feat: improve patient flow and pricing workflow
This commit is contained in:
@@ -50,3 +50,6 @@ app.*.map.json
|
|||||||
|
|
||||||
# Claude Code project settings — local dev tooling only
|
# Claude Code project settings — local dev tooling only
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Codex project settings — local dev tooling only
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ PODS:
|
|||||||
- OneSignalXCFramework/OneSignalNotifications
|
- OneSignalXCFramework/OneSignalNotifications
|
||||||
- OneSignalXCFramework/OneSignalOSCore
|
- OneSignalXCFramework/OneSignalOSCore
|
||||||
- OneSignalXCFramework/OneSignalOutcomes
|
- OneSignalXCFramework/OneSignalOutcomes
|
||||||
|
- path_provider_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- SDWebImage (5.21.7):
|
- SDWebImage (5.21.7):
|
||||||
- SDWebImage/Core (= 5.21.7)
|
- SDWebImage/Core (= 5.21.7)
|
||||||
- SDWebImage/Core (5.21.7)
|
- SDWebImage/Core (5.21.7)
|
||||||
@@ -103,6 +106,7 @@ DEPENDENCIES:
|
|||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`)
|
- onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`)
|
||||||
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
@@ -122,6 +126,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
onesignal_flutter:
|
onesignal_flutter:
|
||||||
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
||||||
|
path_provider_foundation:
|
||||||
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
share_plus:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
@@ -136,6 +142,7 @@ SPEC CHECKSUMS:
|
|||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
onesignal_flutter: 75c70a45a8d97e685273a14f04521ec121611458
|
onesignal_flutter: 75c70a45a8d97e685273a14f04521ec121611458
|
||||||
OneSignalXCFramework: 2f46ff87ccefd9afe8e3b5f9fe357072191205ff
|
OneSignalXCFramework: 2f46ff87ccefd9afe8e3b5f9fe357072191205ff
|
||||||
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
|||||||
@@ -1,25 +1,67 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
const _kAuthKey = 'pb_auth';
|
const _kAuthKey = 'pb_auth';
|
||||||
|
const _kRememberSessionKey = 'remember_session';
|
||||||
|
|
||||||
class PocketBaseClient {
|
class PocketBaseClient {
|
||||||
PocketBaseClient._({required this.pb});
|
PocketBaseClient._({
|
||||||
|
required this.pb,
|
||||||
|
required SharedPreferences prefs,
|
||||||
|
required bool rememberSession,
|
||||||
|
}) : _prefs = prefs,
|
||||||
|
_rememberSession = rememberSession;
|
||||||
static PocketBaseClient? _instance;
|
static PocketBaseClient? _instance;
|
||||||
static PocketBaseClient get instance => _instance!;
|
static PocketBaseClient get instance => _instance!;
|
||||||
final PocketBase pb;
|
final PocketBase pb;
|
||||||
|
final SharedPreferences _prefs;
|
||||||
|
bool _rememberSession;
|
||||||
|
|
||||||
|
bool get rememberSession => _rememberSession;
|
||||||
|
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final remember = prefs.getBool(_kRememberSessionKey) ?? true;
|
||||||
final stored = prefs.getString(_kAuthKey);
|
final stored = prefs.getString(_kAuthKey);
|
||||||
|
|
||||||
final store = AsyncAuthStore(
|
final store = AsyncAuthStore(
|
||||||
save: (String data) => prefs.setString(_kAuthKey, data),
|
save: (String data) async {
|
||||||
initial: stored,
|
final client = _instance;
|
||||||
|
if (client == null || client._rememberSession) {
|
||||||
|
await prefs.setString(_kAuthKey, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prefs.remove(_kAuthKey);
|
||||||
|
},
|
||||||
|
initial: remember ? stored : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
_instance = PocketBaseClient._(
|
_instance = PocketBaseClient._(
|
||||||
pb: PocketBase('https://pocket.kovaksoft.com', authStore: store),
|
pb: PocketBase('https://pocket.kovaksoft.com', authStore: store),
|
||||||
|
prefs: prefs,
|
||||||
|
rememberSession: remember,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!remember && stored != null) {
|
||||||
|
await prefs.remove(_kAuthKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRememberSession(bool value) async {
|
||||||
|
_rememberSession = value;
|
||||||
|
await _prefs.setBool(_kRememberSessionKey, value);
|
||||||
|
if (!value) {
|
||||||
|
await _prefs.remove(_kAuthKey);
|
||||||
|
} else if (pb.authStore.isValid) {
|
||||||
|
await _prefs.setString(
|
||||||
|
_kAuthKey,
|
||||||
|
jsonEncode({
|
||||||
|
'token': pb.authStore.token,
|
||||||
|
'model': pb.authStore.record,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ class AuthRepository {
|
|||||||
static final instance = AuthRepository._();
|
static final instance = AuthRepository._();
|
||||||
|
|
||||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||||
|
PocketBaseClient get _client => PocketBaseClient.instance;
|
||||||
|
|
||||||
Future<AuthResult> login(String email, String password) async {
|
Future<AuthResult> login(
|
||||||
|
String email,
|
||||||
|
String password, {
|
||||||
|
required bool rememberSession,
|
||||||
|
}) async {
|
||||||
|
await _client.setRememberSession(rememberSession);
|
||||||
await _pb.collection('users').authWithPassword(email, password);
|
await _pb.collection('users').authWithPassword(email, password);
|
||||||
return _buildAuthResult();
|
return _buildAuthResult();
|
||||||
}
|
}
|
||||||
@@ -43,7 +49,11 @@ class AuthRepository {
|
|||||||
if (firstName != null && firstName.isNotEmpty) 'first_name': firstName,
|
if (firstName != null && firstName.isNotEmpty) 'first_name': firstName,
|
||||||
if (lastName != null && lastName.isNotEmpty) 'last_name': lastName,
|
if (lastName != null && lastName.isNotEmpty) 'last_name': lastName,
|
||||||
});
|
});
|
||||||
return login(email, password);
|
return login(
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
rememberSession: _client.rememberSession,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthResult> refreshSession() async {
|
Future<AuthResult> refreshSession() async {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class AppStrings {
|
|||||||
required this.tenantKindLab,
|
required this.tenantKindLab,
|
||||||
required this.signInWelcome,
|
required this.signInWelcome,
|
||||||
required this.signInSubtitle,
|
required this.signInSubtitle,
|
||||||
|
required this.rememberMe,
|
||||||
required this.emailAddress,
|
required this.emailAddress,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.emailRequired,
|
required this.emailRequired,
|
||||||
@@ -95,6 +96,7 @@ class AppStrings {
|
|||||||
required this.clinicCategory,
|
required this.clinicCategory,
|
||||||
required this.jobsTitle,
|
required this.jobsTitle,
|
||||||
required this.dashboardTitle,
|
required this.dashboardTitle,
|
||||||
|
required this.homeTitle,
|
||||||
required this.productsTitle,
|
required this.productsTitle,
|
||||||
required this.patientsTitle,
|
required this.patientsTitle,
|
||||||
required this.close,
|
required this.close,
|
||||||
@@ -174,6 +176,7 @@ class AppStrings {
|
|||||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
final String signInWelcome;
|
final String signInWelcome;
|
||||||
final String signInSubtitle;
|
final String signInSubtitle;
|
||||||
|
final String rememberMe;
|
||||||
final String emailAddress;
|
final String emailAddress;
|
||||||
final String password;
|
final String password;
|
||||||
final String emailRequired;
|
final String emailRequired;
|
||||||
@@ -213,6 +216,7 @@ class AppStrings {
|
|||||||
final String clinicCategory;
|
final String clinicCategory;
|
||||||
final String jobsTitle;
|
final String jobsTitle;
|
||||||
final String dashboardTitle;
|
final String dashboardTitle;
|
||||||
|
final String homeTitle;
|
||||||
final String productsTitle;
|
final String productsTitle;
|
||||||
final String patientsTitle;
|
final String patientsTitle;
|
||||||
|
|
||||||
@@ -303,6 +307,7 @@ class AppStrings {
|
|||||||
tenantKindLab: 'Laboratuvar',
|
tenantKindLab: 'Laboratuvar',
|
||||||
signInWelcome: 'Tekrar hoş geldiniz',
|
signInWelcome: 'Tekrar hoş geldiniz',
|
||||||
signInSubtitle: 'Hesabınıza giriş yapın',
|
signInSubtitle: 'Hesabınıza giriş yapın',
|
||||||
|
rememberMe: 'Beni hatırla',
|
||||||
emailAddress: 'E-posta adresi',
|
emailAddress: 'E-posta adresi',
|
||||||
password: 'Şifre',
|
password: 'Şifre',
|
||||||
emailRequired: 'E-posta gereklidir',
|
emailRequired: 'E-posta gereklidir',
|
||||||
@@ -338,6 +343,7 @@ class AppStrings {
|
|||||||
clinicCategory: 'KLİNİK',
|
clinicCategory: 'KLİNİK',
|
||||||
jobsTitle: 'İşler',
|
jobsTitle: 'İşler',
|
||||||
dashboardTitle: 'Özet',
|
dashboardTitle: 'Özet',
|
||||||
|
homeTitle: 'Ana Sayfa',
|
||||||
productsTitle: 'Ürünler',
|
productsTitle: 'Ürünler',
|
||||||
patientsTitle: 'Hastalar',
|
patientsTitle: 'Hastalar',
|
||||||
currencyTRY: 'Türk Lirası (₺)',
|
currencyTRY: 'Türk Lirası (₺)',
|
||||||
@@ -410,6 +416,7 @@ class AppStrings {
|
|||||||
tenantKindLab: 'Laboratory',
|
tenantKindLab: 'Laboratory',
|
||||||
signInWelcome: 'Welcome back',
|
signInWelcome: 'Welcome back',
|
||||||
signInSubtitle: 'Sign in to your account',
|
signInSubtitle: 'Sign in to your account',
|
||||||
|
rememberMe: 'Remember me',
|
||||||
emailAddress: 'Email address',
|
emailAddress: 'Email address',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
emailRequired: 'Email is required',
|
emailRequired: 'Email is required',
|
||||||
@@ -445,6 +452,7 @@ class AppStrings {
|
|||||||
clinicCategory: 'CLINIC',
|
clinicCategory: 'CLINIC',
|
||||||
jobsTitle: 'Jobs',
|
jobsTitle: 'Jobs',
|
||||||
dashboardTitle: 'Overview',
|
dashboardTitle: 'Overview',
|
||||||
|
homeTitle: 'Home',
|
||||||
productsTitle: 'Products',
|
productsTitle: 'Products',
|
||||||
patientsTitle: 'Patients',
|
patientsTitle: 'Patients',
|
||||||
currencyTRY: 'Turkish Lira (₺)',
|
currencyTRY: 'Turkish Lira (₺)',
|
||||||
@@ -517,6 +525,7 @@ class AppStrings {
|
|||||||
tenantKindLab: 'Лаборатория',
|
tenantKindLab: 'Лаборатория',
|
||||||
signInWelcome: 'Добро пожаловать',
|
signInWelcome: 'Добро пожаловать',
|
||||||
signInSubtitle: 'Войдите в свой аккаунт',
|
signInSubtitle: 'Войдите в свой аккаунт',
|
||||||
|
rememberMe: 'Запомнить меня',
|
||||||
emailAddress: 'Адрес эл. почты',
|
emailAddress: 'Адрес эл. почты',
|
||||||
password: 'Пароль',
|
password: 'Пароль',
|
||||||
emailRequired: 'Эл. почта обязательна',
|
emailRequired: 'Эл. почта обязательна',
|
||||||
@@ -552,6 +561,7 @@ class AppStrings {
|
|||||||
clinicCategory: 'КЛИНИКА',
|
clinicCategory: 'КЛИНИКА',
|
||||||
jobsTitle: 'Заказы',
|
jobsTitle: 'Заказы',
|
||||||
dashboardTitle: 'Обзор',
|
dashboardTitle: 'Обзор',
|
||||||
|
homeTitle: 'Главная',
|
||||||
productsTitle: 'Продукты',
|
productsTitle: 'Продукты',
|
||||||
patientsTitle: 'Пациенты',
|
patientsTitle: 'Пациенты',
|
||||||
currencyTRY: 'Турецкая лира (₺)',
|
currencyTRY: 'Турецкая лира (₺)',
|
||||||
@@ -624,6 +634,7 @@ class AppStrings {
|
|||||||
tenantKindLab: 'مختبر',
|
tenantKindLab: 'مختبر',
|
||||||
signInWelcome: 'مرحباً بعودتك',
|
signInWelcome: 'مرحباً بعودتك',
|
||||||
signInSubtitle: 'سجّل دخولك إلى حسابك',
|
signInSubtitle: 'سجّل دخولك إلى حسابك',
|
||||||
|
rememberMe: 'تذكرني',
|
||||||
emailAddress: 'البريد الإلكتروني',
|
emailAddress: 'البريد الإلكتروني',
|
||||||
password: 'كلمة المرور',
|
password: 'كلمة المرور',
|
||||||
emailRequired: 'البريد الإلكتروني مطلوب',
|
emailRequired: 'البريد الإلكتروني مطلوب',
|
||||||
@@ -659,6 +670,7 @@ class AppStrings {
|
|||||||
clinicCategory: 'العيادة',
|
clinicCategory: 'العيادة',
|
||||||
jobsTitle: 'الأعمال',
|
jobsTitle: 'الأعمال',
|
||||||
dashboardTitle: 'نظرة عامة',
|
dashboardTitle: 'نظرة عامة',
|
||||||
|
homeTitle: 'الرئيسية',
|
||||||
productsTitle: 'المنتجات',
|
productsTitle: 'المنتجات',
|
||||||
patientsTitle: 'المرضى',
|
patientsTitle: 'المرضى',
|
||||||
currencyTRY: 'ليرة تركية (₺)',
|
currencyTRY: 'ليرة تركية (₺)',
|
||||||
@@ -731,6 +743,7 @@ class AppStrings {
|
|||||||
tenantKindLab: 'Labor',
|
tenantKindLab: 'Labor',
|
||||||
signInWelcome: 'Willkommen zurück',
|
signInWelcome: 'Willkommen zurück',
|
||||||
signInSubtitle: 'Melden Sie sich in Ihrem Konto an',
|
signInSubtitle: 'Melden Sie sich in Ihrem Konto an',
|
||||||
|
rememberMe: 'Angemeldet bleiben',
|
||||||
emailAddress: 'E-Mail-Adresse',
|
emailAddress: 'E-Mail-Adresse',
|
||||||
password: 'Passwort',
|
password: 'Passwort',
|
||||||
emailRequired: 'E-Mail ist erforderlich',
|
emailRequired: 'E-Mail ist erforderlich',
|
||||||
@@ -766,6 +779,7 @@ class AppStrings {
|
|||||||
clinicCategory: 'KLINIK',
|
clinicCategory: 'KLINIK',
|
||||||
jobsTitle: 'Aufträge',
|
jobsTitle: 'Aufträge',
|
||||||
dashboardTitle: 'Übersicht',
|
dashboardTitle: 'Übersicht',
|
||||||
|
homeTitle: 'Startseite',
|
||||||
productsTitle: 'Produkte',
|
productsTitle: 'Produkte',
|
||||||
patientsTitle: 'Patienten',
|
patientsTitle: 'Patienten',
|
||||||
currencyTRY: 'Türkische Lira (₺)',
|
currencyTRY: 'Türkische Lira (₺)',
|
||||||
|
|||||||
@@ -78,10 +78,18 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signIn(String email, String password) async {
|
Future<void> signIn(
|
||||||
|
String email,
|
||||||
|
String password, {
|
||||||
|
required bool rememberSession,
|
||||||
|
}) async {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
try {
|
try {
|
||||||
final result = await _repo.login(email, password);
|
final result = await _repo.login(
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
rememberSession: rememberSession,
|
||||||
|
);
|
||||||
state = AuthState(
|
state = AuthState(
|
||||||
profile: result.user,
|
profile: result.user,
|
||||||
memberships: result.tenants,
|
memberships: result.tenants,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../l10n/app_strings.dart';
|
||||||
|
import '../providers/locale_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/tooth_logo.dart';
|
import '../widgets/tooth_logo.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
@@ -213,63 +215,60 @@ class _ClinicShell extends ConsumerStatefulWidget {
|
|||||||
class _ClinicShellState extends ConsumerState<_ClinicShell> {
|
class _ClinicShellState extends ConsumerState<_ClinicShell> {
|
||||||
String _selectedRoute = routeClinicDashboard;
|
String _selectedRoute = routeClinicDashboard;
|
||||||
|
|
||||||
// Top-level singles before groups
|
List<_NavItem> _clinicTopSingles(AppStrings s) => [
|
||||||
static final _topSingles = [
|
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
|
||||||
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
|
||||||
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true),
|
||||||
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
|
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
|
||||||
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
|
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true),
|
||||||
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dropdown groups
|
List<_NavGroup> _clinicGroups(AppStrings s) => [
|
||||||
static final _groups = [
|
|
||||||
_NavGroup(
|
_NavGroup(
|
||||||
title: 'Yönetim',
|
title: s.management,
|
||||||
icon: Icons.tune_rounded,
|
icon: Icons.tune_rounded,
|
||||||
selectedIcon: Icons.tune_rounded,
|
selectedIcon: Icons.tune_rounded,
|
||||||
items: [
|
items: [
|
||||||
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true),
|
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true),
|
||||||
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true),
|
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Singles after groups
|
List<_NavItem> _clinicBottomSingles(AppStrings s) => [
|
||||||
static final _bottomSingles = [
|
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
|
||||||
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mobile bottom nav: core items; others accessed from settings
|
List<_NavItem> _clinicMobileItems(AppStrings s) => [
|
||||||
static final _mobileItems = [
|
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
|
||||||
_NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
|
||||||
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true),
|
||||||
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true),
|
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
|
||||||
_NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
|
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
|
||||||
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
List<_SidebarEntry> _allEntries() {
|
List<_SidebarEntry> _allEntries(AppStrings s) {
|
||||||
final membership = ref.read(authProvider).activeTenant;
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
final entries = <_SidebarEntry>[];
|
final entries = <_SidebarEntry>[];
|
||||||
for (final s in _topSingles) {
|
for (final item in _clinicTopSingles(s)) {
|
||||||
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||||||
}
|
}
|
||||||
for (final g in _groups) {
|
for (final group in _clinicGroups(s)) {
|
||||||
if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g));
|
if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group));
|
||||||
}
|
}
|
||||||
for (final s in _bottomSingles) {
|
for (final item in _clinicBottomSingles(s)) {
|
||||||
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final s = ref.watch(stringsProvider);
|
||||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
final entries = _allEntries();
|
final entries = _allEntries(s);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: Row(
|
body: Row(
|
||||||
@@ -290,7 +289,7 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
|
|||||||
|
|
||||||
// Mobile: only core items in bottom nav
|
// Mobile: only core items in bottom nav
|
||||||
final membership = ref.read(authProvider).activeTenant;
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
final items = _mobileItems.where((it) => it.visible(membership)).toList();
|
final items = _clinicMobileItems(s).where((it) => it.visible(membership)).toList();
|
||||||
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
|
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
|
||||||
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
|
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
|
||||||
|
|
||||||
@@ -339,64 +338,61 @@ class _LabShell extends ConsumerStatefulWidget {
|
|||||||
class _LabShellState extends ConsumerState<_LabShell> {
|
class _LabShellState extends ConsumerState<_LabShell> {
|
||||||
String _selectedRoute = routeLabDashboard;
|
String _selectedRoute = routeLabDashboard;
|
||||||
|
|
||||||
// Top-level singles before groups
|
List<_NavItem> _labTopSingles(AppStrings s) => [
|
||||||
static final _topSingles = [
|
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
|
||||||
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
|
||||||
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true),
|
||||||
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
|
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
|
||||||
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
|
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true),
|
||||||
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dropdown groups
|
List<_NavGroup> _labGroups(AppStrings s) => [
|
||||||
static final _groups = [
|
|
||||||
_NavGroup(
|
_NavGroup(
|
||||||
title: 'Yönetim',
|
title: s.management,
|
||||||
icon: Icons.tune_rounded,
|
icon: Icons.tune_rounded,
|
||||||
selectedIcon: Icons.tune_rounded,
|
selectedIcon: Icons.tune_rounded,
|
||||||
items: [
|
items: [
|
||||||
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true),
|
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true),
|
||||||
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: 'İndirimler', visible: (_) => true),
|
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: s.discounts, visible: (_) => true),
|
||||||
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true),
|
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Singles after groups
|
List<_NavItem> _labBottomSingles(AppStrings s) => [
|
||||||
static final _bottomSingles = [
|
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
|
||||||
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mobile bottom nav: core items; others accessed from settings
|
List<_NavItem> _labMobileItems(AppStrings s) => [
|
||||||
static final _mobileItems = [
|
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true),
|
||||||
_NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true),
|
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true),
|
||||||
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true),
|
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true),
|
||||||
_NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true),
|
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true),
|
||||||
_NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true),
|
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true),
|
||||||
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
List<_SidebarEntry> _allEntries() {
|
List<_SidebarEntry> _allEntries(AppStrings s) {
|
||||||
final membership = ref.read(authProvider).activeTenant;
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
final entries = <_SidebarEntry>[];
|
final entries = <_SidebarEntry>[];
|
||||||
for (final s in _topSingles) {
|
for (final item in _labTopSingles(s)) {
|
||||||
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||||||
}
|
}
|
||||||
for (final g in _groups) {
|
for (final group in _labGroups(s)) {
|
||||||
if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g));
|
if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group));
|
||||||
}
|
}
|
||||||
for (final s in _bottomSingles) {
|
for (final item in _labBottomSingles(s)) {
|
||||||
if (s.visible(membership)) entries.add(_SidebarSingleEntry(s));
|
if (item.visible(membership)) entries.add(_SidebarSingleEntry(item));
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final s = ref.watch(stringsProvider);
|
||||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
final entries = _allEntries();
|
final entries = _allEntries(s);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: Row(
|
body: Row(
|
||||||
@@ -417,7 +413,7 @@ class _LabShellState extends ConsumerState<_LabShell> {
|
|||||||
|
|
||||||
// Mobile: only core items in bottom nav
|
// Mobile: only core items in bottom nav
|
||||||
final membership = ref.read(authProvider).activeTenant;
|
final membership = ref.read(authProvider).activeTenant;
|
||||||
final items = _mobileItems.where((it) => it.visible(membership)).toList();
|
final items = _labMobileItems(s).where((it) => it.visible(membership)).toList();
|
||||||
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
|
final flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
|
||||||
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
|
final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
|
|
||||||
|
import '../api/pocketbase_client.dart';
|
||||||
|
|
||||||
|
class FinanceService {
|
||||||
|
FinanceService._();
|
||||||
|
static final instance = FinanceService._();
|
||||||
|
|
||||||
|
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||||
|
|
||||||
|
Future<void> ensureEntriesForJob({
|
||||||
|
required String jobId,
|
||||||
|
required String clinicTenantId,
|
||||||
|
required String labTenantId,
|
||||||
|
required String clinicName,
|
||||||
|
required String labName,
|
||||||
|
required double amount,
|
||||||
|
required String currency,
|
||||||
|
}) async {
|
||||||
|
if (amount <= 0) return;
|
||||||
|
|
||||||
|
final existing = await _pb.collection('finance_entries').getFullList(
|
||||||
|
filter: 'job_id = "$jobId"',
|
||||||
|
batch: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _upsertEntry(
|
||||||
|
existing: existing,
|
||||||
|
jobId: jobId,
|
||||||
|
tenantId: clinicTenantId,
|
||||||
|
counterpartyTenantId: labTenantId,
|
||||||
|
counterpartyName: labName,
|
||||||
|
type: 'payable',
|
||||||
|
amount: amount,
|
||||||
|
currency: currency,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _upsertEntry(
|
||||||
|
existing: existing,
|
||||||
|
jobId: jobId,
|
||||||
|
tenantId: labTenantId,
|
||||||
|
counterpartyTenantId: clinicTenantId,
|
||||||
|
counterpartyName: clinicName,
|
||||||
|
type: 'receivable',
|
||||||
|
amount: amount,
|
||||||
|
currency: currency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markJobPaid(String jobId) async {
|
||||||
|
final existing = await _pb.collection('finance_entries').getFullList(
|
||||||
|
filter: 'job_id = "$jobId"',
|
||||||
|
batch: 200,
|
||||||
|
);
|
||||||
|
final paidAt = DateTime.now().toIso8601String();
|
||||||
|
for (final record in existing) {
|
||||||
|
await _pb.collection('finance_entries').update(
|
||||||
|
record.id,
|
||||||
|
body: {
|
||||||
|
'status': 'paid',
|
||||||
|
'paid_at': paidAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePendingEntriesForJob(String jobId) async {
|
||||||
|
final existing = await _pb.collection('finance_entries').getFullList(
|
||||||
|
filter: 'job_id = "$jobId" && status = "pending"',
|
||||||
|
batch: 200,
|
||||||
|
);
|
||||||
|
for (final record in existing) {
|
||||||
|
await _pb.collection('finance_entries').delete(record.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertEntry({
|
||||||
|
required List<RecordModel> existing,
|
||||||
|
required String jobId,
|
||||||
|
required String tenantId,
|
||||||
|
required String counterpartyTenantId,
|
||||||
|
required String counterpartyName,
|
||||||
|
required String type,
|
||||||
|
required double amount,
|
||||||
|
required String currency,
|
||||||
|
}) async {
|
||||||
|
RecordModel? match;
|
||||||
|
try {
|
||||||
|
match = existing.firstWhere(
|
||||||
|
(record) =>
|
||||||
|
record.data['tenant_id'] == tenantId &&
|
||||||
|
record.data['type'] == type,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
match = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = {
|
||||||
|
'tenant_id': tenantId,
|
||||||
|
'job_id': jobId,
|
||||||
|
'type': type,
|
||||||
|
'amount': amount,
|
||||||
|
'currency': currency,
|
||||||
|
'status': 'pending',
|
||||||
|
'paid_at': null,
|
||||||
|
'counterparty_tenant_id': counterpartyTenantId,
|
||||||
|
'counterparty_name': counterpartyName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (match == null) {
|
||||||
|
await _pb.collection('finance_entries').create(body: body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pb.collection('finance_entries').update(match.id, body: body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import '../../models/clinic_discount.dart';
|
||||||
|
import '../../models/job.dart';
|
||||||
|
import '../../models/prosthetic_product.dart';
|
||||||
|
|
||||||
|
class PricingBreakdown {
|
||||||
|
const PricingBreakdown({
|
||||||
|
required this.billableUnits,
|
||||||
|
required this.unitPrice,
|
||||||
|
required this.baseAmount,
|
||||||
|
required this.discountAmount,
|
||||||
|
required this.finalAmount,
|
||||||
|
required this.appliedDiscounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int billableUnits;
|
||||||
|
final double unitPrice;
|
||||||
|
final double baseAmount;
|
||||||
|
final double discountAmount;
|
||||||
|
final double finalAmount;
|
||||||
|
final List<ClinicDiscount> appliedDiscounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PricingService {
|
||||||
|
PricingService._();
|
||||||
|
static final instance = PricingService._();
|
||||||
|
|
||||||
|
int billableUnitsForType(ProstheticType type, int memberCount) {
|
||||||
|
final safeCount = memberCount <= 0 ? 1 : memberCount;
|
||||||
|
return switch (type) {
|
||||||
|
ProstheticType.tamProtez || ProstheticType.parsiyel => 1,
|
||||||
|
_ => safeCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String unitLabelForType(ProstheticType type) {
|
||||||
|
return switch (type) {
|
||||||
|
ProstheticType.tamProtez || ProstheticType.parsiyel => 'vaka',
|
||||||
|
_ => 'diş',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PricingBreakdown calculate({
|
||||||
|
required ProstheticProduct product,
|
||||||
|
required ProstheticType prostheticType,
|
||||||
|
required int memberCount,
|
||||||
|
required String clinicTenantId,
|
||||||
|
required List<ClinicDiscount> discounts,
|
||||||
|
}) {
|
||||||
|
final billableUnits = billableUnitsForType(prostheticType, memberCount);
|
||||||
|
final unitPrice = product.unitPrice ?? 0;
|
||||||
|
final baseAmount = unitPrice * billableUnits;
|
||||||
|
|
||||||
|
final applicable = discounts.where((discount) {
|
||||||
|
if (!discount.isActive) return false;
|
||||||
|
if (!(discount.appliesToAll || discount.clinicTenantId == clinicTenantId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(discount.appliesToAllTypes ||
|
||||||
|
discount.prostheticType == prostheticType.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (discount.minQuantity > 0 && billableUnits < discount.minQuantity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
double running = baseAmount;
|
||||||
|
for (final discount in applicable) {
|
||||||
|
running = discount.discountType == DiscountType.percentage
|
||||||
|
? running * (1 - discount.discountValue / 100)
|
||||||
|
: running - discount.discountValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final finalAmount = running.clamp(0, double.infinity).toDouble();
|
||||||
|
return PricingBreakdown(
|
||||||
|
billableUnits: billableUnits,
|
||||||
|
unitPrice: unitPrice,
|
||||||
|
baseAmount: baseAmount,
|
||||||
|
discountAmount: (baseAmount - finalAmount)
|
||||||
|
.clamp(0, double.infinity)
|
||||||
|
.toDouble(),
|
||||||
|
finalAmount: finalAmount,
|
||||||
|
appliedDiscounts: applicable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/l10n/app_strings.dart';
|
import '../../core/l10n/app_strings.dart';
|
||||||
|
import '../../core/api/pocketbase_client.dart';
|
||||||
import '../../core/providers/auth_provider.dart';
|
import '../../core/providers/auth_provider.dart';
|
||||||
import '../../core/providers/locale_provider.dart';
|
import '../../core/providers/locale_provider.dart';
|
||||||
import '../../core/router/app_router.dart';
|
import '../../core/router/app_router.dart';
|
||||||
@@ -22,6 +23,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
final _emailCtrl = TextEditingController();
|
final _emailCtrl = TextEditingController();
|
||||||
final _passCtrl = TextEditingController();
|
final _passCtrl = TextEditingController();
|
||||||
bool _obscure = true;
|
bool _obscure = true;
|
||||||
|
bool _rememberMe = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_rememberMe = PocketBaseClient.instance.rememberSession;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -34,7 +42,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
await ref
|
await ref
|
||||||
.read(authProvider.notifier)
|
.read(authProvider.notifier)
|
||||||
.signIn(_emailCtrl.text.trim(), _passCtrl.text);
|
.signIn(
|
||||||
|
_emailCtrl.text.trim(),
|
||||||
|
_passCtrl.text,
|
||||||
|
rememberSession: _rememberMe,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -366,6 +378,38 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
(v == null || v.isEmpty) ? s.passwordRequired : null,
|
(v == null || v.isEmpty) ? s.passwordRequired : null,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: auth.isLoading
|
||||||
|
? null
|
||||||
|
: () => setState(() => _rememberMe = !_rememberMe),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _rememberMe,
|
||||||
|
onChanged: auth.isLoading
|
||||||
|
? null
|
||||||
|
: (value) => setState(() => _rememberMe = value ?? true),
|
||||||
|
activeColor: const Color(0xFF0D4C85),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
s.rememberMe,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
if (auth.error != null) ...[
|
if (auth.error != null) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
import '../../../core/api/pocketbase_client.dart';
|
import '../../../core/api/pocketbase_client.dart';
|
||||||
|
import '../../../core/services/finance_service.dart';
|
||||||
import '../../../models/finance_entry.dart';
|
import '../../../models/finance_entry.dart';
|
||||||
|
|
||||||
class ClinicFinanceRepository {
|
class ClinicFinanceRepository {
|
||||||
@@ -40,10 +41,42 @@ class ClinicFinanceRepository {
|
|||||||
return {'pending': pending, 'paid': paid};
|
return {'pending': pending, 'paid': paid};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
|
||||||
|
final entries = await listEntries(tenantId, limit: 300);
|
||||||
|
final map = <String, CounterpartyFinanceSummary>{};
|
||||||
|
|
||||||
|
for (final entry in entries) {
|
||||||
|
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||||
|
final current = map[key];
|
||||||
|
final pending = (current?.pendingAmount ?? 0) +
|
||||||
|
(entry.status == FinanceStatus.pending ? entry.amount : 0);
|
||||||
|
final paid = (current?.paidAmount ?? 0) +
|
||||||
|
(entry.status == FinanceStatus.paid ? entry.amount : 0);
|
||||||
|
map[key] = CounterpartyFinanceSummary(
|
||||||
|
counterpartyTenantId: entry.counterpartyTenantId,
|
||||||
|
counterpartyName: entry.counterpartyName ?? 'Karşı Taraf',
|
||||||
|
currency: entry.currency,
|
||||||
|
pendingAmount: pending,
|
||||||
|
paidAmount: paid,
|
||||||
|
entryCount: (current?.entryCount ?? 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = map.values.toList();
|
||||||
|
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> markPaid(String entryId) async {
|
Future<void> markPaid(String entryId) async {
|
||||||
|
final record = await _pb.collection('finance_entries').getOne(entryId);
|
||||||
|
final jobId = record.data['job_id']?.toString();
|
||||||
|
if (jobId == null || jobId.isEmpty) {
|
||||||
await _pb.collection('finance_entries').update(entryId, body: {
|
await _pb.collection('finance_entries').update(entryId, body: {
|
||||||
'status': 'paid',
|
'status': 'paid',
|
||||||
'paid_at': DateTime.now().toIso8601String(),
|
'paid_at': DateTime.now().toIso8601String(),
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await FinanceService.instance.markJobPaid(jobId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ClinicFinanceScreen extends ConsumerStatefulWidget {
|
|||||||
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late Future<Map<String, double>> _summaryFuture;
|
late Future<_ClinicFinanceHeaderData> _headerFuture;
|
||||||
_FinanceSort _sort = _FinanceSort.newestFirst;
|
_FinanceSort _sort = _FinanceSort.newestFirst;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -45,8 +45,15 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
|||||||
void _loadSummary() {
|
void _loadSummary() {
|
||||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||||
setState(() {
|
setState(() {
|
||||||
_summaryFuture =
|
_headerFuture = Future.wait([
|
||||||
ClinicFinanceRepository.instance.summary(tenantId);
|
ClinicFinanceRepository.instance.summary(tenantId),
|
||||||
|
ClinicFinanceRepository.instance.byCounterparty(tenantId),
|
||||||
|
]).then(
|
||||||
|
(results) => _ClinicFinanceHeaderData(
|
||||||
|
summary: results[0] as Map<String, double>,
|
||||||
|
counterparties: results[1] as List<CounterpartyFinanceSummary>,
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,22 +97,28 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
|||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<Map<String, double>>(
|
FutureBuilder<_ClinicFinanceHeaderData>(
|
||||||
future: _summaryFuture,
|
future: _headerFuture,
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
if (snap.connectionState == ConnectionState.waiting) {
|
if (snap.connectionState == ConnectionState.waiting) {
|
||||||
return const LinearProgressIndicator(
|
return const LinearProgressIndicator(
|
||||||
color: AppColors.accent);
|
color: AppColors.accent);
|
||||||
}
|
}
|
||||||
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
|
final data = snap.data ??
|
||||||
return Padding(
|
const _ClinicFinanceHeaderData(
|
||||||
|
summary: {'pending': 0.0, 'paid': 0.0},
|
||||||
|
counterparties: [],
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
label: s.pendingReceivable,
|
label: s.pendingReceivable,
|
||||||
amount: summary['pending'] ?? 0.0,
|
amount: data.summary['pending'] ?? 0.0,
|
||||||
currencyCode: currencyCode,
|
currencyCode: currencyCode,
|
||||||
color: AppColors.pending,
|
color: AppColors.pending,
|
||||||
bgColor: AppColors.pendingBg,
|
bgColor: AppColors.pendingBg,
|
||||||
@@ -116,7 +129,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
label: s.collected,
|
label: s.collected,
|
||||||
amount: summary['paid'] ?? 0.0,
|
amount: data.summary['paid'] ?? 0.0,
|
||||||
currencyCode: currencyCode,
|
currencyCode: currencyCode,
|
||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
bgColor: AppColors.successBg,
|
bgColor: AppColors.successBg,
|
||||||
@@ -125,6 +138,17 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (data.counterparties.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: _CounterpartySummaryList(
|
||||||
|
title: 'Laboratuvar Bazlı Borç',
|
||||||
|
items: data.counterparties,
|
||||||
|
currencyCode: currencyCode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -158,6 +182,16 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ClinicFinanceHeaderData {
|
||||||
|
const _ClinicFinanceHeaderData({
|
||||||
|
required this.summary,
|
||||||
|
required this.counterparties,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, double> summary;
|
||||||
|
final List<CounterpartyFinanceSummary> counterparties;
|
||||||
|
}
|
||||||
|
|
||||||
class _SummaryCard extends StatelessWidget {
|
class _SummaryCard extends StatelessWidget {
|
||||||
const _SummaryCard({
|
const _SummaryCard({
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -532,3 +566,66 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CounterpartySummaryList extends StatelessWidget {
|
||||||
|
const _CounterpartySummaryList({
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
required this.currencyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<CounterpartyFinanceSummary> items;
|
||||||
|
final String currencyCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final item in items.take(5)) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.counterpartyName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
CurrencyFormatter.format(item.pendingAmount, currencyCode),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.pending,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -352,7 +352,9 @@ class _JobDetailBody extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
job.patientCode,
|
job.patientName?.isNotEmpty == true
|
||||||
|
? job.patientName!
|
||||||
|
: job.patientCode,
|
||||||
style: Theme.of(context).textTheme.headlineSmall
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -368,6 +370,8 @@ class _JobDetailBody extends StatelessWidget {
|
|||||||
|
|
||||||
// Patient + Lab
|
// Patient + Lab
|
||||||
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||||
|
if (job.patientName != null && job.patientName!.isNotEmpty)
|
||||||
|
_InfoRow(label: 'Hasta', value: job.patientName!),
|
||||||
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
||||||
if (job.patientId != null)
|
if (job.patientId != null)
|
||||||
_InfoRow(label: 'Hasta ID', value: job.patientId!),
|
_InfoRow(label: 'Hasta ID', value: job.patientId!),
|
||||||
@@ -378,6 +382,14 @@ class _JobDetailBody extends StatelessWidget {
|
|||||||
// Prosthetic
|
// Prosthetic
|
||||||
_SectionLabel(title: 'Protez Bilgisi'),
|
_SectionLabel(title: 'Protez Bilgisi'),
|
||||||
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
||||||
|
if (job.prostheticName != null && job.prostheticName!.isNotEmpty)
|
||||||
|
_InfoRow(label: 'Ürün', value: job.prostheticName!),
|
||||||
|
if (job.workflowType != null)
|
||||||
|
_InfoRow(label: 'İş Tipi', value: job.workflowType!.label),
|
||||||
|
_InfoRow(
|
||||||
|
label: 'Prova',
|
||||||
|
value: job.provaRequired ? 'Provalı' : 'Provasız',
|
||||||
|
),
|
||||||
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
|
_InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'),
|
||||||
if (job.teeth.isNotEmpty)
|
if (job.teeth.isNotEmpty)
|
||||||
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
|
_InfoRow(label: 'Dişler', value: job.teeth.join(', ')),
|
||||||
@@ -746,4 +758,3 @@ class _StatusBadge extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
import '../../../core/api/pocketbase_client.dart';
|
import '../../../core/api/pocketbase_client.dart';
|
||||||
|
import '../../../core/services/finance_service.dart';
|
||||||
import '../../../core/services/job_history_service.dart';
|
import '../../../core/services/job_history_service.dart';
|
||||||
import '../../../models/job.dart';
|
import '../../../models/job.dart';
|
||||||
|
|
||||||
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
|
const _listExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
|
||||||
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
|
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
|
||||||
|
|
||||||
class ClinicJobsRepository {
|
class ClinicJobsRepository {
|
||||||
@@ -51,14 +52,19 @@ class ClinicJobsRepository {
|
|||||||
Future<Job> createJob({
|
Future<Job> createJob({
|
||||||
required String clinicTenantId,
|
required String clinicTenantId,
|
||||||
required String labTenantId,
|
required String labTenantId,
|
||||||
|
required String clinicName,
|
||||||
|
required String labName,
|
||||||
required String patientCode,
|
required String patientCode,
|
||||||
required String prostheticId,
|
String? prostheticId,
|
||||||
required ProstheticType prostheticType,
|
required ProstheticType prostheticType,
|
||||||
required List<String> teeth,
|
required List<String> teeth,
|
||||||
String? patientId,
|
String? patientId,
|
||||||
String? color,
|
String? color,
|
||||||
String? description,
|
String? description,
|
||||||
String? dueDate,
|
String? dueDate,
|
||||||
|
double? price,
|
||||||
|
String? currency,
|
||||||
|
JobWorkflowType? workflowType,
|
||||||
bool provaRequired = true,
|
bool provaRequired = true,
|
||||||
}) async {
|
}) async {
|
||||||
final record = await _pb.collection('jobs').create(body: {
|
final record = await _pb.collection('jobs').create(body: {
|
||||||
@@ -66,18 +72,38 @@ class ClinicJobsRepository {
|
|||||||
'lab_tenant_id': labTenantId,
|
'lab_tenant_id': labTenantId,
|
||||||
'patient_code': patientCode,
|
'patient_code': patientCode,
|
||||||
if (patientId != null) 'patient_id': patientId,
|
if (patientId != null) 'patient_id': patientId,
|
||||||
'prosthetic_id': prostheticId,
|
if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId,
|
||||||
'prosthetic_type': prostheticType.value,
|
'prosthetic_type': prostheticType.value,
|
||||||
'member_count': teeth.length,
|
'member_count': teeth.length,
|
||||||
'teeth': teeth,
|
'teeth': teeth,
|
||||||
if (color != null) 'color': color,
|
if (color != null) 'color': color,
|
||||||
if (description != null) 'description': description,
|
if (description != null) 'description': description,
|
||||||
if (dueDate != null) 'due_date': dueDate,
|
if (dueDate != null) 'due_date': dueDate,
|
||||||
|
if (price != null) 'price': price,
|
||||||
|
if (currency != null && currency.isNotEmpty) 'currency': currency,
|
||||||
|
if (workflowType != null) 'workflow_type': workflowType.value,
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'location': 'at_clinic',
|
'location': 'at_clinic',
|
||||||
'prova_required': provaRequired,
|
'prova_required': provaRequired,
|
||||||
});
|
});
|
||||||
return Job.fromJson(record.toJson());
|
final job = Job.fromJson(record.toJson());
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
try {
|
||||||
|
await FinanceService.instance.ensureEntriesForJob(
|
||||||
|
jobId: job.id,
|
||||||
|
clinicTenantId: clinicTenantId,
|
||||||
|
labTenantId: labTenantId,
|
||||||
|
clinicName: clinicName,
|
||||||
|
labName: labName,
|
||||||
|
amount: price,
|
||||||
|
currency: currency ?? 'TRY',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
await _pb.collection('jobs').delete(job.id);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
|
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async {
|
||||||
@@ -134,6 +160,7 @@ class ClinicJobsRepository {
|
|||||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||||
'status': 'cancelled',
|
'status': 'cancelled',
|
||||||
});
|
});
|
||||||
|
await FinanceService.instance.deletePendingEntriesForJob(jobId);
|
||||||
unawaited(JobHistoryService.instance.append(
|
unawaited(JobHistoryService.instance.append(
|
||||||
jobId: jobId,
|
jobId: jobId,
|
||||||
clinicTenantId: job.clinicTenantId,
|
clinicTenantId: job.clinicTenantId,
|
||||||
|
|||||||
@@ -252,8 +252,10 @@ class _JobsTabState extends ConsumerState<_JobsTab> {
|
|||||||
if (q.isNotEmpty) {
|
if (q.isNotEmpty) {
|
||||||
list = list.where((j) {
|
list = list.where((j) {
|
||||||
return j.patientCode.toLowerCase().contains(q) ||
|
return j.patientCode.toLowerCase().contains(q) ||
|
||||||
|
(j.patientName?.toLowerCase().contains(q) ?? false) ||
|
||||||
(j.labName?.toLowerCase().contains(q) ?? false) ||
|
(j.labName?.toLowerCase().contains(q) ?? false) ||
|
||||||
j.prostheticType.label.toLowerCase().contains(q);
|
j.prostheticType.label.toLowerCase().contains(q) ||
|
||||||
|
(j.prostheticName?.toLowerCase().contains(q) ?? false);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,13 +382,16 @@ class _JobListCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final title = job.patientName?.trim().isNotEmpty == true
|
||||||
|
? job.patientName!
|
||||||
|
: job.patientCode;
|
||||||
final statusColor = _statusColor(job);
|
final statusColor = _statusColor(job);
|
||||||
final statusBg = _statusBg(job);
|
final statusBg = _statusBg(job);
|
||||||
final isOverdue =
|
final isOverdue =
|
||||||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: job.patientCode,
|
label: title,
|
||||||
button: true,
|
button: true,
|
||||||
excludeSemantics: true,
|
excludeSemantics: true,
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -426,7 +431,7 @@ class _JobListCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
job.patientCode,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -437,9 +442,22 @@ class _JobListCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(job.prostheticType.label,
|
Text(
|
||||||
|
job.prostheticName?.isNotEmpty == true
|
||||||
|
? '${job.prostheticType.label} · ${job.prostheticName}'
|
||||||
|
: job.prostheticType.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12, color: AppColors.textSecondary)),
|
fontSize: 12, color: AppColors.textSecondary)),
|
||||||
|
if (job.patientName?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
job.patientCode,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: AppColors.textMuted),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
if (job.labName != null) ...[
|
if (job.labName != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -8,8 +8,8 @@ import 'package:http/http.dart' as http;
|
|||||||
|
|
||||||
import '../../../core/api/pocketbase_client.dart';
|
import '../../../core/api/pocketbase_client.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
import '../../../core/services/pricing_service.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/clinic_discount.dart';
|
|
||||||
import '../../../models/job.dart';
|
import '../../../models/job.dart';
|
||||||
import '../../../models/patient.dart';
|
import '../../../models/patient.dart';
|
||||||
import '../../../models/prosthetic_product.dart';
|
import '../../../models/prosthetic_product.dart';
|
||||||
@@ -18,6 +18,11 @@ import '../../lab/products/lab_products_repository.dart';
|
|||||||
import 'clinic_jobs_repository.dart';
|
import 'clinic_jobs_repository.dart';
|
||||||
import '../patients/clinic_patients_repository.dart';
|
import '../patients/clinic_patients_repository.dart';
|
||||||
|
|
||||||
|
enum _PatientEntryMode {
|
||||||
|
selectExisting,
|
||||||
|
createNew,
|
||||||
|
}
|
||||||
|
|
||||||
String _mimeFromExt(String ext) => switch (ext) {
|
String _mimeFromExt(String ext) => switch (ext) {
|
||||||
'jpg' || 'jpeg' => 'image/jpeg',
|
'jpg' || 'jpeg' => 'image/jpeg',
|
||||||
'png' => 'image/png',
|
'png' => 'image/png',
|
||||||
@@ -46,13 +51,17 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
// Form fields
|
// Form fields
|
||||||
Map<String, dynamic>? _selectedLab;
|
Map<String, dynamic>? _selectedLab;
|
||||||
Patient? _selectedPatient;
|
Patient? _selectedPatient;
|
||||||
|
final _patientNameController = TextEditingController();
|
||||||
|
final _patientLastNameController = TextEditingController();
|
||||||
final _patientCodeController = TextEditingController();
|
final _patientCodeController = TextEditingController();
|
||||||
ProstheticType? _selectedProstheticType;
|
ProstheticType? _selectedProstheticType;
|
||||||
|
JobWorkflowType? _selectedWorkflowType = JobWorkflowType.geleneksel;
|
||||||
final Set<int> _selectedTeeth = {};
|
final Set<int> _selectedTeeth = {};
|
||||||
final _colorController = TextEditingController();
|
final _colorController = TextEditingController();
|
||||||
final _descriptionController = TextEditingController();
|
final _descriptionController = TextEditingController();
|
||||||
DateTime? _dueDate;
|
DateTime? _dueDate;
|
||||||
bool _provaRequired = true;
|
bool _provaRequired = true;
|
||||||
|
_PatientEntryMode _patientEntryMode = _PatientEntryMode.selectExisting;
|
||||||
|
|
||||||
// State
|
// State
|
||||||
List<Map<String, dynamic>> _labs = [];
|
List<Map<String, dynamic>> _labs = [];
|
||||||
@@ -64,13 +73,16 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
final List<PlatformFile> _pendingFiles = [];
|
final List<PlatformFile> _pendingFiles = [];
|
||||||
|
|
||||||
// Patient search
|
// Patient search
|
||||||
bool _showPatientSearch = false;
|
|
||||||
final _patientSearchController = TextEditingController();
|
final _patientSearchController = TextEditingController();
|
||||||
List<Patient> _patientResults = [];
|
List<Patient> _patientResults = [];
|
||||||
bool _patientSearchLoading = false;
|
bool _patientSearchLoading = false;
|
||||||
|
Timer? _patientSearchDebounce;
|
||||||
|
|
||||||
// Price preview
|
// Price preview
|
||||||
|
List<ProstheticProduct> _availableProducts = [];
|
||||||
|
ProstheticProduct? _selectedProduct;
|
||||||
ProstheticProduct? _labProduct;
|
ProstheticProduct? _labProduct;
|
||||||
|
PricingBreakdown? _pricingBreakdown;
|
||||||
double? _effectivePrice;
|
double? _effectivePrice;
|
||||||
bool _priceLoading = false;
|
bool _priceLoading = false;
|
||||||
|
|
||||||
@@ -82,6 +94,9 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_patientSearchDebounce?.cancel();
|
||||||
|
_patientNameController.dispose();
|
||||||
|
_patientLastNameController.dispose();
|
||||||
_patientCodeController.dispose();
|
_patientCodeController.dispose();
|
||||||
_colorController.dispose();
|
_colorController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
@@ -111,9 +126,15 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchPrice() async {
|
Future<void> _refreshProductsAndPrice() async {
|
||||||
if (_selectedLab == null || _selectedProstheticType == null) {
|
if (_selectedLab == null || _selectedProstheticType == null) {
|
||||||
setState(() { _labProduct = null; _effectivePrice = null; });
|
setState(() {
|
||||||
|
_availableProducts = [];
|
||||||
|
_selectedProduct = null;
|
||||||
|
_labProduct = null;
|
||||||
|
_pricingBreakdown = null;
|
||||||
|
_effectivePrice = null;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final labId = _selectedLab!['id'] as String;
|
final labId = _selectedLab!['id'] as String;
|
||||||
@@ -122,43 +143,73 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
|
|
||||||
setState(() => _priceLoading = true);
|
setState(() => _priceLoading = true);
|
||||||
try {
|
try {
|
||||||
final products = await LabProductsRepository.instance.listProducts(labId, isActive: true);
|
final products = await LabProductsRepository.instance.listProducts(
|
||||||
|
labId,
|
||||||
|
isActive: true,
|
||||||
|
);
|
||||||
|
final matchingProducts = products
|
||||||
|
.where((p) => p.prostheticType == ptValue)
|
||||||
|
.toList();
|
||||||
|
|
||||||
ProstheticProduct? product;
|
ProstheticProduct? product;
|
||||||
|
if (_selectedProduct != null) {
|
||||||
try {
|
try {
|
||||||
product = products.firstWhere((p) => p.prostheticType == ptValue);
|
product = matchingProducts.firstWhere(
|
||||||
|
(p) => p.id == _selectedProduct!.id,
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
product = null;
|
product = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
product ??= matchingProducts.isNotEmpty ? matchingProducts.first : null;
|
||||||
|
|
||||||
if (product == null || product.unitPrice == null) {
|
if (product == null || product.unitPrice == null) {
|
||||||
setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; });
|
setState(() {
|
||||||
|
_availableProducts = matchingProducts;
|
||||||
|
_selectedProduct = product;
|
||||||
|
_labProduct = null;
|
||||||
|
_pricingBreakdown = null;
|
||||||
|
_effectivePrice = null;
|
||||||
|
_priceLoading = false;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final discounts = await DiscountRepository.instance.listDiscounts(labId);
|
final discounts = await DiscountRepository.instance.listDiscounts(labId);
|
||||||
final applicable = discounts.where((d) =>
|
final breakdown = PricingService.instance.calculate(
|
||||||
d.isActive &&
|
product: product,
|
||||||
(d.appliesToAll || d.clinicTenantId == clinicTenantId) &&
|
prostheticType: _selectedProstheticType!,
|
||||||
(d.appliesToAllTypes || d.prostheticType == ptValue)
|
memberCount: _selectedTeeth.length,
|
||||||
).toList();
|
clinicTenantId: clinicTenantId,
|
||||||
|
discounts: discounts,
|
||||||
double price = product.unitPrice!;
|
);
|
||||||
for (final d in applicable) {
|
|
||||||
price = d.discountType == DiscountType.percentage
|
|
||||||
? price * (1 - d.discountValue / 100)
|
|
||||||
: price - d.discountValue;
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_availableProducts = matchingProducts;
|
||||||
|
_selectedProduct = product;
|
||||||
_labProduct = product;
|
_labProduct = product;
|
||||||
_effectivePrice = price.clamp(0, double.infinity);
|
_pricingBreakdown = breakdown;
|
||||||
|
_effectivePrice = breakdown.finalAmount;
|
||||||
_priceLoading = false;
|
_priceLoading = false;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; });
|
setState(() {
|
||||||
|
_availableProducts = [];
|
||||||
|
_selectedProduct = null;
|
||||||
|
_labProduct = null;
|
||||||
|
_pricingBreakdown = null;
|
||||||
|
_effectivePrice = null;
|
||||||
|
_priceLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _searchPatients(String query) async {
|
Future<void> _searchPatients(String query) async {
|
||||||
if (query.trim().isEmpty) {
|
final normalizedQuery = query.trim();
|
||||||
setState(() => _patientResults = []);
|
if (normalizedQuery.length < 2) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_patientResults = [];
|
||||||
|
_patientSearchLoading = false;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _patientSearchLoading = true);
|
setState(() => _patientSearchLoading = true);
|
||||||
@@ -166,16 +217,52 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
final tenantId =
|
final tenantId =
|
||||||
ref.read(authProvider).activeTenant!.tenant.id;
|
ref.read(authProvider).activeTenant!.tenant.id;
|
||||||
final results = await ClinicPatientsRepository.instance
|
final results = await ClinicPatientsRepository.instance
|
||||||
.listPatients(tenantId, search: query, limit: 10);
|
.listPatients(tenantId, search: normalizedQuery, limit: 10);
|
||||||
|
if (!mounted || _patientSearchController.text.trim() != normalizedQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_patientResults = results;
|
_patientResults = results;
|
||||||
_patientSearchLoading = false;
|
_patientSearchLoading = false;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _patientSearchLoading = false);
|
setState(() => _patientSearchLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPatientSearchChanged(String value) {
|
||||||
|
_selectedPatient = null;
|
||||||
|
_patientSearchDebounce?.cancel();
|
||||||
|
final query = value.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
setState(() {
|
||||||
|
_patientResults = [];
|
||||||
|
_patientSearchLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _patientSearchLoading = true);
|
||||||
|
_patientSearchDebounce = Timer(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
() => _searchPatients(query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setPatientEntryMode(_PatientEntryMode mode) {
|
||||||
|
_patientSearchDebounce?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_patientEntryMode = mode;
|
||||||
|
_selectedPatient = null;
|
||||||
|
_patientResults = [];
|
||||||
|
_patientSearchLoading = false;
|
||||||
|
_patientSearchController.clear();
|
||||||
|
_patientNameController.clear();
|
||||||
|
_patientLastNameController.clear();
|
||||||
|
_patientCodeController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickDueDate() async {
|
Future<void> _pickDueDate() async {
|
||||||
final pickedDate = await showDatePicker(
|
final pickedDate = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -231,16 +318,38 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
try {
|
try {
|
||||||
final auth = ref.read(authProvider);
|
final auth = ref.read(authProvider);
|
||||||
final tenantId = auth.activeTenant!.tenant.id;
|
final tenantId = auth.activeTenant!.tenant.id;
|
||||||
|
final clinicName = auth.activeTenant!.tenant.companyName;
|
||||||
final rawCode = _patientCodeController.text.trim();
|
final rawCode = _patientCodeController.text.trim();
|
||||||
final protocolNo = rawCode.isNotEmpty ? rawCode : _generateProtocolNo();
|
final rawFirstName = _patientNameController.text.trim();
|
||||||
|
final rawLastName = _patientLastNameController.text.trim();
|
||||||
|
Patient? patient = _selectedPatient;
|
||||||
|
if (_patientEntryMode == _PatientEntryMode.selectExisting &&
|
||||||
|
patient == null &&
|
||||||
|
_patientSearchController.text.trim().isNotEmpty) {
|
||||||
|
throw 'Lütfen listeden bir hasta seçin veya "Yeni Hasta Oluştur" moduna geçin.';
|
||||||
|
}
|
||||||
|
final protocolNo = patient?.patientCode ??
|
||||||
|
(rawCode.isNotEmpty ? rawCode : _generateProtocolNo());
|
||||||
|
if (_patientEntryMode == _PatientEntryMode.createNew &&
|
||||||
|
patient == null &&
|
||||||
|
(rawFirstName.isNotEmpty || rawLastName.isNotEmpty)) {
|
||||||
|
patient = await ClinicPatientsRepository.instance.createPatient(
|
||||||
|
tenantId: tenantId,
|
||||||
|
patientCode: protocolNo,
|
||||||
|
firstName: rawFirstName.isNotEmpty ? rawFirstName : null,
|
||||||
|
lastName: rawLastName.isNotEmpty ? rawLastName : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
final job = await ClinicJobsRepository.instance.createJob(
|
final job = await ClinicJobsRepository.instance.createJob(
|
||||||
clinicTenantId: tenantId,
|
clinicTenantId: tenantId,
|
||||||
labTenantId: _selectedLab!['id'] as String,
|
labTenantId: _selectedLab!['id'] as String,
|
||||||
|
clinicName: clinicName,
|
||||||
|
labName: _selectedLab!['company_name'] as String? ?? 'Laboratuvar',
|
||||||
patientCode: protocolNo,
|
patientCode: protocolNo,
|
||||||
prostheticId: '',
|
prostheticId: _selectedProduct?.id,
|
||||||
prostheticType: _selectedProstheticType!,
|
prostheticType: _selectedProstheticType!,
|
||||||
teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(),
|
teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(),
|
||||||
patientId: _selectedPatient?.id,
|
patientId: patient?.id,
|
||||||
color: _colorController.text.trim().isNotEmpty
|
color: _colorController.text.trim().isNotEmpty
|
||||||
? _colorController.text.trim()
|
? _colorController.text.trim()
|
||||||
: null,
|
: null,
|
||||||
@@ -248,6 +357,9 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
? _descriptionController.text.trim()
|
? _descriptionController.text.trim()
|
||||||
: null,
|
: null,
|
||||||
dueDate: _dueDate?.toIso8601String(),
|
dueDate: _dueDate?.toIso8601String(),
|
||||||
|
price: _effectivePrice,
|
||||||
|
currency: _labProduct?.currency,
|
||||||
|
workflowType: _selectedWorkflowType,
|
||||||
provaRequired: _provaRequired,
|
provaRequired: _provaRequired,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -343,57 +455,62 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
setState(() => _selectedLab = val);
|
setState(() {
|
||||||
_fetchPrice();
|
_selectedLab = val;
|
||||||
|
_selectedProduct = null;
|
||||||
|
});
|
||||||
|
_refreshProductsAndPrice();
|
||||||
},
|
},
|
||||||
validator: (val) =>
|
validator: (val) =>
|
||||||
val == null ? 'Laboratuvar seçimi zorunludur' : null,
|
val == null ? 'Laboratuvar seçimi zorunludur' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Protocol number
|
_SectionLabel(label: 'Hasta / Protokol'),
|
||||||
_SectionLabel(label: 'Protokol No (İsteğe Bağlı)'),
|
const SizedBox(height: 8),
|
||||||
Row(
|
SegmentedButton<_PatientEntryMode>(
|
||||||
children: [
|
segments: const [
|
||||||
Expanded(
|
ButtonSegment(
|
||||||
child: TextFormField(
|
value: _PatientEntryMode.selectExisting,
|
||||||
controller: _patientCodeController,
|
icon: Icon(Icons.search_rounded),
|
||||||
decoration: InputDecoration(
|
label: Text('Mevcut Hasta'),
|
||||||
hintText: 'Boş bırakılırsa otomatik üretilir',
|
|
||||||
suffixIcon: _selectedPatient != null
|
|
||||||
? const Icon(Icons.person,
|
|
||||||
color: AppColors.success)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
ButtonSegment(
|
||||||
),
|
value: _PatientEntryMode.createNew,
|
||||||
const SizedBox(width: 8),
|
icon: Icon(Icons.person_add_alt_1_rounded),
|
||||||
OutlinedButton.icon(
|
label: Text('Yeni Hasta'),
|
||||||
onPressed: () {
|
|
||||||
setState(() => _showPatientSearch = !_showPatientSearch);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
label: const Text('Ara'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
selected: {_patientEntryMode},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
_setPatientEntryMode(selection.first);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
if (_patientEntryMode == _PatientEntryMode.selectExisting) ...[
|
||||||
// Patient search panel
|
|
||||||
if (_showPatientSearch) ...[
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _patientSearchController,
|
controller: _patientSearchController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Ad, soyad veya kod ile arayın...',
|
hintText: 'Ad, soyad veya kod ile arayın...',
|
||||||
prefixIcon: Icon(Icons.search),
|
prefixIcon: Icon(Icons.search),
|
||||||
|
helperText: 'Yazmaya başlayınca otomatik arar',
|
||||||
),
|
),
|
||||||
onChanged: _searchPatients,
|
onChanged: _onPatientSearchChanged,
|
||||||
),
|
),
|
||||||
if (_patientSearchLoading)
|
if (_patientSearchLoading)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
|
if (!_patientSearchLoading &&
|
||||||
|
_patientSearchController.text.trim().length >= 2 &&
|
||||||
|
_patientResults.isEmpty)
|
||||||
|
const ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(Icons.info_outline),
|
||||||
|
title: Text('Hasta bulunamadı'),
|
||||||
|
subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
|
||||||
|
),
|
||||||
..._patientResults.map(
|
..._patientResults.map(
|
||||||
(p) => ListTile(
|
(p) => ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
@@ -403,14 +520,93 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedPatient = p;
|
_selectedPatient = p;
|
||||||
|
_patientNameController.text = p.firstName ?? '';
|
||||||
|
_patientLastNameController.text = p.lastName ?? '';
|
||||||
_patientCodeController.text = p.patientCode;
|
_patientCodeController.text = p.patientCode;
|
||||||
_showPatientSearch = false;
|
|
||||||
_patientSearchController.clear();
|
|
||||||
_patientResults.clear();
|
_patientResults.clear();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_selectedPatient != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.successBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.success.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person, color: AppColors.success),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_selectedPatient!.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_selectedPatient!.patientCode,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.success.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedPatient = null;
|
||||||
|
_patientNameController.clear();
|
||||||
|
_patientLastNameController.clear();
|
||||||
|
_patientCodeController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Temizle'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _patientNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Hasta adı',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _patientLastNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Hasta soyadı',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _patientCodeController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Protokol no (boş bırakılırsa otomatik üretilir)',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -430,12 +626,69 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
setState(() => _selectedProstheticType = val);
|
setState(() {
|
||||||
_fetchPrice();
|
_selectedProstheticType = val;
|
||||||
|
_selectedProduct = null;
|
||||||
|
});
|
||||||
|
_refreshProductsAndPrice();
|
||||||
},
|
},
|
||||||
validator: (val) =>
|
validator: (val) =>
|
||||||
val == null ? 'Protez türü zorunludur' : null,
|
val == null ? 'Protez türü zorunludur' : null,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_SectionLabel(label: 'Ürün'),
|
||||||
|
DropdownButtonFormField<ProstheticProduct>(
|
||||||
|
initialValue: _selectedProduct,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: _selectedProstheticType == null
|
||||||
|
? 'Önce protez türü seçin'
|
||||||
|
: _availableProducts.isEmpty
|
||||||
|
? 'Bu tür için ürün bulunamadı'
|
||||||
|
: 'Ürün seçin',
|
||||||
|
),
|
||||||
|
items: _availableProducts
|
||||||
|
.map(
|
||||||
|
(product) => DropdownMenuItem(
|
||||||
|
value: product,
|
||||||
|
child: Text(product.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty)
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _selectedProduct = val);
|
||||||
|
_refreshProductsAndPrice();
|
||||||
|
},
|
||||||
|
validator: (val) {
|
||||||
|
if (_availableProducts.isNotEmpty && val == null) {
|
||||||
|
return 'Lütfen ürün seçin';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_SectionLabel(label: 'İş Tipi'),
|
||||||
|
DropdownButtonFormField<JobWorkflowType>(
|
||||||
|
initialValue: _selectedWorkflowType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'İş tipi seçin',
|
||||||
|
),
|
||||||
|
items: JobWorkflowType.values
|
||||||
|
.map(
|
||||||
|
(type) => DropdownMenuItem(
|
||||||
|
value: type,
|
||||||
|
child: Text(type.label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) =>
|
||||||
|
setState(() => _selectedWorkflowType = val),
|
||||||
|
validator: (val) =>
|
||||||
|
val == null ? 'Lütfen iş tipi seçin' : null,
|
||||||
|
),
|
||||||
// Price preview
|
// Price preview
|
||||||
if (_priceLoading)
|
if (_priceLoading)
|
||||||
const Padding(
|
const Padding(
|
||||||
@@ -450,6 +703,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_PricePreviewChip(
|
_PricePreviewChip(
|
||||||
product: _labProduct!,
|
product: _labProduct!,
|
||||||
|
prostheticType: _selectedProstheticType,
|
||||||
|
breakdown: _pricingBreakdown,
|
||||||
effectivePrice: _effectivePrice!,
|
effectivePrice: _effectivePrice!,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -471,31 +726,43 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
// Bulk select row
|
// Bulk select row
|
||||||
_TeethBulkBar(
|
_TeethBulkBar(
|
||||||
selectedTeeth: _selectedTeeth,
|
selectedTeeth: _selectedTeeth,
|
||||||
onSelectAll: () => setState(() {
|
onSelectAll: () {
|
||||||
|
setState(() {
|
||||||
_selectedTeeth.addAll([
|
_selectedTeeth.addAll([
|
||||||
for (int i = 11; i <= 18; i++) i,
|
for (int i = 11; i <= 18; i++) i,
|
||||||
for (int i = 21; i <= 28; i++) i,
|
for (int i = 21; i <= 28; i++) i,
|
||||||
for (int i = 31; i <= 38; i++) i,
|
for (int i = 31; i <= 38; i++) i,
|
||||||
for (int i = 41; i <= 48; i++) i,
|
for (int i = 41; i <= 48; i++) i,
|
||||||
]);
|
]);
|
||||||
}),
|
});
|
||||||
onSelectUpper: () => setState(() {
|
_refreshProductsAndPrice();
|
||||||
|
},
|
||||||
|
onSelectUpper: () {
|
||||||
|
setState(() {
|
||||||
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]};
|
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]};
|
||||||
if (upper.every(_selectedTeeth.contains)) {
|
if (upper.every(_selectedTeeth.contains)) {
|
||||||
_selectedTeeth.removeAll(upper);
|
_selectedTeeth.removeAll(upper);
|
||||||
} else {
|
} else {
|
||||||
_selectedTeeth.addAll(upper);
|
_selectedTeeth.addAll(upper);
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
onSelectLower: () => setState(() {
|
_refreshProductsAndPrice();
|
||||||
|
},
|
||||||
|
onSelectLower: () {
|
||||||
|
setState(() {
|
||||||
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]};
|
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]};
|
||||||
if (lower.every(_selectedTeeth.contains)) {
|
if (lower.every(_selectedTeeth.contains)) {
|
||||||
_selectedTeeth.removeAll(lower);
|
_selectedTeeth.removeAll(lower);
|
||||||
} else {
|
} else {
|
||||||
_selectedTeeth.addAll(lower);
|
_selectedTeeth.addAll(lower);
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
onClear: () => setState(() => _selectedTeeth.clear()),
|
_refreshProductsAndPrice();
|
||||||
|
},
|
||||||
|
onClear: () {
|
||||||
|
setState(() => _selectedTeeth.clear());
|
||||||
|
_refreshProductsAndPrice();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_TeethGrid(
|
_TeethGrid(
|
||||||
@@ -508,6 +775,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
|||||||
_selectedTeeth.add(t);
|
_selectedTeeth.add(t);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_refreshProductsAndPrice();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -908,16 +1176,28 @@ class _FilePicker extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PricePreviewChip extends StatelessWidget {
|
class _PricePreviewChip extends StatelessWidget {
|
||||||
const _PricePreviewChip({required this.product, required this.effectivePrice});
|
const _PricePreviewChip({
|
||||||
|
required this.product,
|
||||||
|
required this.effectivePrice,
|
||||||
|
this.prostheticType,
|
||||||
|
this.breakdown,
|
||||||
|
});
|
||||||
|
|
||||||
final ProstheticProduct product;
|
final ProstheticProduct product;
|
||||||
final double effectivePrice;
|
final double effectivePrice;
|
||||||
|
final ProstheticType? prostheticType;
|
||||||
|
final PricingBreakdown? breakdown;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currency = product.currency ?? 'TRY';
|
final currency = product.currency ?? 'TRY';
|
||||||
final unitPrice = product.unitPrice!;
|
final unitPrice = product.unitPrice!;
|
||||||
final hasDiscount = (effectivePrice - unitPrice).abs() > 0.01;
|
final hasDiscount = (breakdown?.discountAmount ?? 0) > 0.01;
|
||||||
|
final units = breakdown?.billableUnits ?? 1;
|
||||||
|
final unitLabel = prostheticType != null
|
||||||
|
? PricingService.instance.unitLabelForType(prostheticType!)
|
||||||
|
: 'adet';
|
||||||
|
final baseAmount = breakdown?.baseAmount ?? unitPrice;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -938,9 +1218,13 @@ class _PricePreviewChip extends StatelessWidget {
|
|||||||
'${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency',
|
'${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency',
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success),
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success),
|
||||||
),
|
),
|
||||||
|
Text(
|
||||||
|
'${unitPrice.toStringAsFixed(2)} $currency x $units $unitLabel',
|
||||||
|
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||||
|
),
|
||||||
if (hasDiscount)
|
if (hasDiscount)
|
||||||
Text(
|
Text(
|
||||||
'Liste: ${unitPrice.toStringAsFixed(2)} $currency · İndirim uygulandı',
|
'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı',
|
||||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -742,14 +742,14 @@ class _DiscountSheetState extends State<_DiscountSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
const Text('Minimum Sipariş Adedi (İsteğe Bağlı)',
|
const Text('Minimum Faturalanabilir Adet (İsteğe Bağlı)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary)),
|
color: AppColors.textSecondary)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
const Text(
|
||||||
'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.',
|
'İş bazında diş/vaka adedi bu eşiğe ulaşınca indirim devreye girer. 0 = koşulsuz.',
|
||||||
style:
|
style:
|
||||||
TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -39,4 +39,30 @@ class LabFinanceRepository {
|
|||||||
}
|
}
|
||||||
return {'pending': pending, 'paid': paid};
|
return {'pending': pending, 'paid': paid};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
|
||||||
|
final entries = await listEntries(tenantId, limit: 300);
|
||||||
|
final map = <String, CounterpartyFinanceSummary>{};
|
||||||
|
|
||||||
|
for (final entry in entries) {
|
||||||
|
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||||
|
final current = map[key];
|
||||||
|
final pending = (current?.pendingAmount ?? 0) +
|
||||||
|
(entry.status == FinanceStatus.pending ? entry.amount : 0);
|
||||||
|
final paid = (current?.paidAmount ?? 0) +
|
||||||
|
(entry.status == FinanceStatus.paid ? entry.amount : 0);
|
||||||
|
map[key] = CounterpartyFinanceSummary(
|
||||||
|
counterpartyTenantId: entry.counterpartyTenantId,
|
||||||
|
counterpartyName: entry.counterpartyName ?? 'Karşı Taraf',
|
||||||
|
currency: entry.currency,
|
||||||
|
pendingAmount: pending,
|
||||||
|
paidAmount: paid,
|
||||||
|
entryCount: (current?.entryCount ?? 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = map.values.toList();
|
||||||
|
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
|||||||
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
|
LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'),
|
||||||
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
|
LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'),
|
||||||
LabFinanceRepository.instance.summary(tenantId),
|
LabFinanceRepository.instance.summary(tenantId),
|
||||||
|
LabFinanceRepository.instance.byCounterparty(tenantId),
|
||||||
]).then((results) => _FinanceData(
|
]).then((results) => _FinanceData(
|
||||||
pending: results[0] as List<FinanceEntry>,
|
pending: results[0] as List<FinanceEntry>,
|
||||||
paid: results[1] as List<FinanceEntry>,
|
paid: results[1] as List<FinanceEntry>,
|
||||||
summary: results[2] as Map<String, double>,
|
summary: results[2] as Map<String, double>,
|
||||||
|
counterparties: results[3] as List<CounterpartyFinanceSummary>,
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -199,6 +201,15 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (data.counterparties.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: _CounterpartySummaryList(
|
||||||
|
title: 'Klinik Bazlı Alacak',
|
||||||
|
items: data.counterparties,
|
||||||
|
formatAmount: formatAmount,
|
||||||
|
),
|
||||||
|
),
|
||||||
PillTabs(
|
PillTabs(
|
||||||
tabs: [s.pending, s.collected],
|
tabs: [s.pending, s.collected],
|
||||||
selected: _tabController.index,
|
selected: _tabController.index,
|
||||||
@@ -240,11 +251,13 @@ class _FinanceData {
|
|||||||
required this.pending,
|
required this.pending,
|
||||||
required this.paid,
|
required this.paid,
|
||||||
required this.summary,
|
required this.summary,
|
||||||
|
required this.counterparties,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<FinanceEntry> pending;
|
final List<FinanceEntry> pending;
|
||||||
final List<FinanceEntry> paid;
|
final List<FinanceEntry> paid;
|
||||||
final Map<String, double> summary;
|
final Map<String, double> summary;
|
||||||
|
final List<CounterpartyFinanceSummary> counterparties;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SummaryCard extends StatelessWidget {
|
class _SummaryCard extends StatelessWidget {
|
||||||
@@ -465,3 +478,66 @@ class _EntriesList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CounterpartySummaryList extends StatelessWidget {
|
||||||
|
const _CounterpartySummaryList({
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
required this.formatAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<CounterpartyFinanceSummary> items;
|
||||||
|
final String Function(double) formatAmount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final item in items.take(5)) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.counterpartyName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
formatAmount(item.pendingAmount),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.pending,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,8 +273,10 @@ class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> {
|
|||||||
if (q.isEmpty) return jobs;
|
if (q.isEmpty) return jobs;
|
||||||
return jobs.where((j) =>
|
return jobs.where((j) =>
|
||||||
j.patientCode.toLowerCase().contains(q) ||
|
j.patientCode.toLowerCase().contains(q) ||
|
||||||
|
(j.patientName?.toLowerCase().contains(q) ?? false) ||
|
||||||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
||||||
j.prostheticType.label.toLowerCase().contains(q)
|
j.prostheticType.label.toLowerCase().contains(q) ||
|
||||||
|
(j.prostheticName?.toLowerCase().contains(q) ?? false)
|
||||||
).toList();
|
).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,8 +593,10 @@ class _LabJobsTabState extends ConsumerState<_LabJobsTab> {
|
|||||||
if (q.isNotEmpty) {
|
if (q.isNotEmpty) {
|
||||||
list = list.where((j) {
|
list = list.where((j) {
|
||||||
return j.patientCode.toLowerCase().contains(q) ||
|
return j.patientCode.toLowerCase().contains(q) ||
|
||||||
|
(j.patientName?.toLowerCase().contains(q) ?? false) ||
|
||||||
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
(j.clinicName?.toLowerCase().contains(q) ?? false) ||
|
||||||
j.prostheticType.label.toLowerCase().contains(q) ||
|
j.prostheticType.label.toLowerCase().contains(q) ||
|
||||||
|
(j.prostheticName?.toLowerCase().contains(q) ?? false) ||
|
||||||
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
|
(j.currentStep?.label.toLowerCase().contains(q) ?? false);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -722,12 +726,15 @@ class _LabJobCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final title = job.patientName?.trim().isNotEmpty == true
|
||||||
|
? job.patientName!
|
||||||
|
: job.patientCode;
|
||||||
final isOverdue =
|
final isOverdue =
|
||||||
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
job.dueDate != null && job.dueDate!.isBefore(DateTime.now());
|
||||||
final accentColor = _statusColor(job.status);
|
final accentColor = _statusColor(job.status);
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: job.patientCode,
|
label: title,
|
||||||
button: true,
|
button: true,
|
||||||
excludeSemantics: true,
|
excludeSemantics: true,
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -771,7 +778,7 @@ class _LabJobCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
job.patientCode,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -798,6 +805,16 @@ class _LabJobCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (job.patientName?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
job.patientCode,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -827,7 +844,9 @@ class _LabJobCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
job.prostheticType.label,
|
job.prostheticName?.isNotEmpty == true
|
||||||
|
? '${job.prostheticType.label} · ${job.prostheticName}'
|
||||||
|
: job.prostheticType.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
|
|||||||
@@ -258,7 +258,9 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
job.patientCode,
|
job.patientName?.isNotEmpty == true
|
||||||
|
? job.patientName!
|
||||||
|
: job.patientCode,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineSmall
|
.headlineSmall
|
||||||
@@ -289,10 +291,40 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
|||||||
icon: Icons.business,
|
icon: Icons.business,
|
||||||
label: 'Klinik',
|
label: 'Klinik',
|
||||||
value: job.clinicName ?? '-'),
|
value: job.clinicName ?? '-'),
|
||||||
|
if (job.patientName != null &&
|
||||||
|
job.patientName!.isNotEmpty)
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
label: 'Hasta',
|
||||||
|
value: job.patientName!,
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.tag_outlined,
|
||||||
|
label: 'Protokol No',
|
||||||
|
value: job.patientCode,
|
||||||
|
),
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
icon: Icons.medical_services_outlined,
|
icon: Icons.medical_services_outlined,
|
||||||
label: 'Protez Tipi',
|
label: 'Protez Tipi',
|
||||||
value: job.prostheticType.label),
|
value: job.prostheticType.label),
|
||||||
|
if (job.prostheticName != null &&
|
||||||
|
job.prostheticName!.isNotEmpty)
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.category_outlined,
|
||||||
|
label: 'Ürün',
|
||||||
|
value: job.prostheticName!,
|
||||||
|
),
|
||||||
|
if (job.workflowType != null)
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.tune_rounded,
|
||||||
|
label: 'İş Tipi',
|
||||||
|
value: job.workflowType!.label,
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.fact_check_outlined,
|
||||||
|
label: 'Prova',
|
||||||
|
value: job.provaRequired ? 'Provalı' : 'Provasız',
|
||||||
|
),
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
icon: Icons.format_list_numbered,
|
icon: Icons.format_list_numbered,
|
||||||
label: 'Üye Sayısı',
|
label: 'Üye Sayısı',
|
||||||
@@ -761,4 +793,3 @@ class _JobStepper extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,11 @@ class _InboundJobCardState extends State<_InboundJobCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final job = widget.job;
|
final job = widget.job;
|
||||||
|
final title = job.patientName?.trim().isNotEmpty == true
|
||||||
|
? job.patientName!
|
||||||
|
: job.patientCode;
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: job.patientCode,
|
label: title,
|
||||||
button: true,
|
button: true,
|
||||||
excludeSemantics: true,
|
excludeSemantics: true,
|
||||||
child: Dismissible(
|
child: Dismissible(
|
||||||
@@ -246,9 +249,17 @@ class _InboundJobCardState extends State<_InboundJobCard> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
job.patientCode,
|
title,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
|
if (job.patientName?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
job.patientCode,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
job.clinicName ?? 'Klinik',
|
job.clinicName ?? 'Klinik',
|
||||||
@@ -259,7 +270,9 @@ class _InboundJobCardState extends State<_InboundJobCard> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_Chip(
|
_Chip(
|
||||||
label: job.prostheticType.label,
|
label: job.prostheticName?.isNotEmpty == true
|
||||||
|
? '${job.prostheticType.label} · ${job.prostheticName}'
|
||||||
|
: job.prostheticType.label,
|
||||||
color: AppColors.inProgressBg,
|
color: AppColors.inProgressBg,
|
||||||
textColor: AppColors.inProgress,
|
textColor: AppColors.inProgress,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
import '../../../core/api/pocketbase_client.dart';
|
import '../../../core/api/pocketbase_client.dart';
|
||||||
|
import '../../../core/services/finance_service.dart';
|
||||||
import '../../../core/services/job_history_service.dart';
|
import '../../../core/services/job_history_service.dart';
|
||||||
import '../../../models/job.dart';
|
import '../../../models/job.dart';
|
||||||
|
|
||||||
const _listExpand = 'clinic_tenant_id,lab_tenant_id';
|
const _listExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
|
||||||
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id';
|
const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id';
|
||||||
|
|
||||||
class LabJobsRepository {
|
class LabJobsRepository {
|
||||||
LabJobsRepository._();
|
LabJobsRepository._();
|
||||||
@@ -96,6 +97,7 @@ class LabJobsRepository {
|
|||||||
final record = await _pb.collection('jobs').update(jobId, body: {
|
final record = await _pb.collection('jobs').update(jobId, body: {
|
||||||
'status': 'cancelled',
|
'status': 'cancelled',
|
||||||
});
|
});
|
||||||
|
await FinanceService.instance.deletePendingEntriesForJob(jobId);
|
||||||
unawaited(JobHistoryService.instance.append(
|
unawaited(JobHistoryService.instance.append(
|
||||||
jobId: jobId,
|
jobId: jobId,
|
||||||
clinicTenantId: job.clinicTenantId,
|
clinicTenantId: job.clinicTenantId,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class FinanceEntry {
|
|||||||
required this.amount,
|
required this.amount,
|
||||||
required this.currency,
|
required this.currency,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.counterpartyTenantId,
|
||||||
this.paidAt,
|
this.paidAt,
|
||||||
this.counterpartyName,
|
this.counterpartyName,
|
||||||
this.patientCode,
|
this.patientCode,
|
||||||
@@ -34,6 +35,7 @@ class FinanceEntry {
|
|||||||
final double amount;
|
final double amount;
|
||||||
final String currency;
|
final String currency;
|
||||||
final FinanceStatus status;
|
final FinanceStatus status;
|
||||||
|
final String? counterpartyTenantId;
|
||||||
final String? paidAt;
|
final String? paidAt;
|
||||||
final String? counterpartyName;
|
final String? counterpartyName;
|
||||||
final String? patientCode;
|
final String? patientCode;
|
||||||
@@ -53,6 +55,7 @@ class FinanceEntry {
|
|||||||
currency: j['currency'] as String? ?? 'TRY',
|
currency: j['currency'] as String? ?? 'TRY',
|
||||||
status: FinanceStatus.values.firstWhere((e) => e.value == j['status'],
|
status: FinanceStatus.values.firstWhere((e) => e.value == j['status'],
|
||||||
orElse: () => FinanceStatus.pending),
|
orElse: () => FinanceStatus.pending),
|
||||||
|
counterpartyTenantId: _str(j['counterparty_tenant_id']),
|
||||||
paidAt: _str(j['paid_at']),
|
paidAt: _str(j['paid_at']),
|
||||||
counterpartyName: _str(j['counterparty_name']),
|
counterpartyName: _str(j['counterparty_name']),
|
||||||
patientCode: jobExp?['patient_code'] as String?,
|
patientCode: jobExp?['patient_code'] as String?,
|
||||||
@@ -60,3 +63,23 @@ class FinanceEntry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CounterpartyFinanceSummary {
|
||||||
|
const CounterpartyFinanceSummary({
|
||||||
|
required this.counterpartyName,
|
||||||
|
required this.currency,
|
||||||
|
required this.pendingAmount,
|
||||||
|
required this.paidAmount,
|
||||||
|
required this.entryCount,
|
||||||
|
this.counterpartyTenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String counterpartyName;
|
||||||
|
final String currency;
|
||||||
|
final double pendingAmount;
|
||||||
|
final double paidAmount;
|
||||||
|
final int entryCount;
|
||||||
|
final String? counterpartyTenantId;
|
||||||
|
|
||||||
|
double get totalAmount => pendingAmount + paidAmount;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ enum JobStep {
|
|||||||
|
|
||||||
enum JobLocation { atClinic, atLab }
|
enum JobLocation { atClinic, atLab }
|
||||||
|
|
||||||
|
enum JobWorkflowType { arjinat, geleneksel, dijital }
|
||||||
|
|
||||||
enum ProstheticType {
|
enum ProstheticType {
|
||||||
metalPorselen,
|
metalPorselen,
|
||||||
zirkonyum,
|
zirkonyum,
|
||||||
@@ -81,6 +83,20 @@ extension JobStepExt on JobStep {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension JobWorkflowTypeExt on JobWorkflowType {
|
||||||
|
String get label => switch (this) {
|
||||||
|
JobWorkflowType.arjinat => 'Arjinat',
|
||||||
|
JobWorkflowType.geleneksel => 'Geleneksel',
|
||||||
|
JobWorkflowType.dijital => 'Dijital',
|
||||||
|
};
|
||||||
|
|
||||||
|
String get value => switch (this) {
|
||||||
|
JobWorkflowType.arjinat => 'arjinat',
|
||||||
|
JobWorkflowType.geleneksel => 'geleneksel',
|
||||||
|
JobWorkflowType.dijital => 'dijital',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Prosthetic type ───────────────────────────────────────────────────────────
|
// ── Prosthetic type ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
extension ProstheticTypeExt on ProstheticType {
|
extension ProstheticTypeExt on ProstheticType {
|
||||||
@@ -146,7 +162,9 @@ class Job {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.dateCreated,
|
required this.dateCreated,
|
||||||
this.patientId,
|
this.patientId,
|
||||||
|
this.patientName,
|
||||||
this.prostheticId,
|
this.prostheticId,
|
||||||
|
this.prostheticName,
|
||||||
this.teeth = const [],
|
this.teeth = const [],
|
||||||
this.color,
|
this.color,
|
||||||
this.description,
|
this.description,
|
||||||
@@ -154,6 +172,7 @@ class Job {
|
|||||||
this.currency,
|
this.currency,
|
||||||
this.currentStep,
|
this.currentStep,
|
||||||
this.location = JobLocation.atClinic,
|
this.location = JobLocation.atClinic,
|
||||||
|
this.workflowType,
|
||||||
this.dueDate,
|
this.dueDate,
|
||||||
this.clinicName,
|
this.clinicName,
|
||||||
this.labName,
|
this.labName,
|
||||||
@@ -165,8 +184,10 @@ class Job {
|
|||||||
final String clinicTenantId;
|
final String clinicTenantId;
|
||||||
final String labTenantId;
|
final String labTenantId;
|
||||||
final String? patientId;
|
final String? patientId;
|
||||||
|
final String? patientName;
|
||||||
final String patientCode;
|
final String patientCode;
|
||||||
final String? prostheticId;
|
final String? prostheticId;
|
||||||
|
final String? prostheticName;
|
||||||
final ProstheticType prostheticType;
|
final ProstheticType prostheticType;
|
||||||
final int memberCount;
|
final int memberCount;
|
||||||
final List<String> teeth;
|
final List<String> teeth;
|
||||||
@@ -177,6 +198,7 @@ class Job {
|
|||||||
final JobStatus status;
|
final JobStatus status;
|
||||||
final JobStep? currentStep;
|
final JobStep? currentStep;
|
||||||
final JobLocation location;
|
final JobLocation location;
|
||||||
|
final JobWorkflowType? workflowType;
|
||||||
final DateTime? dueDate;
|
final DateTime? dueDate;
|
||||||
final DateTime dateCreated;
|
final DateTime dateCreated;
|
||||||
final List<String> attachments;
|
final List<String> attachments;
|
||||||
@@ -192,6 +214,7 @@ class Job {
|
|||||||
JobStatus? status,
|
JobStatus? status,
|
||||||
JobStep? currentStep,
|
JobStep? currentStep,
|
||||||
JobLocation? location,
|
JobLocation? location,
|
||||||
|
JobWorkflowType? workflowType,
|
||||||
String? clinicName,
|
String? clinicName,
|
||||||
String? labName,
|
String? labName,
|
||||||
bool clearCurrentStep = false,
|
bool clearCurrentStep = false,
|
||||||
@@ -201,8 +224,10 @@ class Job {
|
|||||||
clinicTenantId: clinicTenantId,
|
clinicTenantId: clinicTenantId,
|
||||||
labTenantId: labTenantId,
|
labTenantId: labTenantId,
|
||||||
patientId: patientId,
|
patientId: patientId,
|
||||||
|
patientName: patientName,
|
||||||
patientCode: patientCode,
|
patientCode: patientCode,
|
||||||
prostheticId: prostheticId,
|
prostheticId: prostheticId,
|
||||||
|
prostheticName: prostheticName,
|
||||||
prostheticType: prostheticType,
|
prostheticType: prostheticType,
|
||||||
memberCount: memberCount,
|
memberCount: memberCount,
|
||||||
teeth: teeth,
|
teeth: teeth,
|
||||||
@@ -213,6 +238,7 @@ class Job {
|
|||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep),
|
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep),
|
||||||
location: location ?? this.location,
|
location: location ?? this.location,
|
||||||
|
workflowType: workflowType ?? this.workflowType,
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
dateCreated: dateCreated,
|
dateCreated: dateCreated,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
@@ -240,6 +266,8 @@ class Job {
|
|||||||
final expand = j['expand'] as Map<String, dynamic>?;
|
final expand = j['expand'] as Map<String, dynamic>?;
|
||||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||||
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
|
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
|
||||||
|
final patientExp = expand?['patient_id'] as Map<String, dynamic>?;
|
||||||
|
final prostheticExp = expand?['prosthetic_id'] as Map<String, dynamic>?;
|
||||||
String? str(dynamic v) {
|
String? str(dynamic v) {
|
||||||
final s = v as String?;
|
final s = v as String?;
|
||||||
return (s == null || s.isEmpty) ? null : s;
|
return (s == null || s.isEmpty) ? null : s;
|
||||||
@@ -250,8 +278,10 @@ class Job {
|
|||||||
clinicTenantId: j['clinic_tenant_id'] as String,
|
clinicTenantId: j['clinic_tenant_id'] as String,
|
||||||
labTenantId: j['lab_tenant_id'] as String,
|
labTenantId: j['lab_tenant_id'] as String,
|
||||||
patientId: str(j['patient_id']),
|
patientId: str(j['patient_id']),
|
||||||
|
patientName: _patientName(patientExp),
|
||||||
patientCode: j['patient_code'] as String,
|
patientCode: j['patient_code'] as String,
|
||||||
prostheticId: str(j['prosthetic_id']),
|
prostheticId: str(j['prosthetic_id']),
|
||||||
|
prostheticName: prostheticExp?['name'] as String?,
|
||||||
prostheticType: _parseProstheticType(j['prosthetic_type'] as String),
|
prostheticType: _parseProstheticType(j['prosthetic_type'] as String),
|
||||||
memberCount: (j['member_count'] as num).toInt(),
|
memberCount: (j['member_count'] as num).toInt(),
|
||||||
teeth: j['teeth'] is List
|
teeth: j['teeth'] is List
|
||||||
@@ -267,6 +297,9 @@ class Job {
|
|||||||
: null,
|
: null,
|
||||||
location:
|
location:
|
||||||
j['location'] == 'at_lab' ? JobLocation.atLab : JobLocation.atClinic,
|
j['location'] == 'at_lab' ? JobLocation.atLab : JobLocation.atClinic,
|
||||||
|
workflowType: str(j['workflow_type']) != null
|
||||||
|
? _parseWorkflowType(j['workflow_type'] as String)
|
||||||
|
: null,
|
||||||
dueDate: str(j['due_date']) != null
|
dueDate: str(j['due_date']) != null
|
||||||
? DateTime.parse(j['due_date'] as String)
|
? DateTime.parse(j['due_date'] as String)
|
||||||
: null,
|
: null,
|
||||||
@@ -299,6 +332,25 @@ class Job {
|
|||||||
_ => JobStep.olcu,
|
_ => JobStep.olcu,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static JobWorkflowType _parseWorkflowType(String s) => switch (s) {
|
||||||
|
'arjinat' => JobWorkflowType.arjinat,
|
||||||
|
'dijital' => JobWorkflowType.dijital,
|
||||||
|
_ => JobWorkflowType.geleneksel,
|
||||||
|
};
|
||||||
|
|
||||||
|
static String? _patientName(Map<String, dynamic>? patientExp) {
|
||||||
|
if (patientExp == null) return null;
|
||||||
|
final first = (patientExp['first_name'] as String?)?.trim();
|
||||||
|
final last = (patientExp['last_name'] as String?)?.trim();
|
||||||
|
final parts = [first, last]
|
||||||
|
.where((part) => part != null && part.isNotEmpty)
|
||||||
|
.cast<String>()
|
||||||
|
.toList();
|
||||||
|
if (parts.isNotEmpty) return parts.join(' ');
|
||||||
|
final code = (patientExp['patient_code'] as String?)?.trim();
|
||||||
|
return (code == null || code.isEmpty) ? null : code;
|
||||||
|
}
|
||||||
|
|
||||||
static ProstheticType _parseProstheticType(String s) => switch (s) {
|
static ProstheticType _parseProstheticType(String s) => switch (s) {
|
||||||
'zirkonyum' => ProstheticType.zirkonyum,
|
'zirkonyum' => ProstheticType.zirkonyum,
|
||||||
'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum,
|
'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum,
|
||||||
|
|||||||
+4
-4
@@ -617,10 +617,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1094,10 +1094,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user