From b42f68214efc1f14c0c149e022d66e9bd233917d Mon Sep 17 00:00:00 2001 From: egecankomur Date: Fri, 12 Jun 2026 00:04:53 +0300 Subject: [PATCH] feat: improve patient flow and pricing workflow --- .gitignore | 3 + ios/Podfile.lock | 7 + lib/core/api/pocketbase_client.dart | 48 +- lib/core/auth/auth_repository.dart | 14 +- lib/core/l10n/app_strings.dart | 14 + lib/core/providers/auth_provider.dart | 12 +- lib/core/router/app_router.dart | 162 +++--- lib/core/services/finance_service.dart | 117 +++++ lib/core/services/pricing_service.dart | 87 ++++ lib/features/auth/sign_in_screen.dart | 46 +- .../finance/clinic_finance_repository.dart | 41 +- .../clinic/finance/clinic_finance_screen.dart | 157 ++++-- .../clinic/jobs/clinic_job_detail_screen.dart | 15 +- .../clinic/jobs/clinic_jobs_repository.dart | 35 +- .../clinic/jobs/clinic_jobs_screen.dart | 26 +- lib/features/clinic/jobs/new_job_screen.dart | 466 ++++++++++++++---- .../lab/discounts/discounts_screen.dart | 4 +- .../lab/finance/lab_finance_repository.dart | 26 + .../lab/finance/lab_finance_screen.dart | 76 +++ .../lab/jobs/lab_all_jobs_screen.dart | 27 +- .../lab/jobs/lab_job_detail_screen.dart | 35 +- .../lab/jobs/lab_jobs_inbound_screen.dart | 19 +- .../lab/jobs/lab_jobs_repository.dart | 6 +- lib/models/finance_entry.dart | 23 + lib/models/job.dart | 52 ++ pubspec.lock | 8 +- 26 files changed, 1283 insertions(+), 243 deletions(-) create mode 100644 lib/core/services/finance_service.dart create mode 100644 lib/core/services/pricing_service.dart diff --git a/.gitignore b/.gitignore index 87d5858..762f988 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ app.*.map.json # Claude Code project settings — local dev tooling only .claude/ + +# Codex project settings — local dev tooling only +.codex/ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index bb6c76d..8aab6c7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -86,6 +86,9 @@ PODS: - OneSignalXCFramework/OneSignalNotifications - OneSignalXCFramework/OneSignalOSCore - OneSignalXCFramework/OneSignalOutcomes + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - SDWebImage (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`) - Flutter (from `Flutter`) - 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`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -122,6 +126,8 @@ EXTERNAL SOURCES: :path: Flutter onesignal_flutter: :path: ".symlinks/plugins/onesignal_flutter/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -136,6 +142,7 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 onesignal_flutter: 75c70a45a8d97e685273a14f04521ec121611458 OneSignalXCFramework: 2f46ff87ccefd9afe8e3b5f9fe357072191205ff + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/lib/core/api/pocketbase_client.dart b/lib/core/api/pocketbase_client.dart index 78f2dc2..e215186 100644 --- a/lib/core/api/pocketbase_client.dart +++ b/lib/core/api/pocketbase_client.dart @@ -1,25 +1,67 @@ +import 'dart:convert'; + import 'package:pocketbase/pocketbase.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _kAuthKey = 'pb_auth'; +const _kRememberSessionKey = 'remember_session'; 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 get instance => _instance!; final PocketBase pb; + final SharedPreferences _prefs; + bool _rememberSession; + + bool get rememberSession => _rememberSession; static Future init() async { final prefs = await SharedPreferences.getInstance(); + final remember = prefs.getBool(_kRememberSessionKey) ?? true; final stored = prefs.getString(_kAuthKey); final store = AsyncAuthStore( - save: (String data) => prefs.setString(_kAuthKey, data), - initial: stored, + save: (String data) async { + final client = _instance; + if (client == null || client._rememberSession) { + await prefs.setString(_kAuthKey, data); + return; + } + await prefs.remove(_kAuthKey); + }, + initial: remember ? stored : null, ); _instance = PocketBaseClient._( pb: PocketBase('https://pocket.kovaksoft.com', authStore: store), + prefs: prefs, + rememberSession: remember, ); + + if (!remember && stored != null) { + await prefs.remove(_kAuthKey); + } + } + + Future 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, + }), + ); + } } } diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index a9f687b..b169a75 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -8,8 +8,14 @@ class AuthRepository { static final instance = AuthRepository._(); PocketBase get _pb => PocketBaseClient.instance.pb; + PocketBaseClient get _client => PocketBaseClient.instance; - Future login(String email, String password) async { + Future login( + String email, + String password, { + required bool rememberSession, + }) async { + await _client.setRememberSession(rememberSession); await _pb.collection('users').authWithPassword(email, password); return _buildAuthResult(); } @@ -43,7 +49,11 @@ class AuthRepository { if (firstName != null && firstName.isNotEmpty) 'first_name': firstName, if (lastName != null && lastName.isNotEmpty) 'last_name': lastName, }); - return login(email, password); + return login( + email, + password, + rememberSession: _client.rememberSession, + ); } Future refreshSession() async { diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 4643e1f..8f10d42 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -57,6 +57,7 @@ class AppStrings { required this.tenantKindLab, required this.signInWelcome, required this.signInSubtitle, + required this.rememberMe, required this.emailAddress, required this.password, required this.emailRequired, @@ -95,6 +96,7 @@ class AppStrings { required this.clinicCategory, required this.jobsTitle, required this.dashboardTitle, + required this.homeTitle, required this.productsTitle, required this.patientsTitle, required this.close, @@ -174,6 +176,7 @@ class AppStrings { // ── Auth ────────────────────────────────────────────────────────────────── final String signInWelcome; final String signInSubtitle; + final String rememberMe; final String emailAddress; final String password; final String emailRequired; @@ -213,6 +216,7 @@ class AppStrings { final String clinicCategory; final String jobsTitle; final String dashboardTitle; + final String homeTitle; final String productsTitle; final String patientsTitle; @@ -303,6 +307,7 @@ class AppStrings { tenantKindLab: 'Laboratuvar', signInWelcome: 'Tekrar hoş geldiniz', signInSubtitle: 'Hesabınıza giriş yapın', + rememberMe: 'Beni hatırla', emailAddress: 'E-posta adresi', password: 'Şifre', emailRequired: 'E-posta gereklidir', @@ -338,6 +343,7 @@ class AppStrings { clinicCategory: 'KLİNİK', jobsTitle: 'İşler', dashboardTitle: 'Özet', + homeTitle: 'Ana Sayfa', productsTitle: 'Ürünler', patientsTitle: 'Hastalar', currencyTRY: 'Türk Lirası (₺)', @@ -410,6 +416,7 @@ class AppStrings { tenantKindLab: 'Laboratory', signInWelcome: 'Welcome back', signInSubtitle: 'Sign in to your account', + rememberMe: 'Remember me', emailAddress: 'Email address', password: 'Password', emailRequired: 'Email is required', @@ -445,6 +452,7 @@ class AppStrings { clinicCategory: 'CLINIC', jobsTitle: 'Jobs', dashboardTitle: 'Overview', + homeTitle: 'Home', productsTitle: 'Products', patientsTitle: 'Patients', currencyTRY: 'Turkish Lira (₺)', @@ -517,6 +525,7 @@ class AppStrings { tenantKindLab: 'Лаборатория', signInWelcome: 'Добро пожаловать', signInSubtitle: 'Войдите в свой аккаунт', + rememberMe: 'Запомнить меня', emailAddress: 'Адрес эл. почты', password: 'Пароль', emailRequired: 'Эл. почта обязательна', @@ -552,6 +561,7 @@ class AppStrings { clinicCategory: 'КЛИНИКА', jobsTitle: 'Заказы', dashboardTitle: 'Обзор', + homeTitle: 'Главная', productsTitle: 'Продукты', patientsTitle: 'Пациенты', currencyTRY: 'Турецкая лира (₺)', @@ -624,6 +634,7 @@ class AppStrings { tenantKindLab: 'مختبر', signInWelcome: 'مرحباً بعودتك', signInSubtitle: 'سجّل دخولك إلى حسابك', + rememberMe: 'تذكرني', emailAddress: 'البريد الإلكتروني', password: 'كلمة المرور', emailRequired: 'البريد الإلكتروني مطلوب', @@ -659,6 +670,7 @@ class AppStrings { clinicCategory: 'العيادة', jobsTitle: 'الأعمال', dashboardTitle: 'نظرة عامة', + homeTitle: 'الرئيسية', productsTitle: 'المنتجات', patientsTitle: 'المرضى', currencyTRY: 'ليرة تركية (₺)', @@ -731,6 +743,7 @@ class AppStrings { tenantKindLab: 'Labor', signInWelcome: 'Willkommen zurück', signInSubtitle: 'Melden Sie sich in Ihrem Konto an', + rememberMe: 'Angemeldet bleiben', emailAddress: 'E-Mail-Adresse', password: 'Passwort', emailRequired: 'E-Mail ist erforderlich', @@ -766,6 +779,7 @@ class AppStrings { clinicCategory: 'KLINIK', jobsTitle: 'Aufträge', dashboardTitle: 'Übersicht', + homeTitle: 'Startseite', productsTitle: 'Produkte', patientsTitle: 'Patienten', currencyTRY: 'Türkische Lira (₺)', diff --git a/lib/core/providers/auth_provider.dart b/lib/core/providers/auth_provider.dart index 3fe3ee9..f2c984b 100644 --- a/lib/core/providers/auth_provider.dart +++ b/lib/core/providers/auth_provider.dart @@ -78,10 +78,18 @@ class AuthNotifier extends StateNotifier { } } - Future signIn(String email, String password) async { + Future signIn( + String email, + String password, { + required bool rememberSession, + }) async { state = state.copyWith(isLoading: true, clearError: true); try { - final result = await _repo.login(email, password); + final result = await _repo.login( + email, + password, + rememberSession: rememberSession, + ); state = AuthState( profile: result.user, memberships: result.tenants, diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 4341da7..263a99e 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../l10n/app_strings.dart'; +import '../providers/locale_provider.dart'; import '../theme/app_theme.dart'; import '../widgets/tooth_logo.dart'; import '../providers/auth_provider.dart'; @@ -213,63 +215,60 @@ class _ClinicShell extends ConsumerStatefulWidget { class _ClinicShellState extends ConsumerState<_ClinicShell> { String _selectedRoute = routeClinicDashboard; - // Top-level singles before groups - static final _topSingles = [ - _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: 'İşler', visible: (m) => m?.showJobs ?? 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: 'Finans', visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true), - ]; + List<_NavItem> _clinicTopSingles(AppStrings s) => [ + _NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, 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: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, 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: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true), + ]; - // Dropdown groups - static final _groups = [ - _NavGroup( - title: 'Yönetim', - icon: Icons.tune_rounded, - selectedIcon: Icons.tune_rounded, - items: [ - _NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', visible: (_) => true), - _NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true), - ], - ), - ]; + List<_NavGroup> _clinicGroups(AppStrings s) => [ + _NavGroup( + title: s.management, + icon: Icons.tune_rounded, + selectedIcon: Icons.tune_rounded, + items: [ + _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: s.reports, visible: (_) => true), + ], + ), + ]; - // Singles after groups - static final _bottomSingles = [ - _NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true), - ]; + List<_NavItem> _clinicBottomSingles(AppStrings s) => [ + _NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + ]; - // Mobile bottom nav: core items; others accessed from settings - static final _mobileItems = [ - _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: 'İşler', visible: (m) => m?.showJobs ?? 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: 'Finans', visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true), - ]; + List<_NavItem> _clinicMobileItems(AppStrings s) => [ + _NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, 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: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, 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: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + ]; - List<_SidebarEntry> _allEntries() { + List<_SidebarEntry> _allEntries(AppStrings s) { final membership = ref.read(authProvider).activeTenant; final entries = <_SidebarEntry>[]; - for (final s in _topSingles) { - if (s.visible(membership)) entries.add(_SidebarSingleEntry(s)); + for (final item in _clinicTopSingles(s)) { + if (item.visible(membership)) entries.add(_SidebarSingleEntry(item)); } - for (final g in _groups) { - if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g)); + for (final group in _clinicGroups(s)) { + if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group)); } - for (final s in _bottomSingles) { - if (s.visible(membership)) entries.add(_SidebarSingleEntry(s)); + for (final item in _clinicBottomSingles(s)) { + if (item.visible(membership)) entries.add(_SidebarSingleEntry(item)); } return entries; } @override Widget build(BuildContext context) { + final s = ref.watch(stringsProvider); final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; if (isDesktop) { - final entries = _allEntries(); + final entries = _allEntries(s); return Scaffold( backgroundColor: AppColors.background, body: Row( @@ -290,7 +289,7 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> { // Mobile: only core items in bottom nav 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 clampedIndex = flatIndex >= 0 ? flatIndex : 0; @@ -339,64 +338,61 @@ class _LabShell extends ConsumerStatefulWidget { class _LabShellState extends ConsumerState<_LabShell> { String _selectedRoute = routeLabDashboard; - // Top-level singles before groups - static final _topSingles = [ - _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: 'İşler', visible: (m) => m?.showJobs ?? 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: 'Finans', visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: 'AI Sohbet', visible: (_) => true), - ]; + List<_NavItem> _labTopSingles(AppStrings s) => [ + _NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, 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: 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: 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: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true), + ]; - // Dropdown groups - static final _groups = [ - _NavGroup( - title: 'Yönetim', - icon: Icons.tune_rounded, - selectedIcon: Icons.tune_rounded, - items: [ - _NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: 'Bağlantılar', 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: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: 'Raporlar', visible: (_) => true), - ], - ), - ]; + List<_NavGroup> _labGroups(AppStrings s) => [ + _NavGroup( + title: s.management, + icon: Icons.tune_rounded, + selectedIcon: Icons.tune_rounded, + items: [ + _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: s.discounts, 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 - static final _bottomSingles = [ - _NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true), - ]; + List<_NavItem> _labBottomSingles(AppStrings s) => [ + _NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + ]; - // Mobile bottom nav: core items; others accessed from settings - static final _mobileItems = [ - _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: 'İşler', visible: (m) => m?.showJobs ?? 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: 'Finans', visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true), - ]; + List<_NavItem> _labMobileItems(AppStrings s) => [ + _NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, 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: 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: 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: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + ]; - List<_SidebarEntry> _allEntries() { + List<_SidebarEntry> _allEntries(AppStrings s) { final membership = ref.read(authProvider).activeTenant; final entries = <_SidebarEntry>[]; - for (final s in _topSingles) { - if (s.visible(membership)) entries.add(_SidebarSingleEntry(s)); + for (final item in _labTopSingles(s)) { + if (item.visible(membership)) entries.add(_SidebarSingleEntry(item)); } - for (final g in _groups) { - if (g.hasVisible(membership)) entries.add(_SidebarGroupEntry(g)); + for (final group in _labGroups(s)) { + if (group.hasVisible(membership)) entries.add(_SidebarGroupEntry(group)); } - for (final s in _bottomSingles) { - if (s.visible(membership)) entries.add(_SidebarSingleEntry(s)); + for (final item in _labBottomSingles(s)) { + if (item.visible(membership)) entries.add(_SidebarSingleEntry(item)); } return entries; } @override Widget build(BuildContext context) { + final s = ref.watch(stringsProvider); final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; if (isDesktop) { - final entries = _allEntries(); + final entries = _allEntries(s); return Scaffold( backgroundColor: AppColors.background, body: Row( @@ -417,7 +413,7 @@ class _LabShellState extends ConsumerState<_LabShell> { // Mobile: only core items in bottom nav 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 clampedIndex = flatIndex >= 0 ? flatIndex : 0; diff --git a/lib/core/services/finance_service.dart b/lib/core/services/finance_service.dart new file mode 100644 index 0000000..e376ecb --- /dev/null +++ b/lib/core/services/finance_service.dart @@ -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 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 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 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 _upsertEntry({ + required List 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); + } +} diff --git a/lib/core/services/pricing_service.dart b/lib/core/services/pricing_service.dart new file mode 100644 index 0000000..ec85851 --- /dev/null +++ b/lib/core/services/pricing_service.dart @@ -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 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 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, + ); + } +} diff --git a/lib/features/auth/sign_in_screen.dart b/lib/features/auth/sign_in_screen.dart index ef9d038..9954bf8 100644 --- a/lib/features/auth/sign_in_screen.dart +++ b/lib/features/auth/sign_in_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/l10n/app_strings.dart'; +import '../../core/api/pocketbase_client.dart'; import '../../core/providers/auth_provider.dart'; import '../../core/providers/locale_provider.dart'; import '../../core/router/app_router.dart'; @@ -22,6 +23,13 @@ class _SignInScreenState extends ConsumerState { final _emailCtrl = TextEditingController(); final _passCtrl = TextEditingController(); bool _obscure = true; + bool _rememberMe = true; + + @override + void initState() { + super.initState(); + _rememberMe = PocketBaseClient.instance.rememberSession; + } @override void dispose() { @@ -34,7 +42,11 @@ class _SignInScreenState extends ConsumerState { if (!_formKey.currentState!.validate()) return; await ref .read(authProvider.notifier) - .signIn(_emailCtrl.text.trim(), _passCtrl.text); + .signIn( + _emailCtrl.text.trim(), + _passCtrl.text, + rememberSession: _rememberMe, + ); } @override @@ -366,6 +378,38 @@ class _SignInScreenState extends ConsumerState { (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) ...[ const SizedBox(height: 14), Container( diff --git a/lib/features/clinic/finance/clinic_finance_repository.dart b/lib/features/clinic/finance/clinic_finance_repository.dart index 0843e49..362e4b0 100644 --- a/lib/features/clinic/finance/clinic_finance_repository.dart +++ b/lib/features/clinic/finance/clinic_finance_repository.dart @@ -1,5 +1,6 @@ import 'package:pocketbase/pocketbase.dart'; import '../../../core/api/pocketbase_client.dart'; +import '../../../core/services/finance_service.dart'; import '../../../models/finance_entry.dart'; class ClinicFinanceRepository { @@ -40,10 +41,42 @@ class ClinicFinanceRepository { return {'pending': pending, 'paid': paid}; } + Future> byCounterparty(String tenantId) async { + final entries = await listEntries(tenantId, limit: 300); + final map = {}; + + 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 markPaid(String entryId) async { - await _pb.collection('finance_entries').update(entryId, body: { - 'status': 'paid', - 'paid_at': DateTime.now().toIso8601String(), - }); + 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: { + 'status': 'paid', + 'paid_at': DateTime.now().toIso8601String(), + }); + return; + } + await FinanceService.instance.markJobPaid(jobId); } } diff --git a/lib/features/clinic/finance/clinic_finance_screen.dart b/lib/features/clinic/finance/clinic_finance_screen.dart index fffd4b7..a3e6448 100644 --- a/lib/features/clinic/finance/clinic_finance_screen.dart +++ b/lib/features/clinic/finance/clinic_finance_screen.dart @@ -23,7 +23,7 @@ class ClinicFinanceScreen extends ConsumerStatefulWidget { class _ClinicFinanceScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; - late Future> _summaryFuture; + late Future<_ClinicFinanceHeaderData> _headerFuture; _FinanceSort _sort = _FinanceSort.newestFirst; @override @@ -45,8 +45,15 @@ class _ClinicFinanceScreenState extends ConsumerState void _loadSummary() { final tenantId = ref.read(authProvider).activeTenant!.tenant.id; setState(() { - _summaryFuture = - ClinicFinanceRepository.instance.summary(tenantId); + _headerFuture = Future.wait([ + ClinicFinanceRepository.instance.summary(tenantId), + ClinicFinanceRepository.instance.byCounterparty(tenantId), + ]).then( + (results) => _ClinicFinanceHeaderData( + summary: results[0] as Map, + counterparties: results[1] as List, + ), + ); }); } @@ -90,41 +97,58 @@ class _ClinicFinanceScreenState extends ConsumerState ), body: Column( children: [ - FutureBuilder>( - future: _summaryFuture, + FutureBuilder<_ClinicFinanceHeaderData>( + future: _headerFuture, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return const LinearProgressIndicator( color: AppColors.accent); } - final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0}; - return Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: _SummaryCard( - label: s.pendingReceivable, - amount: summary['pending'] ?? 0.0, + final data = snap.data ?? + const _ClinicFinanceHeaderData( + summary: {'pending': 0.0, 'paid': 0.0}, + counterparties: [], + ); + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: _SummaryCard( + label: s.pendingReceivable, + amount: data.summary['pending'] ?? 0.0, + currencyCode: currencyCode, + color: AppColors.pending, + bgColor: AppColors.pendingBg, + icon: Icons.hourglass_empty_rounded, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryCard( + label: s.collected, + amount: data.summary['paid'] ?? 0.0, + currencyCode: currencyCode, + color: AppColors.success, + bgColor: AppColors.successBg, + icon: Icons.check_circle_outline, + ), + ), + ], + ), + ), + if (data.counterparties.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _CounterpartySummaryList( + title: 'Laboratuvar Bazlı Borç', + items: data.counterparties, currencyCode: currencyCode, - color: AppColors.pending, - bgColor: AppColors.pendingBg, - icon: Icons.hourglass_empty_rounded, ), ), - const SizedBox(width: 12), - Expanded( - child: _SummaryCard( - label: s.collected, - amount: summary['paid'] ?? 0.0, - currencyCode: currencyCode, - color: AppColors.success, - bgColor: AppColors.successBg, - icon: Icons.check_circle_outline, - ), - ), - ], - ), + ], ); }, ), @@ -158,6 +182,16 @@ class _ClinicFinanceScreenState extends ConsumerState } } +class _ClinicFinanceHeaderData { + const _ClinicFinanceHeaderData({ + required this.summary, + required this.counterparties, + }); + + final Map summary; + final List counterparties; +} + class _SummaryCard extends StatelessWidget { const _SummaryCard({ 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 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), + ], + ], + ), + ); + } +} diff --git a/lib/features/clinic/jobs/clinic_job_detail_screen.dart b/lib/features/clinic/jobs/clinic_job_detail_screen.dart index bc0a047..4ba584a 100644 --- a/lib/features/clinic/jobs/clinic_job_detail_screen.dart +++ b/lib/features/clinic/jobs/clinic_job_detail_screen.dart @@ -352,7 +352,9 @@ class _JobDetailBody extends StatelessWidget { children: [ Expanded( child: Text( - job.patientCode, + job.patientName?.isNotEmpty == true + ? job.patientName! + : job.patientCode, style: Theme.of(context).textTheme.headlineSmall ?.copyWith( fontWeight: FontWeight.bold, @@ -368,6 +370,8 @@ class _JobDetailBody extends StatelessWidget { // Patient + Lab _SectionLabel(title: 'Hasta & Laboratuvar'), + if (job.patientName != null && job.patientName!.isNotEmpty) + _InfoRow(label: 'Hasta', value: job.patientName!), _InfoRow(label: 'Protokol No', value: job.patientCode), if (job.patientId != null) _InfoRow(label: 'Hasta ID', value: job.patientId!), @@ -378,6 +382,14 @@ class _JobDetailBody extends StatelessWidget { // Prosthetic _SectionLabel(title: 'Protez Bilgisi'), _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}'), if (job.teeth.isNotEmpty) _InfoRow(label: 'Dişler', value: job.teeth.join(', ')), @@ -746,4 +758,3 @@ class _StatusBadge extends StatelessWidget { } } } - diff --git a/lib/features/clinic/jobs/clinic_jobs_repository.dart b/lib/features/clinic/jobs/clinic_jobs_repository.dart index 76e462f..888ce29 100644 --- a/lib/features/clinic/jobs/clinic_jobs_repository.dart +++ b/lib/features/clinic/jobs/clinic_jobs_repository.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'package:pocketbase/pocketbase.dart'; import '../../../core/api/pocketbase_client.dart'; +import '../../../core/services/finance_service.dart'; import '../../../core/services/job_history_service.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'; class ClinicJobsRepository { @@ -51,14 +52,19 @@ class ClinicJobsRepository { Future createJob({ required String clinicTenantId, required String labTenantId, + required String clinicName, + required String labName, required String patientCode, - required String prostheticId, + String? prostheticId, required ProstheticType prostheticType, required List teeth, String? patientId, String? color, String? description, String? dueDate, + double? price, + String? currency, + JobWorkflowType? workflowType, bool provaRequired = true, }) async { final record = await _pb.collection('jobs').create(body: { @@ -66,18 +72,38 @@ class ClinicJobsRepository { 'lab_tenant_id': labTenantId, 'patient_code': patientCode, if (patientId != null) 'patient_id': patientId, - 'prosthetic_id': prostheticId, + if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId, 'prosthetic_type': prostheticType.value, 'member_count': teeth.length, 'teeth': teeth, if (color != null) 'color': color, if (description != null) 'description': description, 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', 'location': 'at_clinic', '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 approveAtClinic(String jobId, Job job, {String? note}) async { @@ -134,6 +160,7 @@ class ClinicJobsRepository { final record = await _pb.collection('jobs').update(jobId, body: { 'status': 'cancelled', }); + await FinanceService.instance.deletePendingEntriesForJob(jobId); unawaited(JobHistoryService.instance.append( jobId: jobId, clinicTenantId: job.clinicTenantId, diff --git a/lib/features/clinic/jobs/clinic_jobs_screen.dart b/lib/features/clinic/jobs/clinic_jobs_screen.dart index 884b55e..e37085e 100644 --- a/lib/features/clinic/jobs/clinic_jobs_screen.dart +++ b/lib/features/clinic/jobs/clinic_jobs_screen.dart @@ -252,8 +252,10 @@ class _JobsTabState extends ConsumerState<_JobsTab> { if (q.isNotEmpty) { list = list.where((j) { return j.patientCode.toLowerCase().contains(q) || + (j.patientName?.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(); } @@ -380,13 +382,16 @@ class _JobListCard extends StatelessWidget { @override Widget build(BuildContext context) { + final title = job.patientName?.trim().isNotEmpty == true + ? job.patientName! + : job.patientCode; final statusColor = _statusColor(job); final statusBg = _statusBg(job); final isOverdue = job.dueDate != null && job.dueDate!.isBefore(DateTime.now()); return Semantics( - label: job.patientCode, + label: title, button: true, excludeSemantics: true, child: Material( @@ -426,7 +431,7 @@ class _JobListCard extends StatelessWidget { children: [ Expanded( child: Text( - job.patientCode, + title, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -437,9 +442,22 @@ class _JobListCard extends StatelessWidget { ], ), const SizedBox(height: 3), - Text(job.prostheticType.label, + Text( + job.prostheticName?.isNotEmpty == true + ? '${job.prostheticType.label} · ${job.prostheticName}' + : job.prostheticType.label, style: const TextStyle( 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) ...[ const SizedBox(height: 2), Text( diff --git a/lib/features/clinic/jobs/new_job_screen.dart b/lib/features/clinic/jobs/new_job_screen.dart index 95bed00..e08ca33 100644 --- a/lib/features/clinic/jobs/new_job_screen.dart +++ b/lib/features/clinic/jobs/new_job_screen.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/providers/auth_provider.dart'; +import '../../../core/services/pricing_service.dart'; import '../../../core/theme/app_theme.dart'; -import '../../../models/clinic_discount.dart'; import '../../../models/job.dart'; import '../../../models/patient.dart'; import '../../../models/prosthetic_product.dart'; @@ -18,6 +18,11 @@ import '../../lab/products/lab_products_repository.dart'; import 'clinic_jobs_repository.dart'; import '../patients/clinic_patients_repository.dart'; +enum _PatientEntryMode { + selectExisting, + createNew, +} + String _mimeFromExt(String ext) => switch (ext) { 'jpg' || 'jpeg' => 'image/jpeg', 'png' => 'image/png', @@ -46,13 +51,17 @@ class _NewJobScreenState extends ConsumerState { // Form fields Map? _selectedLab; Patient? _selectedPatient; + final _patientNameController = TextEditingController(); + final _patientLastNameController = TextEditingController(); final _patientCodeController = TextEditingController(); ProstheticType? _selectedProstheticType; + JobWorkflowType? _selectedWorkflowType = JobWorkflowType.geleneksel; final Set _selectedTeeth = {}; final _colorController = TextEditingController(); final _descriptionController = TextEditingController(); DateTime? _dueDate; bool _provaRequired = true; + _PatientEntryMode _patientEntryMode = _PatientEntryMode.selectExisting; // State List> _labs = []; @@ -64,13 +73,16 @@ class _NewJobScreenState extends ConsumerState { final List _pendingFiles = []; // Patient search - bool _showPatientSearch = false; final _patientSearchController = TextEditingController(); List _patientResults = []; bool _patientSearchLoading = false; + Timer? _patientSearchDebounce; // Price preview + List _availableProducts = []; + ProstheticProduct? _selectedProduct; ProstheticProduct? _labProduct; + PricingBreakdown? _pricingBreakdown; double? _effectivePrice; bool _priceLoading = false; @@ -82,6 +94,9 @@ class _NewJobScreenState extends ConsumerState { @override void dispose() { + _patientSearchDebounce?.cancel(); + _patientNameController.dispose(); + _patientLastNameController.dispose(); _patientCodeController.dispose(); _colorController.dispose(); _descriptionController.dispose(); @@ -111,9 +126,15 @@ class _NewJobScreenState extends ConsumerState { } } - Future _fetchPrice() async { + Future _refreshProductsAndPrice() async { if (_selectedLab == null || _selectedProstheticType == null) { - setState(() { _labProduct = null; _effectivePrice = null; }); + setState(() { + _availableProducts = []; + _selectedProduct = null; + _labProduct = null; + _pricingBreakdown = null; + _effectivePrice = null; + }); return; } final labId = _selectedLab!['id'] as String; @@ -122,43 +143,73 @@ class _NewJobScreenState extends ConsumerState { setState(() => _priceLoading = true); 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; - try { - product = products.firstWhere((p) => p.prostheticType == ptValue); - } catch (_) { - product = null; + if (_selectedProduct != null) { + try { + product = matchingProducts.firstWhere( + (p) => p.id == _selectedProduct!.id, + ); + } catch (_) { + product = null; + } } + product ??= matchingProducts.isNotEmpty ? matchingProducts.first : 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; } final discounts = await DiscountRepository.instance.listDiscounts(labId); - final applicable = discounts.where((d) => - d.isActive && - (d.appliesToAll || d.clinicTenantId == clinicTenantId) && - (d.appliesToAllTypes || d.prostheticType == ptValue) - ).toList(); - - double price = product.unitPrice!; - for (final d in applicable) { - price = d.discountType == DiscountType.percentage - ? price * (1 - d.discountValue / 100) - : price - d.discountValue; - } + final breakdown = PricingService.instance.calculate( + product: product, + prostheticType: _selectedProstheticType!, + memberCount: _selectedTeeth.length, + clinicTenantId: clinicTenantId, + discounts: discounts, + ); setState(() { + _availableProducts = matchingProducts; + _selectedProduct = product; _labProduct = product; - _effectivePrice = price.clamp(0, double.infinity); + _pricingBreakdown = breakdown; + _effectivePrice = breakdown.finalAmount; _priceLoading = false; }); } catch (_) { - setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; }); + setState(() { + _availableProducts = []; + _selectedProduct = null; + _labProduct = null; + _pricingBreakdown = null; + _effectivePrice = null; + _priceLoading = false; + }); } } Future _searchPatients(String query) async { - if (query.trim().isEmpty) { - setState(() => _patientResults = []); + final normalizedQuery = query.trim(); + if (normalizedQuery.length < 2) { + if (!mounted) return; + setState(() { + _patientResults = []; + _patientSearchLoading = false; + }); return; } setState(() => _patientSearchLoading = true); @@ -166,16 +217,52 @@ class _NewJobScreenState extends ConsumerState { final tenantId = ref.read(authProvider).activeTenant!.tenant.id; 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(() { _patientResults = results; _patientSearchLoading = false; }); } catch (_) { + if (!mounted) return; 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 _pickDueDate() async { final pickedDate = await showDatePicker( context: context, @@ -231,16 +318,38 @@ class _NewJobScreenState extends ConsumerState { try { final auth = ref.read(authProvider); final tenantId = auth.activeTenant!.tenant.id; + final clinicName = auth.activeTenant!.tenant.companyName; 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( clinicTenantId: tenantId, labTenantId: _selectedLab!['id'] as String, + clinicName: clinicName, + labName: _selectedLab!['company_name'] as String? ?? 'Laboratuvar', patientCode: protocolNo, - prostheticId: '', + prostheticId: _selectedProduct?.id, prostheticType: _selectedProstheticType!, teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(), - patientId: _selectedPatient?.id, + patientId: patient?.id, color: _colorController.text.trim().isNotEmpty ? _colorController.text.trim() : null, @@ -248,6 +357,9 @@ class _NewJobScreenState extends ConsumerState { ? _descriptionController.text.trim() : null, dueDate: _dueDate?.toIso8601String(), + price: _effectivePrice, + currency: _labProduct?.currency, + workflowType: _selectedWorkflowType, provaRequired: _provaRequired, ); @@ -343,57 +455,62 @@ class _NewJobScreenState extends ConsumerState { ) .toList(), onChanged: (val) { - setState(() => _selectedLab = val); - _fetchPrice(); + setState(() { + _selectedLab = val; + _selectedProduct = null; + }); + _refreshProductsAndPrice(); }, validator: (val) => val == null ? 'Laboratuvar seçimi zorunludur' : null, ), const SizedBox(height: 16), - // Protocol number - _SectionLabel(label: 'Protokol No (İsteğe Bağlı)'), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _patientCodeController, - decoration: InputDecoration( - hintText: 'Boş bırakılırsa otomatik üretilir', - suffixIcon: _selectedPatient != null - ? const Icon(Icons.person, - color: AppColors.success) - : null, - ), - ), + _SectionLabel(label: 'Hasta / Protokol'), + const SizedBox(height: 8), + SegmentedButton<_PatientEntryMode>( + segments: const [ + ButtonSegment( + value: _PatientEntryMode.selectExisting, + icon: Icon(Icons.search_rounded), + label: Text('Mevcut Hasta'), ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - setState(() => _showPatientSearch = !_showPatientSearch); - }, - icon: const Icon(Icons.search), - label: const Text('Ara'), + ButtonSegment( + value: _PatientEntryMode.createNew, + icon: Icon(Icons.person_add_alt_1_rounded), + label: Text('Yeni Hasta'), ), ], + selected: {_patientEntryMode}, + onSelectionChanged: (selection) { + _setPatientEntryMode(selection.first); + }, ), - - // Patient search panel - if (_showPatientSearch) ...[ + if (_patientEntryMode == _PatientEntryMode.selectExisting) ...[ const SizedBox(height: 8), TextField( controller: _patientSearchController, decoration: const InputDecoration( hintText: 'Ad, soyad veya kod ile arayın...', prefixIcon: Icon(Icons.search), + helperText: 'Yazmaya başlayınca otomatik arar', ), - onChanged: _searchPatients, + onChanged: _onPatientSearchChanged, ), if (_patientSearchLoading) const Padding( padding: EdgeInsets.all(8), 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( (p) => ListTile( dense: true, @@ -403,14 +520,93 @@ class _NewJobScreenState extends ConsumerState { onTap: () { setState(() { _selectedPatient = p; + _patientNameController.text = p.firstName ?? ''; + _patientLastNameController.text = p.lastName ?? ''; _patientCodeController.text = p.patientCode; - _showPatientSearch = false; - _patientSearchController.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), @@ -430,12 +626,69 @@ class _NewJobScreenState extends ConsumerState { ) .toList(), onChanged: (val) { - setState(() => _selectedProstheticType = val); - _fetchPrice(); + setState(() { + _selectedProstheticType = val; + _selectedProduct = null; + }); + _refreshProductsAndPrice(); }, validator: (val) => val == null ? 'Protez türü zorunludur' : null, ), + const SizedBox(height: 16), + + _SectionLabel(label: 'Ürün'), + DropdownButtonFormField( + 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( + 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 if (_priceLoading) const Padding( @@ -450,6 +703,8 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 8), _PricePreviewChip( product: _labProduct!, + prostheticType: _selectedProstheticType, + breakdown: _pricingBreakdown, effectivePrice: _effectivePrice!, ), ], @@ -471,31 +726,43 @@ class _NewJobScreenState extends ConsumerState { // Bulk select row _TeethBulkBar( selectedTeeth: _selectedTeeth, - onSelectAll: () => setState(() { - _selectedTeeth.addAll([ - for (int i = 11; i <= 18; i++) i, - for (int i = 21; i <= 28; i++) i, - for (int i = 31; i <= 38; i++) i, - for (int i = 41; i <= 48; i++) i, - ]); - }), - onSelectUpper: () => setState(() { - final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]}; - if (upper.every(_selectedTeeth.contains)) { - _selectedTeeth.removeAll(upper); - } else { - _selectedTeeth.addAll(upper); - } - }), - onSelectLower: () => setState(() { - final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]}; - if (lower.every(_selectedTeeth.contains)) { - _selectedTeeth.removeAll(lower); - } else { - _selectedTeeth.addAll(lower); - } - }), - onClear: () => setState(() => _selectedTeeth.clear()), + onSelectAll: () { + setState(() { + _selectedTeeth.addAll([ + for (int i = 11; i <= 18; i++) i, + for (int i = 21; i <= 28; i++) i, + for (int i = 31; i <= 38; i++) i, + for (int i = 41; i <= 48; i++) i, + ]); + }); + _refreshProductsAndPrice(); + }, + onSelectUpper: () { + setState(() { + final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]}; + if (upper.every(_selectedTeeth.contains)) { + _selectedTeeth.removeAll(upper); + } else { + _selectedTeeth.addAll(upper); + } + }); + _refreshProductsAndPrice(); + }, + onSelectLower: () { + setState(() { + final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]}; + if (lower.every(_selectedTeeth.contains)) { + _selectedTeeth.removeAll(lower); + } else { + _selectedTeeth.addAll(lower); + } + }); + _refreshProductsAndPrice(); + }, + onClear: () { + setState(() => _selectedTeeth.clear()); + _refreshProductsAndPrice(); + }, ), const SizedBox(height: 8), _TeethGrid( @@ -508,6 +775,7 @@ class _NewJobScreenState extends ConsumerState { _selectedTeeth.add(t); } }); + _refreshProductsAndPrice(); }, ), const SizedBox(height: 16), @@ -908,16 +1176,28 @@ class _FilePicker 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 double effectivePrice; + final ProstheticType? prostheticType; + final PricingBreakdown? breakdown; @override Widget build(BuildContext context) { final currency = product.currency ?? 'TRY'; 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( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -938,9 +1218,13 @@ class _PricePreviewChip extends StatelessWidget { '${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency', 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) 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)), ) else diff --git a/lib/features/lab/discounts/discounts_screen.dart b/lib/features/lab/discounts/discounts_screen.dart index 39c975a..c4683f0 100644 --- a/lib/features/lab/discounts/discounts_screen.dart +++ b/lib/features/lab/discounts/discounts_screen.dart @@ -742,14 +742,14 @@ class _DiscountSheetState extends State<_DiscountSheet> { ), const SizedBox(height: 16), - const Text('Minimum Sipariş Adedi (İsteğe Bağlı)', + const Text('Minimum Faturalanabilir Adet (İsteğe Bağlı)', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), const SizedBox(height: 4), 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: TextStyle(fontSize: 11, color: AppColors.textMuted)), const SizedBox(height: 8), diff --git a/lib/features/lab/finance/lab_finance_repository.dart b/lib/features/lab/finance/lab_finance_repository.dart index 07646fc..8c53464 100644 --- a/lib/features/lab/finance/lab_finance_repository.dart +++ b/lib/features/lab/finance/lab_finance_repository.dart @@ -39,4 +39,30 @@ class LabFinanceRepository { } return {'pending': pending, 'paid': paid}; } + + Future> byCounterparty(String tenantId) async { + final entries = await listEntries(tenantId, limit: 300); + final map = {}; + + 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; + } } diff --git a/lib/features/lab/finance/lab_finance_screen.dart b/lib/features/lab/finance/lab_finance_screen.dart index 56f342d..23b6eb7 100644 --- a/lib/features/lab/finance/lab_finance_screen.dart +++ b/lib/features/lab/finance/lab_finance_screen.dart @@ -48,10 +48,12 @@ class _LabFinanceScreenState extends ConsumerState LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'), LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'), LabFinanceRepository.instance.summary(tenantId), + LabFinanceRepository.instance.byCounterparty(tenantId), ]).then((results) => _FinanceData( pending: results[0] as List, paid: results[1] as List, summary: results[2] as Map, + counterparties: results[3] as List, )); }); } @@ -199,6 +201,15 @@ class _LabFinanceScreenState extends ConsumerState ], ), ), + 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( tabs: [s.pending, s.collected], selected: _tabController.index, @@ -240,11 +251,13 @@ class _FinanceData { required this.pending, required this.paid, required this.summary, + required this.counterparties, }); final List pending; final List paid; final Map summary; + final List counterparties; } 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 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), + ], + ], + ), + ); + } +} diff --git a/lib/features/lab/jobs/lab_all_jobs_screen.dart b/lib/features/lab/jobs/lab_all_jobs_screen.dart index 482cbe7..d8cce53 100644 --- a/lib/features/lab/jobs/lab_all_jobs_screen.dart +++ b/lib/features/lab/jobs/lab_all_jobs_screen.dart @@ -273,8 +273,10 @@ class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> { if (q.isEmpty) return jobs; return jobs.where((j) => j.patientCode.toLowerCase().contains(q) || + (j.patientName?.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(); } @@ -591,8 +593,10 @@ class _LabJobsTabState extends ConsumerState<_LabJobsTab> { if (q.isNotEmpty) { list = list.where((j) { return j.patientCode.toLowerCase().contains(q) || + (j.patientName?.toLowerCase().contains(q) ?? false) || (j.clinicName?.toLowerCase().contains(q) ?? false) || j.prostheticType.label.toLowerCase().contains(q) || + (j.prostheticName?.toLowerCase().contains(q) ?? false) || (j.currentStep?.label.toLowerCase().contains(q) ?? false); }).toList(); } @@ -722,12 +726,15 @@ class _LabJobCard extends StatelessWidget { @override Widget build(BuildContext context) { + final title = job.patientName?.trim().isNotEmpty == true + ? job.patientName! + : job.patientCode; final isOverdue = job.dueDate != null && job.dueDate!.isBefore(DateTime.now()); final accentColor = _statusColor(job.status); return Semantics( - label: job.patientCode, + label: title, button: true, excludeSemantics: true, child: Material( @@ -771,7 +778,7 @@ class _LabJobCard extends StatelessWidget { children: [ Expanded( child: Text( - job.patientCode, + title, style: const TextStyle( fontSize: 15, 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), Row( children: [ @@ -827,7 +844,9 @@ class _LabJobCard extends StatelessWidget { borderRadius: BorderRadius.circular(6), ), child: Text( - job.prostheticType.label, + job.prostheticName?.isNotEmpty == true + ? '${job.prostheticType.label} · ${job.prostheticName}' + : job.prostheticType.label, style: const TextStyle( fontSize: 11, color: AppColors.textSecondary, diff --git a/lib/features/lab/jobs/lab_job_detail_screen.dart b/lib/features/lab/jobs/lab_job_detail_screen.dart index e46d171..89c6af3 100644 --- a/lib/features/lab/jobs/lab_job_detail_screen.dart +++ b/lib/features/lab/jobs/lab_job_detail_screen.dart @@ -258,7 +258,9 @@ class _LabJobDetailScreenState extends ConsumerState { children: [ Expanded( child: Text( - job.patientCode, + job.patientName?.isNotEmpty == true + ? job.patientName! + : job.patientCode, style: Theme.of(context) .textTheme .headlineSmall @@ -289,10 +291,40 @@ class _LabJobDetailScreenState extends ConsumerState { icon: Icons.business, label: 'Klinik', 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( icon: Icons.medical_services_outlined, label: 'Protez Tipi', 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( icon: Icons.format_list_numbered, label: 'Üye Sayısı', @@ -761,4 +793,3 @@ class _JobStepper extends StatelessWidget { ); } } - diff --git a/lib/features/lab/jobs/lab_jobs_inbound_screen.dart b/lib/features/lab/jobs/lab_jobs_inbound_screen.dart index b8dea87..98a0b3d 100644 --- a/lib/features/lab/jobs/lab_jobs_inbound_screen.dart +++ b/lib/features/lab/jobs/lab_jobs_inbound_screen.dart @@ -188,8 +188,11 @@ class _InboundJobCardState extends State<_InboundJobCard> { @override Widget build(BuildContext context) { final job = widget.job; + final title = job.patientName?.trim().isNotEmpty == true + ? job.patientName! + : job.patientCode; return Semantics( - label: job.patientCode, + label: title, button: true, excludeSemantics: true, child: Dismissible( @@ -246,9 +249,17 @@ class _InboundJobCardState extends State<_InboundJobCard> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - job.patientCode, + title, 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), Text( job.clinicName ?? 'Klinik', @@ -259,7 +270,9 @@ class _InboundJobCardState extends State<_InboundJobCard> { Row( children: [ _Chip( - label: job.prostheticType.label, + label: job.prostheticName?.isNotEmpty == true + ? '${job.prostheticType.label} · ${job.prostheticName}' + : job.prostheticType.label, color: AppColors.inProgressBg, textColor: AppColors.inProgress, ), diff --git a/lib/features/lab/jobs/lab_jobs_repository.dart b/lib/features/lab/jobs/lab_jobs_repository.dart index 711d2c2..1b878ab 100644 --- a/lib/features/lab/jobs/lab_jobs_repository.dart +++ b/lib/features/lab/jobs/lab_jobs_repository.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'package:pocketbase/pocketbase.dart'; import '../../../core/api/pocketbase_client.dart'; +import '../../../core/services/finance_service.dart'; import '../../../core/services/job_history_service.dart'; import '../../../models/job.dart'; -const _listExpand = 'clinic_tenant_id,lab_tenant_id'; -const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id'; +const _listExpand = 'clinic_tenant_id,lab_tenant_id,patient_id'; +const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id'; class LabJobsRepository { LabJobsRepository._(); @@ -96,6 +97,7 @@ class LabJobsRepository { final record = await _pb.collection('jobs').update(jobId, body: { 'status': 'cancelled', }); + await FinanceService.instance.deletePendingEntriesForJob(jobId); unawaited(JobHistoryService.instance.append( jobId: jobId, clinicTenantId: job.clinicTenantId, diff --git a/lib/models/finance_entry.dart b/lib/models/finance_entry.dart index 5b79e4e..240a407 100644 --- a/lib/models/finance_entry.dart +++ b/lib/models/finance_entry.dart @@ -21,6 +21,7 @@ class FinanceEntry { required this.amount, required this.currency, required this.status, + this.counterpartyTenantId, this.paidAt, this.counterpartyName, this.patientCode, @@ -34,6 +35,7 @@ class FinanceEntry { final double amount; final String currency; final FinanceStatus status; + final String? counterpartyTenantId; final String? paidAt; final String? counterpartyName; final String? patientCode; @@ -53,6 +55,7 @@ class FinanceEntry { currency: j['currency'] as String? ?? 'TRY', status: FinanceStatus.values.firstWhere((e) => e.value == j['status'], orElse: () => FinanceStatus.pending), + counterpartyTenantId: _str(j['counterparty_tenant_id']), paidAt: _str(j['paid_at']), counterpartyName: _str(j['counterparty_name']), 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; +} diff --git a/lib/models/job.dart b/lib/models/job.dart index 670c331..3b1fc53 100644 --- a/lib/models/job.dart +++ b/lib/models/job.dart @@ -13,6 +13,8 @@ enum JobStep { enum JobLocation { atClinic, atLab } +enum JobWorkflowType { arjinat, geleneksel, dijital } + enum ProstheticType { metalPorselen, 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 ─────────────────────────────────────────────────────────── extension ProstheticTypeExt on ProstheticType { @@ -146,7 +162,9 @@ class Job { required this.status, required this.dateCreated, this.patientId, + this.patientName, this.prostheticId, + this.prostheticName, this.teeth = const [], this.color, this.description, @@ -154,6 +172,7 @@ class Job { this.currency, this.currentStep, this.location = JobLocation.atClinic, + this.workflowType, this.dueDate, this.clinicName, this.labName, @@ -165,8 +184,10 @@ class Job { final String clinicTenantId; final String labTenantId; final String? patientId; + final String? patientName; final String patientCode; final String? prostheticId; + final String? prostheticName; final ProstheticType prostheticType; final int memberCount; final List teeth; @@ -177,6 +198,7 @@ class Job { final JobStatus status; final JobStep? currentStep; final JobLocation location; + final JobWorkflowType? workflowType; final DateTime? dueDate; final DateTime dateCreated; final List attachments; @@ -192,6 +214,7 @@ class Job { JobStatus? status, JobStep? currentStep, JobLocation? location, + JobWorkflowType? workflowType, String? clinicName, String? labName, bool clearCurrentStep = false, @@ -201,8 +224,10 @@ class Job { clinicTenantId: clinicTenantId, labTenantId: labTenantId, patientId: patientId, + patientName: patientName, patientCode: patientCode, prostheticId: prostheticId, + prostheticName: prostheticName, prostheticType: prostheticType, memberCount: memberCount, teeth: teeth, @@ -213,6 +238,7 @@ class Job { status: status ?? this.status, currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep), location: location ?? this.location, + workflowType: workflowType ?? this.workflowType, dueDate: dueDate, dateCreated: dateCreated, attachments: attachments, @@ -240,6 +266,8 @@ class Job { final expand = j['expand'] as Map?; final clinicExp = expand?['clinic_tenant_id'] as Map?; final labExp = expand?['lab_tenant_id'] as Map?; + final patientExp = expand?['patient_id'] as Map?; + final prostheticExp = expand?['prosthetic_id'] as Map?; String? str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; @@ -250,8 +278,10 @@ class Job { clinicTenantId: j['clinic_tenant_id'] as String, labTenantId: j['lab_tenant_id'] as String, patientId: str(j['patient_id']), + patientName: _patientName(patientExp), patientCode: j['patient_code'] as String, prostheticId: str(j['prosthetic_id']), + prostheticName: prostheticExp?['name'] as String?, prostheticType: _parseProstheticType(j['prosthetic_type'] as String), memberCount: (j['member_count'] as num).toInt(), teeth: j['teeth'] is List @@ -267,6 +297,9 @@ class Job { : null, location: 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 ? DateTime.parse(j['due_date'] as String) : null, @@ -299,6 +332,25 @@ class Job { _ => JobStep.olcu, }; + static JobWorkflowType _parseWorkflowType(String s) => switch (s) { + 'arjinat' => JobWorkflowType.arjinat, + 'dijital' => JobWorkflowType.dijital, + _ => JobWorkflowType.geleneksel, + }; + + static String? _patientName(Map? 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() + .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) { 'zirkonyum' => ProstheticType.zirkonyum, 'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum, diff --git a/pubspec.lock b/pubspec.lock index faad228..70a956a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -617,10 +617,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1094,10 +1094,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timing: dependency: transitive description: