feat: improve patient flow and pricing workflow

This commit is contained in:
egecankomur
2026-06-12 00:04:53 +03:00
parent e12587398b
commit b42f68214e
26 changed files with 1283 additions and 243 deletions
+3
View File
@@ -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/
+7
View File
@@ -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
+45 -3
View File
@@ -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,
}),
); );
} }
}
} }
+12 -2
View File
@@ -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 {
+14
View File
@@ -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 (₺)',
+10 -2
View File
@@ -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,
+59 -63
View File
@@ -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;
+117
View File
@@ -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);
}
}
+87
View File
@@ -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,
);
}
}
+45 -1
View File
@@ -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(
+354 -70
View File
@@ -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),
],
],
),
);
}
}
+23 -4
View File
@@ -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,
+23
View File
@@ -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;
}
+52
View File
@@ -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
View File
@@ -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: