diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4bff20e..f9219a4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + ? workflowOverrides, }) async { final body = {}; if (companyName != null) body['company_name'] = companyName; if (defaultCurrency != null) body['default_currency'] = defaultCurrency; + if (companyAddress != null) body['company_address'] = companyAddress; + if (city != null) body['city'] = city; + if (district != null) body['district'] = district; + if (latitude != null) body['latitude'] = latitude; + if (longitude != null) body['longitude'] = longitude; + if (workflowOverrides != null) { + body['workflow_overrides'] = workflowOverrides; + } if (body.isEmpty) return; await _pb.collection('tenants').update(id, body: body); } @@ -85,26 +100,51 @@ class AuthRepository { final record = _pb.authStore.record!; final user = UserProfile.fromJson(record.toJson()); List tenants = []; + List platformMemberships = []; try { tenants = await _fetchUserTenants(record.id); } catch (_) {} - return AuthResult(user: user, tenants: tenants); + try { + platformMemberships = await _fetchPlatformMemberships(record.id); + } catch (_) {} + return AuthResult( + user: user, + tenants: tenants, + platformMemberships: platformMemberships, + ); } Future> _fetchUserTenants(String userId) async { final result = await _pb.collection('tenant_members').getList( - filter: 'user_id = "$userId"', - expand: 'tenant_id', - perPage: 50, - ); + filter: 'user_id = "$userId"', + expand: 'tenant_id', + perPage: 50, + ); return result.items .map((r) => TenantMembership.fromJson(r.toJson())) .toList(); } + + Future> _fetchPlatformMemberships( + String userId, + ) async { + final result = await _pb.collection('platform_memberships').getList( + filter: 'user_id = "$userId"', + perPage: 20, + ); + return result.items + .map((r) => PlatformMembership.fromJson(r.toJson())) + .toList(); + } } class AuthResult { - const AuthResult({required this.user, required this.tenants}); + const AuthResult({ + required this.user, + required this.tenants, + this.platformMemberships = const [], + }); final UserProfile user; final List tenants; + final List platformMemberships; } diff --git a/lib/core/location/location_access_service.dart b/lib/core/location/location_access_service.dart new file mode 100644 index 0000000..aa20ca3 --- /dev/null +++ b/lib/core/location/location_access_service.dart @@ -0,0 +1,30 @@ +import 'package:geolocator/geolocator.dart'; + +class LocationAccessService { + LocationAccessService._(); + + static Future getCurrentPosition() async { + final enabled = await Geolocator.isLocationServiceEnabled(); + if (!enabled) { + throw Exception( + 'Konum servisleri kapalı. Lütfen cihaz ayarlarından açın.'); + } + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.denied) { + throw Exception('Konum izni verilmedi.'); + } + + if (permission == LocationPermission.deniedForever) { + throw Exception( + 'Konum izni kalıcı olarak reddedildi. Lütfen cihaz ayarlarından izin verin.', + ); + } + + return Geolocator.getCurrentPosition(); + } +} diff --git a/lib/core/maps/open_free_map.dart b/lib/core/maps/open_free_map.dart new file mode 100644 index 0000000..8667b5e --- /dev/null +++ b/lib/core/maps/open_free_map.dart @@ -0,0 +1,12 @@ +class OpenFreeMap { + OpenFreeMap._(); + + static const libertyStyle = 'https://tiles.openfreemap.org/styles/liberty'; + static const positronStyle = 'https://tiles.openfreemap.org/styles/positron'; + static const brightStyle = 'https://tiles.openfreemap.org/styles/bright'; + static const darkStyle = 'https://tiles.openfreemap.org/styles/dark'; + static const fiordStyle = 'https://tiles.openfreemap.org/styles/fiord'; + + static const attribution = + 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap'; +} diff --git a/lib/core/providers/auth_provider.dart b/lib/core/providers/auth_provider.dart index f2c984b..57f83fa 100644 --- a/lib/core/providers/auth_provider.dart +++ b/lib/core/providers/auth_provider.dart @@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pocketbase/pocketbase.dart'; import '../auth/auth_repository.dart'; import '../services/notification_service.dart'; +import '../../models/platform_admin.dart'; import '../../models/tenant.dart'; import '../../models/user_profile.dart'; +import '../../features/shared/tenant_location_data.dart'; import 'locale_provider.dart'; class AuthState { @@ -12,6 +14,7 @@ class AuthState { this.profile, this.activeTenant, this.memberships = const [], + this.platformMemberships = const [], this.isLoading = true, this.error, }); @@ -19,15 +22,24 @@ class AuthState { final UserProfile? profile; final TenantMembership? activeTenant; final List memberships; + final List platformMemberships; final bool isLoading; final String? error; bool get isAuthenticated => profile != null; + bool get isSuperAdmin => platformMemberships.any((m) => m.isSuperAdmin); + PlatformMembership? get primaryPlatformMembership { + for (final membership in platformMemberships) { + if (membership.isActive) return membership; + } + return null; + } AuthState copyWith({ UserProfile? profile, TenantMembership? activeTenant, List? memberships, + List? platformMemberships, bool? isLoading, String? error, bool clearError = false, @@ -36,6 +48,7 @@ class AuthState { profile: profile ?? this.profile, activeTenant: activeTenant ?? this.activeTenant, memberships: memberships ?? this.memberships, + platformMemberships: platformMemberships ?? this.platformMemberships, isLoading: isLoading ?? this.isLoading, error: clearError ? null : (error ?? this.error), ); @@ -60,11 +73,12 @@ class AuthNotifier extends StateNotifier { state = AuthState( profile: result.user, memberships: result.tenants, - activeTenant: - result.tenants.isEmpty ? null : result.tenants.first, + platformMemberships: result.platformMemberships, + activeTenant: result.tenants.isEmpty ? null : result.tenants.first, isLoading: false, ); - final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab; + final isLab = + result.tenants.isNotEmpty && result.tenants.first.tenant.isLab; NotificationService.loginUser(result.user.id, isLab: isLab); _applyLocale(result.user.preferredLanguage); } catch (_) { @@ -93,11 +107,12 @@ class AuthNotifier extends StateNotifier { state = AuthState( profile: result.user, memberships: result.tenants, - activeTenant: - result.tenants.isEmpty ? null : result.tenants.first, + platformMemberships: result.platformMemberships, + activeTenant: result.tenants.isEmpty ? null : result.tenants.first, isLoading: false, ); - final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab; + final isLab = + result.tenants.isNotEmpty && result.tenants.first.tenant.isLab; NotificationService.loginUser(result.user.id, isLab: isLab); _applyLocale(result.user.preferredLanguage); } catch (e) { @@ -122,8 +137,8 @@ class AuthNotifier extends StateNotifier { state = AuthState( profile: result.user, memberships: result.tenants, - activeTenant: - result.tenants.isEmpty ? null : result.tenants.first, + platformMemberships: result.platformMemberships, + activeTenant: result.tenants.isEmpty ? null : result.tenants.first, isLoading: false, ); } catch (e) { @@ -157,6 +172,7 @@ class AuthNotifier extends StateNotifier { state = state.copyWith( profile: result.user, memberships: result.tenants, + platformMemberships: result.platformMemberships, activeTenant: newActive, ); } catch (_) {} @@ -172,11 +188,19 @@ class AuthNotifier extends StateNotifier { required String tenantId, required String companyName, String? defaultCurrency, + TenantLocationData? location, + List? workflowOverrides, }) async { await _repo.updateTenant( tenantId, companyName: companyName, defaultCurrency: defaultCurrency, + companyAddress: location?.address, + city: location?.city, + district: location?.district, + latitude: location?.latitude, + longitude: location?.longitude, + workflowOverrides: workflowOverrides, ); await refresh(); } @@ -194,8 +218,7 @@ class AuthNotifier extends StateNotifier { } } -final authProvider = - StateNotifierProvider((ref) { +final authProvider = StateNotifierProvider((ref) { return AuthNotifier( onLocaleLoaded: (code) => ref.read(localeProvider.notifier).setLocale(Locale(code)), diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 263a99e..a483e6b 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -10,6 +10,7 @@ import '../../models/tenant.dart'; import '../../features/auth/sign_in_screen.dart'; import '../../features/auth/sign_up_screen.dart'; import '../../features/auth/onboarding_screen.dart'; +import '../../features/auth/welcome_pricing_screen.dart'; import '../../features/clinic/dashboard/clinic_dashboard_screen.dart'; import '../../features/clinic/jobs/clinic_jobs_screen.dart'; import '../../features/clinic/jobs/clinic_job_detail_screen.dart'; @@ -37,6 +38,7 @@ import '../../models/connection.dart'; const routeSignIn = '/sign-in'; const routeSignUp = '/sign-up'; const routeOnboarding = '/onboarding'; +const routeWelcome = '/welcome'; // Clinic routes const routeClinicDashboard = '/clinic/dashboard'; @@ -65,15 +67,20 @@ const routeLabAi = '/lab/ai'; const routeLabDiscounts = '/lab/discounts'; List buildRoutes() => [ + GoRoute( + path: routeWelcome, builder: (_, __) => const WelcomePricingScreen()), GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()), GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()), - GoRoute(path: routeOnboarding, builder: (_, __) => const OnboardingScreen()), + GoRoute( + path: routeOnboarding, builder: (_, __) => const OnboardingScreen()), // ── Clinic shell ────────────────────────────────────────────────────── ShellRoute( builder: (context, state, child) => _ClinicShell(child: child), routes: [ - GoRoute(path: routeClinicDashboard, builder: (_, __) => const ClinicDashboardScreen()), + GoRoute( + path: routeClinicDashboard, + builder: (_, __) => const ClinicDashboardScreen()), GoRoute( path: routeClinicJobs, builder: (_, __) => const ClinicJobsScreen(), @@ -81,7 +88,8 @@ List buildRoutes() => [ GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()), GoRoute( path: ':jobId', - builder: (_, s) => ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!), + builder: (_, s) => + ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!), ), ], ), @@ -91,15 +99,25 @@ List buildRoutes() => [ routes: [ GoRoute( path: ':patientId', - builder: (_, s) => ClinicPatientDetailScreen(patientId: s.pathParameters['patientId']!), + builder: (_, s) => ClinicPatientDetailScreen( + patientId: s.pathParameters['patientId']!), ), ], ), - GoRoute(path: routeClinicConnections, builder: (_, __) => const ClinicConnectionsScreen()), - GoRoute(path: routeClinicFinance, builder: (_, __) => const ClinicFinanceScreen()), - GoRoute(path: routeClinicSettings, builder: (_, __) => const ClinicSettingsScreen()), - GoRoute(path: routeClinicReports, builder: (_, __) => const ReportsScreen()), - GoRoute(path: routeClinicAi, builder: (_, __) => const AiChatScreen()), + GoRoute( + path: routeClinicConnections, + builder: (_, __) => const ClinicConnectionsScreen()), + GoRoute( + path: routeClinicFinance, + builder: (_, __) => const ClinicFinanceScreen()), + GoRoute( + path: routeClinicSettings, + builder: (_, __) => const ClinicSettingsScreen()), + GoRoute( + path: routeClinicReports, + builder: (_, __) => const ReportsScreen()), + GoRoute( + path: routeClinicAi, builder: (_, __) => const AiChatScreen()), ], ), @@ -107,19 +125,26 @@ List buildRoutes() => [ ShellRoute( builder: (context, state, child) => _LabShell(child: child), routes: [ - GoRoute(path: routeLabDashboard, builder: (_, __) => const LabDashboardScreen()), - GoRoute(path: routeLabJobsInbound, builder: (_, __) => const LabJobsInboundScreen()), + GoRoute( + path: routeLabDashboard, + builder: (_, __) => const LabDashboardScreen()), + GoRoute( + path: routeLabJobsInbound, + builder: (_, __) => const LabJobsInboundScreen()), GoRoute( path: routeLabJobsAll, builder: (_, __) => const LabAllJobsScreen(), routes: [ GoRoute( path: ':jobId', - builder: (_, s) => LabJobDetailScreen(jobId: s.pathParameters['jobId']!), + builder: (_, s) => + LabJobDetailScreen(jobId: s.pathParameters['jobId']!), ), ], ), - GoRoute(path: routeLabProducts, builder: (_, __) => const LabProductsScreen()), + GoRoute( + path: routeLabProducts, + builder: (_, __) => const LabProductsScreen()), GoRoute( path: routeLabConnections, builder: (_, __) => const LabConnectionsScreen(), @@ -141,10 +166,17 @@ List buildRoutes() => [ ), ], ), - GoRoute(path: routeLabDiscounts, builder: (_, __) => const DiscountsScreen()), - GoRoute(path: routeLabFinance, builder: (_, __) => const LabFinanceScreen()), - GoRoute(path: routeLabSettings, builder: (_, __) => const LabSettingsScreen()), - GoRoute(path: routeLabReports, builder: (_, __) => const ReportsScreen()), + GoRoute( + path: routeLabDiscounts, + builder: (_, __) => const DiscountsScreen()), + GoRoute( + path: routeLabFinance, + builder: (_, __) => const LabFinanceScreen()), + GoRoute( + path: routeLabSettings, + builder: (_, __) => const LabSettingsScreen()), + GoRoute( + path: routeLabReports, builder: (_, __) => const ReportsScreen()), GoRoute(path: routeLabAi, builder: (_, __) => const AiChatScreen()), ], ), @@ -216,11 +248,36 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> { String _selectedRoute = routeClinicDashboard; List<_NavItem> _clinicTopSingles(AppStrings s) => [ - _NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true), - _NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), - _NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true), - _NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true), + _NavItem( + route: routeClinicDashboard, + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home_rounded), + label: s.homeTitle, + visible: (_) => true), + _NavItem( + route: routeClinicJobs, + icon: const Icon(Icons.work_outline_rounded), + selectedIcon: const Icon(Icons.work_rounded), + label: s.jobsTitle, + visible: (m) => m?.showJobs ?? true), + _NavItem( + route: routeClinicPatients, + icon: const Icon(Icons.people_outline_rounded), + selectedIcon: const Icon(Icons.people_rounded), + label: s.patientsTitle, + visible: (m) => m?.showPatients ?? true), + _NavItem( + route: routeClinicFinance, + icon: const Icon(Icons.account_balance_outlined), + selectedIcon: const Icon(Icons.account_balance_rounded), + label: s.finance, + visible: (m) => m?.showFinance ?? true), + _NavItem( + route: routeClinicAi, + icon: const Icon(Icons.auto_awesome_outlined), + selectedIcon: const Icon(Icons.auto_awesome_rounded), + label: s.aiAssistant, + visible: (_) => true), ]; List<_NavGroup> _clinicGroups(AppStrings s) => [ @@ -229,22 +286,62 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> { icon: Icons.tune_rounded, selectedIcon: Icons.tune_rounded, items: [ - _NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true), - _NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true), + _NavItem( + route: routeClinicConnections, + icon: const Icon(Icons.link_rounded), + selectedIcon: const Icon(Icons.link_rounded), + label: s.connections, + visible: (_) => true), + _NavItem( + route: routeClinicReports, + icon: const Icon(Icons.bar_chart_outlined), + selectedIcon: const Icon(Icons.bar_chart_rounded), + label: s.reports, + visible: (_) => true), ], ), ]; List<_NavItem> _clinicBottomSingles(AppStrings s) => [ - _NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + _NavItem( + route: routeClinicSettings, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings_rounded), + label: s.settings, + visible: (_) => true), ]; List<_NavItem> _clinicMobileItems(AppStrings s) => [ - _NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true), - _NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), - _NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true), - _NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + _NavItem( + route: routeClinicDashboard, + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home_rounded), + label: s.homeTitle, + visible: (_) => true), + _NavItem( + route: routeClinicJobs, + icon: const Icon(Icons.work_outline_rounded), + selectedIcon: const Icon(Icons.work_rounded), + label: s.jobsTitle, + visible: (m) => m?.showJobs ?? true), + _NavItem( + route: routeClinicPatients, + icon: const Icon(Icons.people_outline_rounded), + selectedIcon: const Icon(Icons.people_rounded), + label: s.patientsTitle, + visible: (m) => m?.showPatients ?? true), + _NavItem( + route: routeClinicFinance, + icon: const Icon(Icons.account_balance_outlined), + selectedIcon: const Icon(Icons.account_balance_rounded), + label: s.finance, + visible: (m) => m?.showFinance ?? true), + _NavItem( + route: routeClinicSettings, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings_rounded), + label: s.settings, + visible: (_) => true), ]; List<_SidebarEntry> _allEntries(AppStrings s) { @@ -265,7 +362,8 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> { @override 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) { final entries = _allEntries(s); @@ -289,7 +387,8 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> { // Mobile: only core items in bottom nav final membership = ref.read(authProvider).activeTenant; - final items = _clinicMobileItems(s).where((it) => it.visible(membership)).toList(); + final items = + _clinicMobileItems(s).where((it) => it.visible(membership)).toList(); final flatIndex = items.indexWhere((it) => it.route == _selectedRoute); final clampedIndex = flatIndex >= 0 ? flatIndex : 0; @@ -317,7 +416,10 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> { Semantics( label: it.label, button: true, - child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label), + child: NavigationDestination( + icon: it.icon, + selectedIcon: it.selectedIcon, + label: it.label), ), ], ), @@ -339,11 +441,36 @@ class _LabShellState extends ConsumerState<_LabShell> { String _selectedRoute = routeLabDashboard; List<_NavItem> _labTopSingles(AppStrings s) => [ - _NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true), - _NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), - _NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true), - _NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true), + _NavItem( + route: routeLabDashboard, + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home_rounded), + label: s.homeTitle, + visible: (_) => true), + _NavItem( + route: routeLabJobsAll, + icon: const Icon(Icons.work_outline_rounded), + selectedIcon: const Icon(Icons.work_rounded), + label: s.jobsTitle, + visible: (m) => m?.showJobs ?? true), + _NavItem( + route: routeLabProducts, + icon: const Icon(Icons.inventory_2_outlined), + selectedIcon: const Icon(Icons.inventory_2_rounded), + label: s.productsTitle, + visible: (m) => m?.showProducts ?? true), + _NavItem( + route: routeLabFinance, + icon: const Icon(Icons.account_balance_outlined), + selectedIcon: const Icon(Icons.account_balance_rounded), + label: s.finance, + visible: (m) => m?.showFinance ?? true), + _NavItem( + route: routeLabAi, + icon: const Icon(Icons.auto_awesome_outlined), + selectedIcon: const Icon(Icons.auto_awesome_rounded), + label: s.aiAssistant, + visible: (_) => true), ]; List<_NavGroup> _labGroups(AppStrings s) => [ @@ -352,23 +479,68 @@ class _LabShellState extends ConsumerState<_LabShell> { icon: Icons.tune_rounded, selectedIcon: Icons.tune_rounded, items: [ - _NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true), - _NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: s.discounts, visible: (_) => true), - _NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true), + _NavItem( + route: routeLabConnections, + icon: const Icon(Icons.link_rounded), + selectedIcon: const Icon(Icons.link_rounded), + label: s.connections, + visible: (_) => true), + _NavItem( + route: routeLabDiscounts, + icon: const Icon(Icons.local_offer_outlined), + selectedIcon: const Icon(Icons.local_offer_rounded), + label: s.discounts, + visible: (_) => true), + _NavItem( + route: routeLabReports, + icon: const Icon(Icons.bar_chart_outlined), + selectedIcon: const Icon(Icons.bar_chart_rounded), + label: s.reports, + visible: (_) => true), ], ), ]; List<_NavItem> _labBottomSingles(AppStrings s) => [ - _NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + _NavItem( + route: routeLabSettings, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings_rounded), + label: s.settings, + visible: (_) => true), ]; List<_NavItem> _labMobileItems(AppStrings s) => [ - _NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: s.homeTitle, visible: (_) => true), - _NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), - _NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: s.productsTitle, visible: (m) => m?.showProducts ?? true), - _NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: s.finance, visible: (m) => m?.showFinance ?? true), - _NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), + _NavItem( + route: routeLabDashboard, + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home_rounded), + label: s.homeTitle, + visible: (_) => true), + _NavItem( + route: routeLabJobsAll, + icon: const Icon(Icons.work_outline_rounded), + selectedIcon: const Icon(Icons.work_rounded), + label: s.jobsTitle, + visible: (m) => m?.showJobs ?? true), + _NavItem( + route: routeLabProducts, + icon: const Icon(Icons.inventory_2_outlined), + selectedIcon: const Icon(Icons.inventory_2_rounded), + label: s.productsTitle, + visible: (m) => m?.showProducts ?? true), + _NavItem( + route: routeLabFinance, + icon: const Icon(Icons.account_balance_outlined), + selectedIcon: const Icon(Icons.account_balance_rounded), + label: s.finance, + visible: (m) => m?.showFinance ?? true), + _NavItem( + route: routeLabSettings, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings_rounded), + label: s.settings, + visible: (_) => true), ]; List<_SidebarEntry> _allEntries(AppStrings s) { @@ -389,7 +561,8 @@ class _LabShellState extends ConsumerState<_LabShell> { @override 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) { final entries = _allEntries(s); @@ -413,7 +586,8 @@ class _LabShellState extends ConsumerState<_LabShell> { // Mobile: only core items in bottom nav final membership = ref.read(authProvider).activeTenant; - final items = _labMobileItems(s).where((it) => it.visible(membership)).toList(); + final items = + _labMobileItems(s).where((it) => it.visible(membership)).toList(); final flatIndex = items.indexWhere((it) => it.route == _selectedRoute); final clampedIndex = flatIndex >= 0 ? flatIndex : 0; @@ -441,7 +615,10 @@ class _LabShellState extends ConsumerState<_LabShell> { Semantics( label: it.label, button: true, - child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label), + child: NavigationDestination( + icon: it.icon, + selectedIcon: it.selectedIcon, + label: it.label), ), ], ), @@ -483,7 +660,10 @@ class _DesktopSidebarState extends State<_DesktopSidebar> { decoration: const BoxDecoration( color: AppColors.surface, border: Border(right: BorderSide(color: AppColors.border)), - boxShadow: [BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0))], + boxShadow: [ + BoxShadow( + color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0)) + ], ), child: ClipRect( child: Column( @@ -492,7 +672,8 @@ class _DesktopSidebarState extends State<_DesktopSidebar> { Container( height: _DesktopSidebar.headerHeight, decoration: const BoxDecoration( - gradient: LinearGradient(colors: [AppColors.primary, AppColors.accent]), + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.accent]), border: Border(bottom: BorderSide(color: AppColors.border)), ), padding: const EdgeInsets.symmetric(horizontal: 16), @@ -504,15 +685,21 @@ class _DesktopSidebarState extends State<_DesktopSidebar> { decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(9), - border: Border.all(color: Colors.white.withValues(alpha: 0.25)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.25)), ), - child: const Center(child: ToothLogo(size: 18, color: Colors.white)), + child: const Center( + child: ToothLogo(size: 18, color: Colors.white)), ), if (_open) ...[ const SizedBox(width: 10), const Text( 'DLS', - style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 1), + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w800, + letterSpacing: 1), ), ], ], @@ -525,8 +712,7 @@ class _DesktopSidebarState extends State<_DesktopSidebar> { child: Column( children: [ const SizedBox(height: 8), - for (final entry in widget.entries) - _buildEntry(entry), + for (final entry in widget.entries) _buildEntry(entry), const SizedBox(height: 8), ], ), @@ -545,17 +731,24 @@ class _DesktopSidebarState extends State<_DesktopSidebar> { child: SizedBox( height: 48, child: Row( - mainAxisAlignment: _open ? MainAxisAlignment.start : MainAxisAlignment.center, + mainAxisAlignment: _open + ? MainAxisAlignment.start + : MainAxisAlignment.center, children: [ if (_open) const SizedBox(width: 20), AnimatedRotation( duration: const Duration(milliseconds: 220), turns: _open ? 0.5 : 0, - child: const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted, size: 20), + child: const Icon(Icons.chevron_right_rounded, + color: AppColors.textMuted, size: 20), ), if (_open) ...[ const SizedBox(width: 8), - const Text('Daralt', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textMuted)), + const Text('Daralt', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textMuted)), ], ], ), @@ -629,7 +822,9 @@ class _SidebarItem extends StatelessWidget { children: [ IconTheme( data: IconThemeData( - color: selected ? AppColors.primary : AppColors.textSecondary, + color: selected + ? AppColors.primary + : AppColors.textSecondary, size: 20, ), child: selected ? selectedIcon : icon, @@ -639,8 +834,11 @@ class _SidebarItem extends StatelessWidget { label, style: TextStyle( fontSize: 14, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: selected ? AppColors.primary : AppColors.textSecondary, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w500, + color: selected + ? AppColors.primary + : AppColors.textSecondary, ), ), ], @@ -649,7 +847,9 @@ class _SidebarItem extends StatelessWidget { : Center( child: IconTheme( data: IconThemeData( - color: selected ? AppColors.primary : AppColors.textSecondary, + color: selected + ? AppColors.primary + : AppColors.textSecondary, size: 20, ), child: selected ? selectedIcon : icon, @@ -740,7 +940,9 @@ class _SidebarGroupState extends State<_SidebarGroup> { child: Icon( isSelected ? widget.group.selectedIcon : widget.group.icon, size: 20, - color: isSelected ? AppColors.primary : AppColors.textSecondary, + color: isSelected + ? AppColors.primary + : AppColors.textSecondary, ), ), ), @@ -768,20 +970,24 @@ class _SidebarGroupState extends State<_SidebarGroup> { child: Row( children: [ Icon( - isSelected ? widget.group.selectedIcon : widget.group.icon, + isSelected + ? widget.group.selectedIcon + : widget.group.icon, size: 20, - color: isSelected ? AppColors.primary : AppColors.textSecondary, + color: isSelected + ? AppColors.primary + : AppColors.textSecondary, ), const SizedBox(width: 12), Expanded( - child: Text( - widget.group.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - ), + child: Text( + widget.group.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, ), + ), ), AnimatedRotation( duration: const Duration(milliseconds: 200), @@ -803,7 +1009,8 @@ class _SidebarGroupState extends State<_SidebarGroup> { // Sub-items (animated expand/collapse) AnimatedCrossFade( duration: const Duration(milliseconds: 200), - crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, + crossFadeState: + _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: Column( children: [ for (final item in widget.group.items) diff --git a/lib/core/router/router_provider.dart b/lib/core/router/router_provider.dart index 379b867..9a2b2ff 100644 --- a/lib/core/router/router_provider.dart +++ b/lib/core/router/router_provider.dart @@ -17,18 +17,19 @@ final routerProvider = Provider((ref) { return GoRouter( refreshListenable: notifier, - initialLocation: routeSignIn, + initialLocation: routeWelcome, redirect: (context, state) { final auth = ref.read(authProvider); if (auth.isLoading) return null; final loc = state.matchedLocation; - final onLoginOrRegister = loc == routeSignIn || loc == routeSignUp; + final onLoginOrRegister = + loc == routeSignIn || loc == routeSignUp || loc == routeWelcome; final onAuthPage = onLoginOrRegister || loc == routeOnboarding; if (!auth.isAuthenticated) { - return onAuthPage ? null : routeSignIn; + return onAuthPage ? null : routeWelcome; } // Authenticated but no tenant → onboarding diff --git a/lib/core/services/finance_service.dart b/lib/core/services/finance_service.dart index e376ecb..895869b 100644 --- a/lib/core/services/finance_service.dart +++ b/lib/core/services/finance_service.dart @@ -20,9 +20,9 @@ class FinanceService { if (amount <= 0) return; final existing = await _pb.collection('finance_entries').getFullList( - filter: 'job_id = "$jobId"', - batch: 200, - ); + filter: 'job_id = "$jobId"', + batch: 200, + ); await _upsertEntry( existing: existing, @@ -47,11 +47,27 @@ class FinanceService { ); } - Future markJobPaid(String jobId) async { + Future reportJobPayment(String jobId) async { final existing = await _pb.collection('finance_entries').getFullList( - filter: 'job_id = "$jobId"', - batch: 200, - ); + filter: 'job_id = "$jobId"', + batch: 200, + ); + for (final record in existing) { + await _pb.collection('finance_entries').update( + record.id, + body: { + 'status': 'reported', + 'paid_at': null, + }, + ); + } + } + + Future confirmJobPayment(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( @@ -66,9 +82,10 @@ class FinanceService { Future deletePendingEntriesForJob(String jobId) async { final existing = await _pb.collection('finance_entries').getFullList( - filter: 'job_id = "$jobId" && status = "pending"', - batch: 200, - ); + filter: + 'job_id = "$jobId" && (status = "pending" || status = "reported")', + batch: 200, + ); for (final record in existing) { await _pb.collection('finance_entries').delete(record.id); } @@ -88,8 +105,7 @@ class FinanceService { try { match = existing.firstWhere( (record) => - record.data['tenant_id'] == tenantId && - record.data['type'] == type, + record.data['tenant_id'] == tenantId && record.data['type'] == type, ); } catch (_) { match = null; diff --git a/lib/core/services/job_history_service.dart b/lib/core/services/job_history_service.dart index 196ed3a..7e820a7 100644 --- a/lib/core/services/job_history_service.dart +++ b/lib/core/services/job_history_service.dart @@ -19,6 +19,7 @@ class JobHistoryEntry { enum JobHistoryAction { accepted, + stepCompleted, handedToClinic, approved, revisionRequested, @@ -29,6 +30,7 @@ enum JobHistoryAction { extension JobHistoryActionExt on JobHistoryAction { String get value => switch (this) { JobHistoryAction.accepted => 'accepted', + JobHistoryAction.stepCompleted => 'step_completed', JobHistoryAction.handedToClinic => 'handed_to_clinic', JobHistoryAction.approved => 'approved', JobHistoryAction.revisionRequested => 'revision_requested', @@ -43,21 +45,21 @@ class JobHistoryService { PocketBase get _pb => PocketBaseClient.instance.pb; - String get _currentUserId => - (_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? ''; + String get _currentUserId => _pb.authStore.record?.id ?? ''; Future> listForJob(String jobId) async { try { final result = await _pb.collection('job_status_history').getList( - filter: 'job_id = "$jobId"', - perPage: 200, - ); + filter: 'job_id = "$jobId"', + perPage: 200, + ); return (result.items.map((r) { final j = r.toJson(); String? str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; } + return JobHistoryEntry( id: j['id'] as String, action: _parseAction(j['action_type'] as String? ?? ''), @@ -65,30 +67,38 @@ class JobHistoryService { note: str(j['note']), createdAt: DateTime.parse(j['created'] as String), ); - }).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt))); + }).toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt))); } catch (_) { return []; } } static JobHistoryAction _parseAction(String s) => switch (s) { - 'accepted' => JobHistoryAction.accepted, - 'handed_to_clinic' => JobHistoryAction.handedToClinic, - 'approved' => JobHistoryAction.approved, + 'accepted' => JobHistoryAction.accepted, + 'step_completed' => JobHistoryAction.stepCompleted, + 'handed_to_clinic' => JobHistoryAction.handedToClinic, + 'approved' => JobHistoryAction.approved, 'revision_requested' => JobHistoryAction.revisionRequested, - 'delivered' => JobHistoryAction.delivered, - _ => JobHistoryAction.cancelled, + 'delivered' => JobHistoryAction.delivered, + _ => JobHistoryAction.cancelled, }; static JobStep _parseStep(String s) => switch (s) { + 'olcu_kontrol' => JobStep.olcuKontrol, + 'dijital_tasarim' => JobStep.dijitalTasarim, + 'model_hazirlik' => JobStep.modelHazirlik, 'alt_yapi_prova' => JobStep.altYapiProva, 'ust_yapi_prova' => JobStep.ustYapiProva, - 'mum_prova' => JobStep.mumProva, - 'disler_prova' => JobStep.dislerProva, - 'dayanak_prova' => JobStep.dayanakProva, - 'kron_prova' => JobStep.kronProva, - 'cila_bitim' => JobStep.cilaBitim, - _ => JobStep.olcu, + 'mum_prova' => JobStep.mumProva, + 'disler_prova' => JobStep.dislerProva, + 'dayanak_prova' => JobStep.dayanakProva, + 'kron_prova' => JobStep.kronProva, + 'fotograf_onay' => JobStep.fotografOnay, + 'kalite_kontrol' => JobStep.kaliteKontrol, + 'teslim_oncesi_kontrol' => JobStep.teslimOncesiKontrol, + 'cila_bitim' => JobStep.cilaBitim, + _ => JobStep.olcu, }; Future append({ diff --git a/lib/core/services/realtime_service.dart b/lib/core/services/realtime_service.dart index f5c0628..bb13b4b 100644 --- a/lib/core/services/realtime_service.dart +++ b/lib/core/services/realtime_service.dart @@ -16,21 +16,33 @@ class RealtimeService { required void Function(RecordSubscriptionEvent) onEvent, }) { UnsubFn? cancel; + bool disposeRequested = false; + bool disposed = false; - _pb.collection(collection).subscribe(topic, onEvent, filter: filter).then((fn) { + _pb + .collection(collection) + .subscribe(topic, onEvent, filter: filter) + .then((fn) async { + if (disposeRequested) { + disposed = true; + await fn(); + return; + } cancel = fn; - }); + }).catchError((_) {}); return () async { + if (disposed) return; + disposeRequested = true; try { final fn = cancel; if (fn != null) { + disposed = true; await fn(); - } else { - await _pb.collection(collection).unsubscribe(topic); } } catch (_) { - await _pb.collection(collection).unsubscribe(topic); + // swallow — never globally unsubscribe the topic here, because + // other screens may still be subscribed to the same collection/topic. } }; } diff --git a/lib/features/auth/onboarding_repository.dart b/lib/features/auth/onboarding_repository.dart index a263715..57f27a7 100644 --- a/lib/features/auth/onboarding_repository.dart +++ b/lib/features/auth/onboarding_repository.dart @@ -11,12 +11,22 @@ class OnboardingRepository { Future createTenantAndJoin({ required String kind, required String companyName, + String? companyAddress, + String? city, + String? district, + double? latitude, + double? longitude, }) async { final userId = _pb.authStore.record!.id; final tenant = await _pb.collection('tenants').create(body: { 'kind': kind, 'company_name': companyName, + 'company_address': companyAddress, + 'city': city, + 'district': district, + 'latitude': latitude, + 'longitude': longitude, 'status': 'active', 'default_currency': 'TRY', }); diff --git a/lib/features/auth/onboarding_screen.dart b/lib/features/auth/onboarding_screen.dart index 0e0cecc..31cb7b1 100644 --- a/lib/features/auth/onboarding_screen.dart +++ b/lib/features/auth/onboarding_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/providers/auth_provider.dart'; +import '../../core/theme/app_theme.dart'; +import '../shared/location_picker_sheet.dart'; +import '../shared/tenant_location_data.dart'; import 'onboarding_repository.dart'; class OnboardingScreen extends ConsumerStatefulWidget { @@ -15,6 +18,7 @@ class _OnboardingScreenState extends ConsumerState final _formKey = GlobalKey(); final _nameCtrl = TextEditingController(); String _selectedKind = 'clinic'; + TenantLocationData? _location; bool _loading = false; String? _error; late AnimationController _animCtrl; @@ -53,6 +57,11 @@ class _OnboardingScreenState extends ConsumerState final result = await OnboardingRepository.instance.createTenantAndJoin( kind: _selectedKind, companyName: _nameCtrl.text.trim(), + companyAddress: _location?.address, + city: _location?.city, + district: _location?.district, + latitude: _location?.latitude, + longitude: _location?.longitude, ); if (!mounted) return; ref.read(authProvider.notifier).setActiveTenant(result.tenants.first); @@ -249,6 +258,70 @@ class _OnboardingScreenState extends ConsumerState const SizedBox(height: 24), + Text( + 'Konum', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _location?.fullLabel.isNotEmpty == true + ? _location!.fullLabel + : 'Konumu haritadan seçin. Laboratuvar aramalarında bu veri kullanılacak.', + style: TextStyle( + fontSize: 13, + color: _location == null + ? cs.onSurfaceVariant + : cs.onSurface, + ), + ), + if (_location?.hasCoordinates == true) ...[ + const SizedBox(height: 8), + Text( + '${_location!.latitude!.toStringAsFixed(6)}, ${_location!.longitude!.toStringAsFixed(6)}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () async { + final picked = + await showLocationPickerSheet( + context, + initialLocation: _location, + title: _selectedKind == 'lab' + ? 'Laboratuvar Konumu' + : 'Klinik Konumu', + ); + if (picked != null) { + setState(() => _location = picked); + } + }, + icon: const Icon(Icons.map_outlined), + label: Text(_location == null + ? 'Haritadan Seç' + : 'Konumu Güncelle'), + ), + ], + ), + ), + + const SizedBox(height: 24), + // Company name Text( 'Kurum Adı', @@ -287,8 +360,8 @@ class _OnboardingScreenState extends ConsumerState ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), - borderSide: BorderSide( - color: cs.error, width: 1.5), + borderSide: + BorderSide(color: cs.error, width: 1.5), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), @@ -340,7 +413,9 @@ class _OnboardingScreenState extends ConsumerState const SizedBox(height: 28), FilledButton( - onPressed: _loading ? null : _create, + onPressed: _loading || _location == null + ? null + : _create, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder( @@ -431,9 +506,7 @@ class _KindCard extends StatelessWidget { child: Icon( icon, size: 26, - color: selected - ? const Color(0xFF4F46E5) - : cs.onSurfaceVariant, + color: selected ? const Color(0xFF4F46E5) : cs.onSurfaceVariant, ), ), const SizedBox(height: 10), diff --git a/lib/features/auth/sign_in_screen.dart b/lib/features/auth/sign_in_screen.dart index 9954bf8..185791f 100644 --- a/lib/features/auth/sign_in_screen.dart +++ b/lib/features/auth/sign_in_screen.dart @@ -40,9 +40,7 @@ class _SignInScreenState extends ConsumerState { Future _submit() async { if (!_formKey.currentState!.validate()) return; - await ref - .read(authProvider.notifier) - .signIn( + await ref.read(authProvider.notifier).signIn( _emailCtrl.text.trim(), _passCtrl.text, rememberSession: _rememberMe, @@ -98,10 +96,13 @@ class _SignInScreenState extends ConsumerState { ), ], ), - child: - const Center(child: ToothLogo(size: 34, color: Colors.white)), + child: const Center( + child: ToothLogo(size: 34, color: Colors.white)), ), - ).animate().fadeIn(duration: 400.ms).scale(begin: const Offset(0.8, 0.8)), + ) + .animate() + .fadeIn(duration: 400.ms) + .scale(begin: const Offset(0.8, 0.8)), const SizedBox(height: 24), Center( @@ -114,7 +115,10 @@ class _SignInScreenState extends ConsumerState { letterSpacing: -0.5, ), ), - ).animate(delay: 60.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1), + ) + .animate(delay: 60.ms) + .fadeIn(duration: 400.ms) + .slideY(begin: 0.1), const SizedBox(height: 6), Center( child: Text( @@ -169,10 +173,18 @@ class _SignInScreenState extends ConsumerState { ), ), ), - const Positioned(top: -140, left: -140, child: _Ring(size: 520, opacity: 0.06)), - const Positioned(bottom: -100, right: -100, child: _Ring(size: 400, opacity: 0.05)), - const Positioned(top: 160, right: 60, child: _Ring(size: 100, opacity: 0.09)), - const Positioned(bottom: 220, left: 60, child: _Ring(size: 70, opacity: 0.07)), + const Positioned( + top: -140, + left: -140, + child: _Ring(size: 520, opacity: 0.06)), + const Positioned( + bottom: -100, + right: -100, + child: _Ring(size: 400, opacity: 0.05)), + const Positioned( + top: 160, right: 60, child: _Ring(size: 100, opacity: 0.09)), + const Positioned( + bottom: 220, left: 60, child: _Ring(size: 70, opacity: 0.07)), Padding( padding: const EdgeInsets.symmetric(horizontal: 64, vertical: 52), @@ -356,7 +368,6 @@ class _SignInScreenState extends ConsumerState { (v == null || v.trim().isEmpty) ? s.emailRequired : null, ), const SizedBox(height: 14), - _Field( controller: _passCtrl, label: s.password, @@ -377,44 +388,29 @@ class _SignInScreenState extends ConsumerState { validator: (v) => (v == null || v.isEmpty) ? s.passwordRequired : null, ), - const SizedBox(height: 12), - - InkWell( - borderRadius: BorderRadius.circular(10), - onTap: auth.isLoading + CheckboxListTile( + value: _rememberMe, + onChanged: 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, - ), - ), - ], + : (value) => setState(() => _rememberMe = value ?? true), + activeColor: const Color(0xFF0D4C85), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: Text( + s.rememberMe, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, ), ), ), - if (auth.error != null) ...[ const SizedBox(height: 14), Container( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(10), @@ -437,9 +433,7 @@ class _SignInScreenState extends ConsumerState { ), ), ], - const SizedBox(height: 24), - DecoratedBox( decoration: BoxDecoration( gradient: const LinearGradient( @@ -488,24 +482,36 @@ class _SignInScreenState extends ConsumerState { // ── Sign-up link ─────────────────────────────────────────────────────────── Widget _buildSignUpLink(BuildContext context, AppStrings s) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, + return Column( children: [ - Text( - s.noAccount, - style: - const TextStyle(color: AppColors.textSecondary, fontSize: 14), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + s.noAccount, + style: + const TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + TextButton( + onPressed: () => context.go(routeSignUp), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF0D4C85), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Text( + s.signUp, + style: + const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + ), + ), + ], ), - TextButton( - onPressed: () => context.go(routeSignUp), + TextButton.icon( + onPressed: () => context.go(routeWelcome), + icon: const Icon(Icons.workspace_premium_outlined, size: 18), + label: const Text('Paketleri İncele'), style: TextButton.styleFrom( - foregroundColor: const Color(0xFF0D4C85), - padding: const EdgeInsets.symmetric(horizontal: 8), - ), - child: Text( - s.signUp, - style: - const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + foregroundColor: AppColors.textSecondary, ), ), ], @@ -726,7 +732,8 @@ class _DashboardPreviewCard extends StatelessWidget { const SizedBox(height: 18), const Row( children: [ - _StatChip(value: '24', label: 'Aktif', color: Color(0xFF60A5FA)), + _StatChip( + value: '24', label: 'Aktif', color: Color(0xFF60A5FA)), SizedBox(width: 8), _StatChip( value: '8', label: 'Bekliyor', color: Color(0xFFFBBF24)), @@ -915,8 +922,7 @@ class _Field extends StatelessWidget { ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: - const BorderSide(color: AppColors.cancelled, width: 1.5), + borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -924,8 +930,8 @@ class _Field extends StatelessWidget { ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - labelStyle: const TextStyle( - color: AppColors.textSecondary, fontSize: 14), + labelStyle: + const TextStyle(color: AppColors.textSecondary, fontSize: 14), ), ); } diff --git a/lib/features/auth/welcome_pricing_screen.dart b/lib/features/auth/welcome_pricing_screen.dart new file mode 100644 index 0000000..63f8197 --- /dev/null +++ b/lib/features/auth/welcome_pricing_screen.dart @@ -0,0 +1,1100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/providers/auth_provider.dart'; +import '../../core/providers/locale_provider.dart'; +import '../../core/router/app_router.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/tooth_logo.dart'; +import 'auth_widgets.dart'; + +class WelcomePricingScreen extends ConsumerStatefulWidget { + const WelcomePricingScreen({super.key}); + + @override + ConsumerState createState() => + _WelcomePricingScreenState(); +} + +class _WelcomePricingScreenState extends ConsumerState { + bool _yearly = false; + + @override + Widget build(BuildContext context) { + final locale = ref.watch(localeProvider); + final copy = _WelcomeCopy.of(locale.languageCode); + final auth = ref.watch(authProvider); + final isDesktop = MediaQuery.sizeOf(context).width > 980; + + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + if (isDesktop) + Row( + children: [ + Expanded(child: _HeroPane(copy: copy, compact: false)), + Expanded(child: _ContentPane(copy: copy, yearly: _yearly)), + ], + ) + else + SafeArea( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _HeroPane(copy: copy, compact: true)), + SliverToBoxAdapter( + child: _ContentPane(copy: copy, yearly: _yearly), + ), + ], + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 12, + right: 12, + child: Wrap( + spacing: 8, + children: [ + if (auth.isAuthenticated && auth.activeTenant != null) + OutlinedButton.icon( + onPressed: () => context.go( + auth.activeTenant!.tenant.isLab + ? routeLabDashboard + : routeClinicDashboard, + ), + icon: const Icon(Icons.arrow_back_rounded, size: 18), + label: Text(copy.backToApp), + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.92), + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: AppColors.border), + ), + ), + _LanguageFab(locale: locale), + ], + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _BottomActionBar( + copy: copy, + onYearlyChanged: (value) => setState(() => _yearly = value), + yearly: _yearly, + ), + ), + ], + ), + ); + } +} + +class _HeroPane extends StatelessWidget { + const _HeroPane({required this.copy, required this.compact}); + + final _WelcomeCopy copy; + final bool compact; + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(minHeight: compact ? 360 : double.infinity), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF08111F), Color(0xFF0E325F), Color(0xFF13639C)], + ), + ), + child: Stack( + children: [ + const AnimatedAuthBg(bright: true), + Padding( + padding: EdgeInsets.fromLTRB( + compact ? 24 : 48, + compact ? 32 : 64, + compact ? 24 : 48, + compact ? 36 : 64, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: compact ? 60 : 72, + height: compact ? 60 : 72, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + ), + ), + child: Center( + child: ToothLogo( + size: compact ? 30 : 36, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'DLS', + style: TextStyle( + color: Colors.white, + fontSize: compact ? 34 : 48, + fontWeight: FontWeight.w900, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: 12), + Text( + copy.heroTitle, + style: TextStyle( + color: Colors.white, + fontSize: compact ? 28 : 42, + fontWeight: FontWeight.w800, + height: 1.08, + letterSpacing: -0.8, + ), + ), + const SizedBox(height: 14), + Text( + copy.heroSubtitle, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: compact ? 14 : 17, + height: 1.6, + ), + ), + const SizedBox(height: 28), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _HeroMetric( + label: copy.metricClinics, + value: copy.metricClinicsValue, + ), + _HeroMetric( + label: copy.metricSpeed, + value: copy.metricSpeedValue, + ), + _HeroMetric( + label: copy.metricAi, + value: copy.metricAiValue, + ), + ], + ), + const SizedBox(height: 28), + ...copy.heroBullets.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + width: 22, + height: 22, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.check_rounded, + size: 14, + color: Colors.white, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + item, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.92), + fontSize: compact ? 14 : 15, + height: 1.45, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ContentPane extends StatelessWidget { + const _ContentPane({required this.copy, required this.yearly}); + + final _WelcomeCopy copy; + final bool yearly; + + @override + Widget build(BuildContext context) { + final plans = copy.buildPlans(yearly); + + return Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.of(context).padding.bottom + 110, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + copy.packageEyebrow, + style: const TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + const SizedBox(height: 8), + Text( + copy.packageTitle, + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + letterSpacing: -0.7, + ), + ), + const SizedBox(height: 10), + Text( + copy.packageSubtitle, + style: const TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + height: 1.6, + ), + ), + const SizedBox(height: 24), + LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth > 720; + return Wrap( + spacing: 16, + runSpacing: 16, + children: plans + .map( + (plan) => SizedBox( + width: wide ? (constraints.maxWidth - 16) / 2 : null, + child: _PlanCard(plan: plan, yearly: yearly), + ), + ) + .toList(), + ); + }, + ), + const SizedBox(height: 28), + _FreeUsageCard(copy: copy), + ], + ), + ); + } +} + +class _BottomActionBar extends StatelessWidget { + const _BottomActionBar({ + required this.copy, + required this.yearly, + required this.onYearlyChanged, + }); + + final _WelcomeCopy copy; + final bool yearly; + final ValueChanged onYearlyChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + 16, + 14, + 16, + MediaQuery.paddingOf(context).bottom + 14, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.96), + border: const Border(top: BorderSide(color: AppColors.border)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, -8), + ), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + height: 48, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Expanded( + child: _BillingOptionButton( + label: copy.monthly, + selected: !yearly, + onTap: () => onYearlyChanged(false), + ), + ), + Expanded( + child: _BillingOptionButton( + label: copy.yearly, + caption: copy.yearlyDiscount, + selected: yearly, + onTap: () => onYearlyChanged(true), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => context.go(routeSignUp), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Text(copy.startNow), + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: () => context.go(routeSignIn), + style: OutlinedButton.styleFrom( + minimumSize: const Size(112, 48), + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Text(copy.signIn), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _BillingOptionButton extends StatelessWidget { + const _BillingOptionButton({ + required this.label, + required this.selected, + required this.onTap, + this.caption, + }); + + final String label; + final String? caption; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: selected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : null, + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: + selected ? AppColors.textPrimary : AppColors.textSecondary, + ), + ), + if (caption != null) + Text( + caption!, + style: const TextStyle( + fontSize: 11, + color: AppColors.success, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} + +class _PlanCard extends StatelessWidget { + const _PlanCard({required this.plan, required this.yearly}); + + final _PlanViewModel plan; + final bool yearly; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: plan.highlighted ? const Color(0xFFF8FBFF) : Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: plan.highlighted ? const Color(0xFF93C5FD) : AppColors.border, + width: plan.highlighted ? 1.4 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + plan.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + plan.subtitle, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + ], + ), + ), + if (plan.badge != null) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + plan.badge!, + style: const TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 18), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: plan.price, + style: const TextStyle( + fontSize: 34, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + letterSpacing: -1, + ), + ), + TextSpan( + text: plan.period, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + yearly ? plan.yearlyNote : plan.monthlyNote, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 18), + _FeatureRow( + icon: Icons.auto_awesome_rounded, + text: plan.aiCredits, + emphasized: true, + ), + const SizedBox(height: 8), + ...plan.features.map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FeatureRow( + icon: Icons.check_circle_outline_rounded, + text: feature, + ), + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => context.go(routeSignUp), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(46), + foregroundColor: plan.highlighted + ? AppColors.primary + : AppColors.textPrimary, + side: BorderSide( + color: + plan.highlighted ? AppColors.primary : AppColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Text(plan.cta), + ), + ), + ], + ), + ); + } +} + +class _FeatureRow extends StatelessWidget { + const _FeatureRow({ + required this.icon, + required this.text, + this.emphasized = false, + }); + + final IconData icon; + final String text; + final bool emphasized; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 18, + color: emphasized ? const Color(0xFF7C3AED) : AppColors.success, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13, + fontWeight: emphasized ? FontWeight.w700 : FontWeight.w500, + color: + emphasized ? AppColors.textPrimary : AppColors.textSecondary, + height: 1.45, + ), + ), + ), + ], + ); + } +} + +class _FreeUsageCard extends StatelessWidget { + const _FreeUsageCard({required this.copy}); + + final _WelcomeCopy copy; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + copy.freeUsageTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + copy.freeUsageSubtitle, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.55, + ), + ), + const SizedBox(height: 16), + ...copy.freeUsageBullets.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _FeatureRow( + icon: Icons.lightbulb_outline_rounded, + text: item, + ), + ), + ), + ], + ), + ); + } +} + +class _HeroMetric extends StatelessWidget { + const _HeroMetric({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _LanguageFab extends ConsumerWidget { + const _LanguageFab({required this.locale}); + + final Locale locale; + + @override + Widget build(BuildContext context, WidgetRef ref) { + const options = [ + ('tr', 'TR'), + ('en', 'EN'), + ('de', 'DE'), + ('ru', 'RU'), + ('ar', 'AR'), + ]; + + return PopupMenuButton( + tooltip: 'Language', + onSelected: (value) => + ref.read(localeProvider.notifier).setLocale(Locale(value)), + itemBuilder: (_) => options + .map( + (option) => PopupMenuItem( + value: option.$1, + child: Row( + children: [ + Expanded(child: Text(option.$2)), + if (locale.languageCode == option.$1) + const Icon(Icons.check_rounded, size: 16), + ], + ), + ), + ) + .toList(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.language_rounded, size: 18), + const SizedBox(width: 8), + Text( + locale.languageCode.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +class _PlanViewModel { + const _PlanViewModel({ + required this.name, + required this.subtitle, + required this.price, + required this.period, + required this.monthlyNote, + required this.yearlyNote, + required this.aiCredits, + required this.features, + required this.cta, + this.badge, + this.highlighted = false, + }); + + final String name; + final String subtitle; + final String price; + final String period; + final String monthlyNote; + final String yearlyNote; + final String aiCredits; + final List features; + final String cta; + final String? badge; + final bool highlighted; +} + +class _WelcomeCopy { + const _WelcomeCopy({ + required this.heroTitle, + required this.heroSubtitle, + required this.heroBullets, + required this.metricClinics, + required this.metricClinicsValue, + required this.metricSpeed, + required this.metricSpeedValue, + required this.metricAi, + required this.metricAiValue, + required this.packageEyebrow, + required this.packageTitle, + required this.packageSubtitle, + required this.monthly, + required this.yearly, + required this.yearlyDiscount, + required this.startNow, + required this.signIn, + required this.backToApp, + required this.freeUsageTitle, + required this.freeUsageSubtitle, + required this.freeUsageBullets, + required this.planFree, + required this.planFreeSub, + required this.planStarter, + required this.planStarterSub, + required this.planPro, + required this.planProSub, + required this.planEnterprise, + required this.planEnterpriseSub, + required this.freeCta, + required this.paidCta, + required this.enterpriseCta, + required this.recommendedBadge, + required this.enterpriseBadge, + required this.perMonth, + required this.perYear, + required this.customPrice, + required this.noCardTrial, + required this.annualPrepay, + required this.customContact, + }); + + final String heroTitle; + final String heroSubtitle; + final List heroBullets; + final String metricClinics; + final String metricClinicsValue; + final String metricSpeed; + final String metricSpeedValue; + final String metricAi; + final String metricAiValue; + final String packageEyebrow; + final String packageTitle; + final String packageSubtitle; + final String monthly; + final String yearly; + final String yearlyDiscount; + final String startNow; + final String signIn; + final String backToApp; + final String freeUsageTitle; + final String freeUsageSubtitle; + final List freeUsageBullets; + final String planFree; + final String planFreeSub; + final String planStarter; + final String planStarterSub; + final String planPro; + final String planProSub; + final String planEnterprise; + final String planEnterpriseSub; + final String freeCta; + final String paidCta; + final String enterpriseCta; + final String recommendedBadge; + final String enterpriseBadge; + final String perMonth; + final String perYear; + final String customPrice; + final String noCardTrial; + final String annualPrepay; + final String customContact; + + static _WelcomeCopy of(String code) { + switch (code) { + case 'tr': + return const _WelcomeCopy( + heroTitle: 'Klinik ve laboratuvar operasyonunu tek akışta yönetin.', + heroSubtitle: + 'İş takibi, finans, bağlantılar ve AI destekli operasyon yardımı tek uygulamada birleşir.', + heroBullets: [ + 'Klinik ve laboratuvar arasında iş akışı ve teslim süreçleri aynı dilde ilerler.', + 'Hasta, ürün, fiyat ve onay süreçleri gerçek saha kullanımına göre ölçeklenir.', + 'AI özellikleri kredi bazlı ilerleyecek şekilde ürünleştirilmeye hazırdır.', + ], + metricClinics: 'Kurulum', + metricClinicsValue: 'Aynı gün', + metricSpeed: 'Odak', + metricSpeedValue: 'Operasyon + finans', + metricAi: 'AI yaklaşımı', + metricAiValue: 'Kredi bazlı', + packageEyebrow: 'Paketler', + packageTitle: 'Trial ve paket yapısını şimdiden net gösterelim.', + packageSubtitle: + 'Bu ekran şimdilik tanıtım ve yönlendirme amaçlıdır. Paketler aylık ve yıllık olarak ayrıldı; AI kredi mantığı da görünür halde.', + monthly: 'Aylık', + yearly: 'Yıllık', + yearlyDiscount: '%20 avantaj', + startNow: 'Hemen Başla', + signIn: 'Giriş Yap', + backToApp: 'Uygulamaya dön', + freeUsageTitle: 'Ücretsiz kullanım için önerilen model', + freeUsageSubtitle: + 'Monetization başlamadan önce ücretsiz katmanı ürün denemesi için güçlü ama kontrollü tutmak en sağlıklı yapı olur.', + freeUsageBullets: [ + 'Süresiz ücretsiz plan: 1 tenant, sınırlı ekip üyesi ve aylık düşük AI kredi.', + 'Kart istemeyen 14 günlük Pro deneme: kullanıcı ilk değeri hızlı görür, bariyer düşer.', + 'Referans veya ilk aktivasyon sonrası bonus kredi: AI özelliğini tattırır.', + ], + planFree: 'Free', + planFreeSub: 'Temel operasyonu görmek isteyenler için', + planStarter: 'Starter', + planStarterSub: 'Yeni başlayan klinik ve laboratuvarlar için', + planPro: 'Pro', + planProSub: 'Düzenli iş trafiği ve ekip yönetimi için', + planEnterprise: 'Enterprise', + planEnterpriseSub: 'Çoklu şube, operasyon ve özel süreçler için', + freeCta: 'Ücretsiz başla', + paidCta: 'Denemeyi başlat', + enterpriseCta: 'Görüşme planla', + recommendedBadge: 'Önerilen', + enterpriseBadge: 'Kurumsal', + perMonth: '/ ay', + perYear: '/ yıl', + customPrice: 'Özel', + noCardTrial: 'Kart gerektirmeyen başlangıç', + annualPrepay: 'Yıllık peşin ödeme ile daha avantajlı', + customContact: 'Özel AI kredi ve tenant yapısı planlanır', + ); + default: + return const _WelcomeCopy( + heroTitle: 'Run clinic and lab operations in one shared workflow.', + heroSubtitle: + 'Jobs, finance, connections, and AI-assisted operations live in the same product surface.', + heroBullets: [ + 'Clinics and labs follow the same production language and handoff flow.', + 'Patients, products, pricing, and approvals scale with real field usage.', + 'AI features are ready to evolve into a credit-based monetization layer.', + ], + metricClinics: 'Setup', + metricClinicsValue: 'Same day', + metricSpeed: 'Focus', + metricSpeedValue: 'Ops + finance', + metricAi: 'AI model', + metricAiValue: 'Credit-based', + packageEyebrow: 'Plans', + packageTitle: + 'Make trial and package structure visible from day one.', + packageSubtitle: + 'This screen is intentionally promotional for now. Plans are split monthly and yearly, and AI credits are visible for future packaging.', + monthly: 'Monthly', + yearly: 'Yearly', + yearlyDiscount: 'Save 20%', + startNow: 'Get Started', + signIn: 'Sign In', + backToApp: 'Back to app', + freeUsageTitle: 'Recommended free usage policy', + freeUsageSubtitle: + 'Before full monetization, a controlled free tier is the healthiest way to drive adoption without hurting conversion.', + freeUsageBullets: [ + 'Free forever tier with 1 tenant, limited team seats, and low monthly AI credits.', + '14-day Pro trial without requiring a card to reduce onboarding friction.', + 'Bonus credits after referral or first activation to showcase AI value.', + ], + planFree: 'Free', + planFreeSub: 'For teams exploring the product', + planStarter: 'Starter', + planStarterSub: 'For early-stage clinics and labs', + planPro: 'Pro', + planProSub: 'For active teams with recurring job volume', + planEnterprise: 'Enterprise', + planEnterpriseSub: 'For multi-branch and custom operations', + freeCta: 'Start free', + paidCta: 'Start trial', + enterpriseCta: 'Book a demo', + recommendedBadge: 'Recommended', + enterpriseBadge: 'Enterprise', + perMonth: '/ mo', + perYear: '/ yr', + customPrice: 'Custom', + noCardTrial: 'No-card onboarding', + annualPrepay: 'Lower effective cost with annual billing', + customContact: 'Custom AI credits and tenant structure', + ); + } + } + + List<_PlanViewModel> buildPlans(bool yearly) => [ + _PlanViewModel( + name: planFree, + subtitle: planFreeSub, + price: yearly ? '0₺' : '0₺', + period: yearly ? perYear : perMonth, + monthlyNote: noCardTrial, + yearlyNote: noCardTrial, + aiCredits: yearly ? '300 AI kredi / yıl' : '25 AI kredi / ay', + features: const [ + '1 tenant / temel ekip kullanımı', + 'İş ve hasta akışını deneme', + 'Sınırlı rapor ve temel finans görünümü', + ], + cta: freeCta, + ), + _PlanViewModel( + name: planStarter, + subtitle: planStarterSub, + price: yearly ? '7.680₺' : '800₺', + period: yearly ? perYear : perMonth, + monthlyNote: noCardTrial, + yearlyNote: annualPrepay, + aiCredits: yearly ? '3.600 AI kredi / yıl' : '300 AI kredi / ay', + features: const [ + 'Çoklu kullanıcı ve temel tenant yönetimi', + 'İş akışı, ürün ve bağlantı yönetimi', + 'Temel AI yardımcı deneyimi', + ], + cta: paidCta, + ), + _PlanViewModel( + name: planPro, + subtitle: planProSub, + price: yearly ? '17.280₺' : '1.800₺', + period: yearly ? perYear : perMonth, + monthlyNote: noCardTrial, + yearlyNote: annualPrepay, + aiCredits: yearly ? '14.400 AI kredi / yıl' : '1.200 AI kredi / ay', + features: const [ + 'Gelişmiş finans ve fiyatlandırma görünürlüğü', + 'Daha yüksek ekip ve tenant esnekliği', + 'Öncelikli AI kullanım ve operasyon desteği', + ], + cta: paidCta, + badge: recommendedBadge, + highlighted: true, + ), + _PlanViewModel( + name: planEnterprise, + subtitle: planEnterpriseSub, + price: customPrice, + period: '', + monthlyNote: customContact, + yearlyNote: customContact, + aiCredits: 'Özel AI kredi havuzu ve kurallar', + features: const [ + 'Super admin, çoklu tenant ve özel onboarding', + 'Saha sürecine göre özelleşen workflow yapısı', + 'Kurumsal SLA, entegrasyon ve destek', + ], + cta: enterpriseCta, + badge: enterpriseBadge, + ), + ]; +} diff --git a/lib/features/clinic/connections/clinic_connections_repository.dart b/lib/features/clinic/connections/clinic_connections_repository.dart index 3dbd7e4..f390043 100644 --- a/lib/features/clinic/connections/clinic_connections_repository.dart +++ b/lib/features/clinic/connections/clinic_connections_repository.dart @@ -1,6 +1,7 @@ import 'package:pocketbase/pocketbase.dart'; import '../../../core/api/pocketbase_client.dart'; import '../../../models/connection.dart'; +import '../../../models/tenant.dart'; class ClinicConnectionsRepository { ClinicConnectionsRepository._(); @@ -10,10 +11,10 @@ class ClinicConnectionsRepository { Future> listConnections(String clinicTenantId) async { final result = await _pb.collection('connections').getList( - filter: 'clinic_tenant_id = "$clinicTenantId"', - expand: 'lab_tenant_id,clinic_tenant_id', - perPage: 100, - ); + filter: 'clinic_tenant_id = "$clinicTenantId"', + expand: 'lab_tenant_id,clinic_tenant_id', + perPage: 100, + ); return (result.items.map((r) => Connection.fromJson(r.toJson())).toList() ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); } @@ -30,11 +31,26 @@ class ClinicConnectionsRepository { return Connection.fromJson(record.toJson()); } - Future>> searchLabs(String query) async { + Future> searchLabs({ + String query = '', + String? city, + }) async { + final normalizedQuery = query.trim().replaceAll('"', '\\"'); + final normalizedCity = (city ?? '').trim().replaceAll('"', '\\"'); + + final filterParts = ['kind = "lab"']; + if (normalizedQuery.isNotEmpty) { + filterParts.add( + '(company_name ~ "$normalizedQuery" || city ~ "$normalizedQuery" || district ~ "$normalizedQuery")', + ); + } else if (normalizedCity.isNotEmpty) { + filterParts.add('city = "$normalizedCity"'); + } + final result = await _pb.collection('tenants').getList( - filter: 'kind = "lab" && company_name ~ "$query"', - perPage: 20, - ); - return result.items.map((r) => r.toJson()).toList(); + filter: filterParts.join(' && '), + perPage: 100, + ); + return result.items.map((r) => Tenant.fromJson(r.toJson())).toList(); } } diff --git a/lib/features/clinic/connections/clinic_connections_screen.dart b/lib/features/clinic/connections/clinic_connections_screen.dart index f89acfa..e5321c8 100644 --- a/lib/features/clinic/connections/clinic_connections_screen.dart +++ b/lib/features/clinic/connections/clinic_connections_screen.dart @@ -1,9 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart' as ll; +import 'package:maplibre_gl/maplibre_gl.dart'; +import '../../../core/location/location_access_service.dart'; +import '../../../core/maps/open_free_map.dart'; import '../../../core/providers/auth_provider.dart'; import '../../../core/theme/app_theme.dart'; import '../../../models/connection.dart'; +import '../../../models/tenant.dart'; import 'clinic_connections_repository.dart'; class ClinicConnectionsScreen extends ConsumerStatefulWidget { @@ -27,19 +34,19 @@ class _ClinicConnectionsScreenState void _load() { final tenantId = ref.read(authProvider).activeTenant!.tenant.id; setState(() { - _future = ClinicConnectionsRepository.instance - .listConnections(tenantId); + _future = ClinicConnectionsRepository.instance.listConnections(tenantId); }); } void _showSearchDialog() { + final clinicTenant = ref.read(authProvider).activeTenant!.tenant; showDialog( context: context, builder: (ctx) => _LabSearchDialog( + clinicTenant: clinicTenant, onRequested: (labId, labName) async { Navigator.of(ctx).pop(); - final tenantId = - ref.read(authProvider).activeTenant!.tenant.id; + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; try { await ClinicConnectionsRepository.instance.requestConnection( clinicTenantId: tenantId, @@ -49,8 +56,8 @@ class _ClinicConnectionsScreenState if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - '$labName\'a bağlantı talebi gönderildi.')), + content: Text('$labName\'a bağlantı talebi gönderildi.'), + ), ); } } catch (e) { @@ -86,7 +93,8 @@ class _ClinicConnectionsScreenState builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center( - child: CircularProgressIndicator(color: AppColors.accent)); + child: CircularProgressIndicator(color: AppColors.accent), + ); } if (snap.hasError) { return Center( @@ -97,15 +105,20 @@ class _ClinicConnectionsScreenState width: 64, height: 64, decoration: BoxDecoration( - color: AppColors.cancelledBg, - borderRadius: BorderRadius.circular(16)), - child: const Icon(Icons.wifi_off_rounded, - color: AppColors.cancelled, size: 30), + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.wifi_off_rounded, + color: AppColors.cancelled, + size: 30, + ), ), const SizedBox(height: 16), - Text('Hata: ${snap.error}', - style: - const TextStyle(color: AppColors.textSecondary)), + Text( + 'Hata: ${snap.error}', + style: const TextStyle(color: AppColors.textSecondary), + ), const SizedBox(height: 12), FilledButton.icon( onPressed: _load, @@ -126,18 +139,23 @@ class _ClinicConnectionsScreenState width: 72, height: 72, decoration: BoxDecoration( - color: AppColors.inProgressBg, - borderRadius: BorderRadius.circular(20)), - child: const Icon(Icons.link_off, - color: AppColors.inProgress, size: 32), + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.link_off, + color: AppColors.inProgress, + size: 32, + ), ), const SizedBox(height: 16), const Text( 'Henüz bağlantı yok', style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), const SizedBox(height: 8), FilledButton.icon( @@ -161,25 +179,31 @@ class _ClinicConnectionsScreenState child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2)) - ]), + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( - color: statusBg, - borderRadius: BorderRadius.circular(12)), - child: Icon(Icons.science_outlined, - color: statusColor, size: 22), + color: statusBg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.science_outlined, + color: statusColor, + size: 22, + ), ), const SizedBox(width: 12), Expanded( @@ -189,17 +213,19 @@ class _ClinicConnectionsScreenState Text( conn.labName ?? 'Laboratuvar', style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), if (conn.dateCreated != null) ...[ const SizedBox(height: 2), Text( _formatDate(conn.dateCreated!), style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary), + fontSize: 12, + color: AppColors.textSecondary, + ), ), ], ], @@ -259,6 +285,7 @@ class _ClinicConnectionsScreenState class _StatusChip extends StatelessWidget { const _StatusChip({required this.status}); + final ConnectionStatus status; @override @@ -306,7 +333,12 @@ class _StatusChip extends StatelessWidget { } class _LabSearchDialog extends StatefulWidget { - const _LabSearchDialog({required this.onRequested}); + const _LabSearchDialog({ + required this.clinicTenant, + required this.onRequested, + }); + + final Tenant clinicTenant; final void Function(String labId, String labName) onRequested; @override @@ -314,128 +346,687 @@ class _LabSearchDialog extends StatefulWidget { } class _LabSearchDialogState extends State<_LabSearchDialog> { + static const _fallbackCenter = LatLng(41.0082, 28.9784); + static const _defaultZoom = 10.5; + + final _distance = const ll.Distance(); final _searchController = TextEditingController(); - List> _results = []; + Timer? _searchDebounce; + List<_LabSearchItem> _results = []; bool _isLoading = false; bool _searched = false; + String? _error; + String? _selectedLabId; + LatLng? _devicePoint; + bool _resolvingDeviceLocation = false; + MapLibreMapController? _mapController; + bool _styleReady = false; + + LatLng get _clinicPoint => LatLng( + widget.clinicTenant.latitude ?? _fallbackCenter.latitude, + widget.clinicTenant.longitude ?? _fallbackCenter.longitude, + ); + + bool get _hasClinicLocation => widget.clinicTenant.hasLocation; + LatLng? get _searchAnchorPoint => + _devicePoint ?? (_hasClinicLocation ? _clinicPoint : null); + + _LabSearchItem? get _selectedLab { + for (final item in _results) { + if (item.tenant.id == _selectedLabId) return item; + } + return _results.isNotEmpty ? _results.first : null; + } + + List<_LabSearchItem> get _mappedLabs => + _results.where((item) => item.tenant.hasLocation).toList(); + + List<_LabSearchItem> get _legacyLabs => + _results.where((item) => !item.tenant.hasLocation).toList(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _search()); + } @override void dispose() { + _searchDebounce?.cancel(); _searchController.dispose(); super.dispose(); } Future _search() async { final query = _searchController.text.trim(); - if (query.isEmpty) return; setState(() { _isLoading = true; _searched = true; + _error = null; }); + try { - final results = - await ClinicConnectionsRepository.instance.searchLabs(query); + final results = await ClinicConnectionsRepository.instance.searchLabs( + query: query, + city: widget.clinicTenant.city, + ); + final mapped = results + .map( + (lab) => _LabSearchItem( + tenant: lab, + distanceKm: _distanceFor(lab), + ), + ) + .toList() + ..sort((a, b) { + final aDistance = a.distanceKm ?? double.infinity; + final bDistance = b.distanceKm ?? double.infinity; + final compareDistance = aDistance.compareTo(bDistance); + if (compareDistance != 0) return compareDistance; + return a.tenant.companyName.compareTo(b.tenant.companyName); + }); + setState(() { - _results = results; + _results = mapped; + _selectedLabId = mapped.isNotEmpty ? mapped.first.tenant.id : null; _isLoading = false; }); + await _refreshMarkers(); + await _moveMapToResults(); } catch (e) { - setState(() => _isLoading = false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Hata: $e')), - ); - } + setState(() { + _isLoading = false; + _error = e.toString(); + }); } } + double? _distanceFor(Tenant lab) { + final anchor = _searchAnchorPoint; + if (anchor == null || !lab.hasLocation) return null; + final meters = _distance( + ll.LatLng(anchor.latitude, anchor.longitude), + ll.LatLng(lab.latitude!, lab.longitude!), + ); + return meters / 1000; + } + + Future _moveMapToResults() async { + final controller = _mapController; + if (controller == null) return; + + final selected = _selectedLab; + if (selected?.tenant.hasLocation == true) { + await controller.animateCamera( + CameraUpdate.newLatLngZoom( + LatLng(selected!.tenant.latitude!, selected.tenant.longitude!), + 12.8, + ), + ); + return; + } + + final fallbackPoint = _searchAnchorPoint ?? _clinicPoint; + await controller.animateCamera( + CameraUpdate.newLatLngZoom(fallbackPoint, _defaultZoom), + ); + } + + Future _refreshMarkers() async { + final controller = _mapController; + if (controller == null || !_styleReady) return; + + await controller.clearCircles(); + + final circles = [ + if (_hasClinicLocation) + CircleOptions( + geometry: _clinicPoint, + circleRadius: 7, + circleColor: '#111827', + circleStrokeWidth: 2, + circleStrokeColor: '#FFFFFF', + ), + ..._mappedLabs.map( + (item) => CircleOptions( + geometry: LatLng( + item.tenant.latitude!, + item.tenant.longitude!, + ), + circleRadius: item.tenant.id == _selectedLabId ? 8 : 6, + circleColor: item.tenant.id == _selectedLabId ? '#4F46E5' : '#0F766E', + circleStrokeWidth: 2, + circleStrokeColor: '#FFFFFF', + ), + ), + ]; + + if (circles.isNotEmpty) { + await controller.addCircles(circles); + } + } + + void _queueSearch(String _) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 350), _search); + } + + Future _useDeviceLocationForSearch() async { + setState(() { + _resolvingDeviceLocation = true; + _error = null; + }); + try { + final position = await LocationAccessService.getCurrentPosition(); + _devicePoint = LatLng(position.latitude, position.longitude); + await _search(); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _resolvingDeviceLocation = false); + } + } + + void _selectLab(_LabSearchItem item) { + setState(() => _selectedLabId = item.tenant.id); + unawaited(_refreshMarkers()); + unawaited(_moveMapToResults()); + } + @override Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Laboratuvar Bul'), - content: SizedBox( - width: double.maxFinite, + final selectedLab = _selectedLab; + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + backgroundColor: Colors.transparent, + child: Container( + height: MediaQuery.sizeOf(context).height * 0.88, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(24), + ), child: Column( - mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - hintText: 'Lab adı ile arayın...', - contentPadding: EdgeInsets.symmetric( - horizontal: 12, vertical: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Row( + children: [ + const Expanded( + child: Text( + 'Laboratuvar Bul', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), - onSubmitted: (_) => _search(), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Lab adı, şehir veya ilçe ile arayın...', + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _isLoading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accent, + ), + ), + ) + : (_searchController.text.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + _queueSearch(''); + setState(() {}); + }, + icon: const Icon(Icons.close_rounded), + ) + : null), + ), + onChanged: (value) { + setState(() {}); + _queueSearch(value); + }, + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + _searchAnchorPoint != null + ? Icons.near_me_rounded + : Icons.info_outline_rounded, + size: 16, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _devicePoint != null + ? 'Sonuçlar aktif konumunuza göre yakın olandan sıralanır.' + : (_hasClinicLocation + ? 'Sonuçlar kliniğin konumuna göre yakın olandan sıralanır.' + : 'Aktif konumunuzu kullanarak yakın arama yapabilir veya isimle arayabilirsiniz.'), + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + OutlinedButton.icon( + onPressed: _resolvingDeviceLocation + ? null + : _useDeviceLocationForSearch, + icon: _resolvingDeviceLocation + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location_rounded, size: 18), + label: Text( + _devicePoint == null + ? 'Aktif Konumumla Ara' + : 'Aktif Konumu Yenile', + ), + ), + if (_devicePoint != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () { + setState(() => _devicePoint = null); + unawaited(_search()); + }, + child: const Text('Klinik Konumuna Dön'), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(height: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + children: [ + MapLibreMap( + styleString: OpenFreeMap.libertyStyle, + initialCameraPosition: CameraPosition( + target: _clinicPoint, + zoom: _defaultZoom, + ), + onMapCreated: (controller) { + _mapController = controller; + }, + onStyleLoadedCallback: () async { + _styleReady = true; + await _refreshMarkers(); + }, + compassEnabled: false, + tiltGesturesEnabled: false, + rotateGesturesEnabled: false, + myLocationEnabled: false, + myLocationTrackingMode: MyLocationTrackingMode.none, + ), + if (selectedLab != null) + Positioned( + left: 12, + right: 12, + bottom: 12, + child: _SelectedLabCard( + item: selectedLab, + onRequest: () => widget.onRequested( + selectedLab.tenant.id, + selectedLab.tenant.companyName, + ), + ), + ), + ], ), ), - const SizedBox(width: 8), - FilledButton( - onPressed: _search, - child: const Text('Ara'), - ), - ], - ), - const SizedBox(height: 12), - if (_isLoading) - const Padding( - padding: EdgeInsets.all(16), - child: CircularProgressIndicator(color: AppColors.accent), - ) - else if (_searched && _results.isEmpty) - const Padding( - padding: EdgeInsets.all(16), - child: Text('Sonuç bulunamadı', - style: TextStyle(color: AppColors.textSecondary)), - ) - else if (_results.isNotEmpty) - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240), - child: ListView.builder( - shrinkWrap: true, - itemCount: _results.length, - itemBuilder: (context, index) { - final lab = _results[index]; - final name = - lab['company_name'] as String? ?? 'Lab'; - return ListTile( - dense: true, - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.inProgressBg, - borderRadius: BorderRadius.circular(8)), - child: const Icon(Icons.science_outlined, - color: AppColors.inProgress, size: 18), - ), - title: Text(name, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppColors.textPrimary)), - subtitle: lab['member_number'] != null - ? Text('No: ${lab['member_number']}', - style: const TextStyle( - color: AppColors.textSecondary)) - : null, - onTap: () => - widget.onRequested(lab['id'] as String, name), - ); - }, - ), ), + ), + const SizedBox(height: 14), + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), + child: _buildResultsList(), + ), + ), ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('İptal'), + ); + } + + Widget _buildResultsList() { + if (_error != null) { + return Center( + child: Text( + 'Hata: $_error', + style: const TextStyle(color: AppColors.cancelled), + textAlign: TextAlign.center, + ), + ); + } + + if (_isLoading && !_searched) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent), + ); + } + + if (_searched && _results.isEmpty) { + return const Center( + child: Text( + 'Bu kriterlerde laboratuvar bulunamadı.', + style: TextStyle(color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + ); + } + + final children = [ + if (_mappedLabs.isNotEmpty) ...[ + const _ResultsSectionHeader( + title: 'Haritadaki Laboratuvarlar', + subtitle: 'Konumu tanımlı işletmeler', + ), + const SizedBox(height: 8), + for (final item in _mappedLabs) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _LabResultTile( + item: item, + isSelected: item.tenant.id == _selectedLabId, + badgeText: item.distanceKm != null + ? '${item.distanceKm!.toStringAsFixed(1)} km' + : 'Haritada', + onTap: () => _selectLab(item), + onRequest: () => widget.onRequested( + item.tenant.id, + item.tenant.companyName, + ), + ), + ), + ], + if (_legacyLabs.isNotEmpty) ...[ + if (_mappedLabs.isNotEmpty) const SizedBox(height: 8), + const _ResultsSectionHeader( + title: 'İsimle Bulunan İşletmeler', + subtitle: 'Eski kayıtlar için konum zorunlu değil', + ), + const SizedBox(height: 8), + for (final item in _legacyLabs) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _LabResultTile( + item: item, + isSelected: item.tenant.id == _selectedLabId, + badgeText: 'Konum bekleniyor', + onTap: () => _selectLab(item), + onRequest: () => widget.onRequested( + item.tenant.id, + item.tenant.companyName, + ), + ), + ), + ], + ]; + + return ListView(children: children); + } +} + +class _LabSearchItem { + const _LabSearchItem({ + required this.tenant, + required this.distanceKm, + }); + + final Tenant tenant; + final double? distanceKm; +} + +class _SelectedLabCard extends StatelessWidget { + const _SelectedLabCard({ + required this.item, + required this.onRequest, + }); + + final _LabSearchItem item; + final VoidCallback onRequest; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.science_outlined, + color: AppColors.accent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.tenant.companyName, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + item.tenant.locationLabel.isNotEmpty + ? item.tenant.locationLabel + : 'Adres bilgisi henüz girilmemiş', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: onRequest, + child: const Text('Bağlan'), + ), + ], + ), + ); + } +} + +class _ResultsSectionHeader extends StatelessWidget { + const _ResultsSectionHeader({ + required this.title, + required this.subtitle, + }); + + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), ), ], ); } } + +class _LabResultTile extends StatelessWidget { + const _LabResultTile({ + required this.item, + required this.isSelected, + required this.badgeText, + required this.onTap, + required this.onRequest, + }); + + final _LabSearchItem item; + final bool isSelected; + final String badgeText; + final VoidCallback onTap; + final VoidCallback onRequest; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected ? AppColors.inProgressBg : AppColors.background, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.accent : AppColors.border, + ), + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + item.tenant.hasLocation + ? Icons.location_searching_rounded + : Icons.manage_search_rounded, + color: AppColors.accent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.tenant.companyName, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (item.tenant.locationLabel.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.tenant.locationLabel, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + if (item.tenant.memberNumber.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Üye No: ${item.tenant.memberNumber}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + badgeText, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: onRequest, + child: const Text('Bağlan'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/clinic/dashboard/clinic_dashboard_screen.dart b/lib/features/clinic/dashboard/clinic_dashboard_screen.dart index 0562ab8..d08562e 100644 --- a/lib/features/clinic/dashboard/clinic_dashboard_screen.dart +++ b/lib/features/clinic/dashboard/clinic_dashboard_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; @@ -9,6 +10,7 @@ import '../../../core/router/app_router.dart'; import '../../../core/services/realtime_service.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/tooth_logo.dart'; +import '../../shared/location_completion_banner.dart'; import '../../../models/job.dart'; import '../jobs/clinic_jobs_repository.dart'; import '../patients/clinic_patients_repository.dart'; @@ -23,28 +25,48 @@ class ClinicDashboardScreen extends ConsumerStatefulWidget { class _ClinicDashboardScreenState extends ConsumerState { late Future<_DashboardData> _future; - late UnsubFn _unsub; + UnsubFn? _unsub; final Map _actingJobs = {}; + Timer? _reloadDebounce; + String? _subscribedTenantId; @override void initState() { super.initState(); _load(); - final tenantId = ref.read(authProvider).activeTenant!.tenant.id; - _unsub = RealtimeService.instance.watch( - 'jobs', - filter: "clinic_tenant_id='$tenantId'", - onEvent: (_) { if (mounted) _load(); }, - ); + _ensureRealtimeSubscription(); } @override void dispose() { - _unsub(); + _reloadDebounce?.cancel(); + _unsub?.call(); super.dispose(); } + void _ensureRealtimeSubscription() { + final tenantId = ref.read(authProvider).activeTenant?.tenant.id; + if (tenantId == null || tenantId == _subscribedTenantId) return; + _unsub?.call(); + _subscribedTenantId = tenantId; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: "clinic_tenant_id='$tenantId'", + onEvent: (_) { + _scheduleReload(); + }, + ); + } + + void _scheduleReload() { + _reloadDebounce?.cancel(); + _reloadDebounce = Timer(const Duration(milliseconds: 250), () { + if (mounted) _load(); + }); + } + void _load() { + _ensureRealtimeSubscription(); final tenantId = ref.read(authProvider).activeTenant!.tenant.id; setState(() { _future = _loadAll(tenantId); @@ -58,7 +80,9 @@ class _ClinicDashboardScreenState extends ConsumerState { title: Text(job.patientCode), content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal')), FilledButton( style: FilledButton.styleFrom(backgroundColor: AppColors.success), onPressed: () => Navigator.pop(ctx, true), @@ -73,7 +97,10 @@ class _ClinicDashboardScreenState extends ConsumerState { await ClinicJobsRepository.instance.approveAtClinic(job.id, job); _load(); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); + } } finally { if (mounted) setState(() => _actingJobs.remove(job.id)); } @@ -84,9 +111,12 @@ class _ClinicDashboardScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: Text(job.patientCode), - content: Text('${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'), + content: Text( + '${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal')), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Teslim Aldım'), @@ -100,7 +130,10 @@ class _ClinicDashboardScreenState extends ConsumerState { await ClinicJobsRepository.instance.markDelivered(job.id, job); _load(); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); + } } finally { if (mounted) setState(() => _actingJobs.remove(job.id)); } @@ -112,18 +145,25 @@ class _ClinicDashboardScreenState extends ConsumerState { final lastMonthStart = DateTime(now.year, now.month - 1, 1); final results = await Future.wait([ - ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['pending'], limit: 200), - ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['in_progress'], limit: 200), - ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['sent'], limit: 200), + ClinicJobsRepository.instance + .listOutbound(tenantId, statuses: ['pending'], limit: 200), + ClinicJobsRepository.instance + .listOutbound(tenantId, statuses: ['in_progress'], limit: 200), + ClinicJobsRepository.instance + .listOutbound(tenantId, statuses: ['sent'], limit: 200), ClinicJobsRepository.instance.listOutbound(tenantId, limit: 5), ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200), ]); - final thisMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart); - final lastMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart); + final thisMonth = await ClinicJobsRepository.instance + .countDelivered(tenantId, from: thisMonthStart); + final lastMonth = await ClinicJobsRepository.instance + .countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart); final inProgressJobs = results[1] as List; final sentJobs = results[2] as List; - final provaAtClinic = inProgressJobs.where((j) => j.location == JobLocation.atClinic).toList(); + final provaAtClinic = inProgressJobs + .where((j) => j.location == JobLocation.atClinic) + .toList(); final actionJobs = [...provaAtClinic, ...sentJobs]; return _DashboardData( @@ -140,8 +180,10 @@ class _ClinicDashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final companyName = - ref.watch(authProvider).activeTenant?.tenant.companyName ?? ''; + _ensureRealtimeSubscription(); + final activeTenant = ref.watch(authProvider).activeTenant?.tenant; + final companyName = activeTenant?.companyName ?? ''; + final showLocationWarning = activeTenant?.hasLocation != true; return Scaffold( backgroundColor: AppColors.background, @@ -159,16 +201,31 @@ class _ClinicDashboardScreenState extends ConsumerState { future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { - return _DashboardSkeleton(companyName: companyName, hPad: hPad); + return _DashboardSkeleton( + companyName: companyName, hPad: hPad); } if (snap.hasError) { return _ErrorBody(onRetry: _load); } final data = snap.data!; - final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; return CustomScrollView( slivers: [ _DashboardHeader(companyName: companyName), + if (showLocationWarning) + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0), + sliver: SliverToBoxAdapter( + child: LocationCompletionBanner( + title: 'Konum kaydı eksik', + description: + 'Haritada görünmek ve yakın laboratuvar sıralamasında doğru yer almak için işletme konumunu tamamlayın.', + buttonLabel: 'Konumu Tamamla', + onTap: () => context.go(routeClinicSettings), + ), + ), + ), if (isDesktop) SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), @@ -186,14 +243,18 @@ class _ClinicDashboardScreenState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), sliver: SliverToBoxAdapter( child: _MonthlyReportSection(data: data) - .animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0), + .animate() + .fadeIn(duration: 300.ms) + .slideY(begin: 0.08, end: 0), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), sliver: SliverToBoxAdapter( child: _GamificationRow(data: data) - .animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0), + .animate() + .fadeIn(duration: 300.ms, delay: 60.ms) + .slideY(begin: 0.08, end: 0), ), ), ], @@ -208,7 +269,10 @@ class _ClinicDashboardScreenState extends ConsumerState { minimumSize: const Size(double.infinity, 52), backgroundColor: AppColors.accent, ), - ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0), + ) + .animate() + .fadeIn(duration: 300.ms) + .slideY(begin: 0.1, end: 0), ), ), if (data.actionJobs.isNotEmpty) @@ -220,7 +284,10 @@ class _ClinicDashboardScreenState extends ConsumerState { actingJobs: _actingJobs, onApprove: _approveAtClinic, onDeliver: _markDelivered, - ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.06, end: 0), + ) + .animate() + .fadeIn(duration: 300.ms) + .slideY(begin: 0.06, end: 0), ), ), SliverPadding( @@ -235,7 +302,8 @@ class _ClinicDashboardScreenState extends ConsumerState { onPressed: () => context.go(routeClinicJobs), style: TextButton.styleFrom( foregroundColor: AppColors.accent, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: + const EdgeInsets.symmetric(horizontal: 8), ), child: const Text('Tümünü Gör'), ), @@ -251,7 +319,8 @@ class _ClinicDashboardScreenState extends ConsumerState { padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24), sliver: SliverList.separated( itemCount: data.recentJobs.length, - separatorBuilder: (_, __) => const SizedBox(height: 10), + separatorBuilder: (_, __) => + const SizedBox(height: 10), itemBuilder: (ctx, i) => _JobCard(job: data.recentJobs[i]) .animate(delay: (i * 60).ms) @@ -319,30 +388,47 @@ class _ActionSection extends StatelessWidget { Row( children: [ Container( - width: 26, height: 26, - decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(7)), - child: const Icon(Icons.priority_high_rounded, size: 15, color: Colors.white), + width: 26, + height: 26, + decoration: BoxDecoration( + color: AppColors.pending, + borderRadius: BorderRadius.circular(7)), + child: const Icon(Icons.priority_high_rounded, + size: 15, color: Colors.white), ), const SizedBox(width: 8), - Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + Text('Yapılacaklar', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700)), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(10)), - child: Text('${jobs.length}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: Colors.white)), + decoration: BoxDecoration( + color: AppColors.pending, + borderRadius: BorderRadius.circular(10)), + child: Text('${jobs.length}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white)), ), ], ), const SizedBox(height: 12), ...jobs.asMap().entries.map((entry) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _ActionJobCard( - job: entry.value, - acting: actingJobs[entry.value.id] == true, - onApprove: () => onApprove(entry.value), - onDeliver: () => onDeliver(entry.value), - ).animate(delay: (entry.key * 50).ms).fadeIn(duration: 250.ms).slideY(begin: 0.08, end: 0), - )), + padding: const EdgeInsets.only(bottom: 10), + child: _ActionJobCard( + job: entry.value, + acting: actingJobs[entry.value.id] == true, + onApprove: () => onApprove(entry.value), + onDeliver: () => onDeliver(entry.value), + ) + .animate(delay: (entry.key * 50).ms) + .fadeIn(duration: 250.ms) + .slideY(begin: 0.08, end: 0), + )), ], ); } @@ -361,15 +447,18 @@ class _ActionJobCard extends StatelessWidget { final VoidCallback onApprove; final VoidCallback onDeliver; - bool get _isProva => job.status == JobStatus.inProgress && job.location == JobLocation.atClinic; + bool get _isProva => + job.status == JobStatus.inProgress && + job.location == JobLocation.atClinic; @override Widget build(BuildContext context) { final isProva = _isProva; final borderColor = isProva ? AppColors.pending : AppColors.accent; - final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg; - final iconColor = isProva ? AppColors.pending : AppColors.accent; - final icon = isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined; + final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg; + final iconColor = isProva ? AppColors.pending : AppColors.accent; + final icon = + isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined; final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor'; return Semantics( @@ -385,8 +474,14 @@ class _ActionJobCard extends StatelessWidget { child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), - border: Border.all(color: borderColor.withValues(alpha: 0.45), width: 1.5), - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 3))], + border: Border.all( + color: borderColor.withValues(alpha: 0.45), width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 3)) + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -396,8 +491,11 @@ class _ActionJobCard extends StatelessWidget { child: Row( children: [ Container( - width: 40, height: 40, - decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(11)), + width: 40, + height: 40, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(11)), child: Icon(icon, color: iconColor, size: 19), ), const SizedBox(width: 10), @@ -405,21 +503,34 @@ class _ActionJobCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + Text(job.patientCode, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary)), const SizedBox(height: 2), Text( '${job.prostheticType.label} · ${job.labName ?? 'Lab'}', - style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), - maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8)), - child: Text(statusLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: iconColor)), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8)), + child: Text(statusLabel, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: iconColor)), ), ], ), @@ -427,14 +538,20 @@ class _ActionJobCard extends StatelessWidget { Container( decoration: BoxDecoration( color: bgColor.withValues(alpha: 0.45), - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(13), bottomRight: Radius.circular(13)), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(13), + bottomRight: Radius.circular(13)), ), padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), child: acting ? const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 4), - child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent)), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: AppColors.accent)), ), ) : isProva @@ -442,27 +559,38 @@ class _ActionJobCard extends StatelessWidget { Expanded( child: FilledButton.icon( onPressed: onApprove, - icon: const Icon(Icons.check_circle_outline, size: 15), - label: const Text('Onayla', style: TextStyle(fontSize: 13)), + icon: const Icon(Icons.check_circle_outline, + size: 15), + label: const Text('Onayla', + style: TextStyle(fontSize: 13)), style: FilledButton.styleFrom( backgroundColor: AppColors.success, minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric( + horizontal: 12), ), ), ), const SizedBox(width: 8), OutlinedButton.icon( - onPressed: () => context.push('/clinic/jobs/${job.id}'), - icon: const Icon(Icons.open_in_new_rounded, size: 14), - label: const Text('Detay', style: TextStyle(fontSize: 13)), + onPressed: () => + context.push('/clinic/jobs/${job.id}'), + icon: const Icon(Icons.open_in_new_rounded, + size: 14), + label: const Text('Detay', + style: TextStyle(fontSize: 13)), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric( + horizontal: 12), foregroundColor: AppColors.pending, - side: BorderSide(color: AppColors.pending.withValues(alpha: 0.6)), + side: BorderSide( + color: AppColors.pending + .withValues(alpha: 0.6)), ), ), ]) @@ -470,26 +598,37 @@ class _ActionJobCard extends StatelessWidget { Expanded( child: FilledButton.icon( onPressed: onDeliver, - icon: const Icon(Icons.inventory_2_outlined, size: 15), - label: const Text('Teslim Aldım', style: TextStyle(fontSize: 13)), + icon: const Icon(Icons.inventory_2_outlined, + size: 15), + label: const Text('Teslim Aldım', + style: TextStyle(fontSize: 13)), style: FilledButton.styleFrom( minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric( + horizontal: 12), ), ), ), const SizedBox(width: 8), OutlinedButton.icon( - onPressed: () => context.push('/clinic/jobs/${job.id}'), - icon: const Icon(Icons.open_in_new_rounded, size: 14), - label: const Text('Detay', style: TextStyle(fontSize: 13)), + onPressed: () => + context.push('/clinic/jobs/${job.id}'), + icon: const Icon(Icons.open_in_new_rounded, + size: 14), + label: const Text('Detay', + style: TextStyle(fontSize: 13)), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric( + horizontal: 12), foregroundColor: AppColors.accent, - side: BorderSide(color: AppColors.accent.withValues(alpha: 0.6)), + side: BorderSide( + color: AppColors.accent + .withValues(alpha: 0.6)), ), ), ]), @@ -526,20 +665,34 @@ class _MonthlyReportSection extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent), + const Icon(Icons.bar_chart_rounded, + size: 18, color: AppColors.accent), const SizedBox(width: 6), - Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text('Aylık Rapor', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w600)), ], ), const SizedBox(height: 12), Row( children: [ - Expanded(child: _MonthStat(label: 'Bu Ay', value: data.thisMonthDelivered, highlighted: true)), + Expanded( + child: _MonthStat( + label: 'Bu Ay', + value: data.thisMonthDelivered, + highlighted: true)), const SizedBox(width: 12), - Expanded(child: _MonthStat(label: 'Geçen Ay', value: data.lastMonthDelivered, highlighted: false)), + Expanded( + child: _MonthStat( + label: 'Geçen Ay', + value: data.lastMonthDelivered, + highlighted: false)), const SizedBox(width: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: isUp ? AppColors.successBg : AppColors.cancelledBg, borderRadius: BorderRadius.circular(8), @@ -548,7 +701,9 @@ class _MonthlyReportSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, + isUp + ? Icons.trending_up_rounded + : Icons.trending_down_rounded, size: 16, color: isUp ? AppColors.success : AppColors.cancelled, ), @@ -573,7 +728,8 @@ class _MonthlyReportSection extends StatelessWidget { } class _MonthStat extends StatelessWidget { - const _MonthStat({required this.label, required this.value, required this.highlighted}); + const _MonthStat( + {required this.label, required this.value, required this.highlighted}); final String label; final int value; final bool highlighted; @@ -583,14 +739,22 @@ class _MonthStat extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background, + color: highlighted + ? AppColors.accent.withValues(alpha: 0.06) + : AppColors.background, borderRadius: BorderRadius.circular(8), - border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null, + border: highlighted + ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + Text(label, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500)), const SizedBox(height: 2), Text( '$value iş', @@ -617,7 +781,8 @@ class _GamificationRow extends StatelessWidget { @override Widget build(BuildContext context) { final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0); - final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal); + final remaining = + (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -632,7 +797,11 @@ class _GamificationRow extends StatelessWidget { children: [ const Text('🏆', style: TextStyle(fontSize: 16)), const SizedBox(width: 6), - Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text('Aylık Hedef', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w600)), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), @@ -642,7 +811,10 @@ class _GamificationRow extends StatelessWidget { ), child: Text( '${data.points} puan', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary), ), ), ], @@ -665,14 +837,17 @@ class _GamificationRow extends StatelessWidget { children: [ Text( '${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), ), Text( progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary, + color: progress >= 1.0 + ? AppColors.success + : AppColors.textSecondary, ), ), ], @@ -693,7 +868,8 @@ class _DashboardHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; if (isDesktop) { return SliverAppBar( @@ -712,15 +888,24 @@ class _DashboardHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)), - const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + Text('Genel Bakış', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary.withValues(alpha: 0.8), + letterSpacing: 0.3)), + const Text('Bugünkü Durum', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary)), ], ), ), actions: [ IconButton( onPressed: () => context.go(routeClinicSettings), - icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22), + icon: const Icon(Icons.settings_outlined, + color: AppColors.textSecondary, size: 22), ), const SizedBox(width: 8), ], @@ -751,14 +936,26 @@ class _DashboardHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)), - Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis), + Text('DLS', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.5)), + Text(companyName, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis), ], ), actions: [ IconButton( onPressed: () => context.go(routeClinicSettings), - icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22), + icon: const Icon(Icons.settings_outlined, + color: Colors.white, size: 22), ), ], flexibleSpace: FlexibleSpaceBar( @@ -777,9 +974,19 @@ class _DashboardHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.end, children: [ - Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)), + Text('Genel Bakış', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5)), const SizedBox(height: 4), - const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)), + const Text('Bugünkü Durum', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: -0.5)), ], ), ), @@ -805,19 +1012,48 @@ class _StatsRow extends StatelessWidget { @override Widget build(BuildContext context) { - final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint; + final isWideDesktop = + MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint; - final c1 = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg) - .animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); - final c2 = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg) - .animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); - final c3 = _StatCard(label: 'Toplam Hasta', value: '$patients', icon: Icons.people_outline_rounded, color: AppColors.success, bgColor: AppColors.successBg) - .animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final c1 = _StatCard( + label: 'Bekleyen', + value: '$pending', + icon: Icons.hourglass_top_rounded, + color: AppColors.pending, + bgColor: AppColors.pendingBg) + .animate() + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); + final c2 = _StatCard( + label: 'Devam Eden', + value: '$inProgress', + icon: Icons.autorenew_rounded, + color: AppColors.inProgress, + bgColor: AppColors.inProgressBg) + .animate(delay: 80.ms) + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); + final c3 = _StatCard( + label: 'Toplam Hasta', + value: '$patients', + icon: Icons.people_outline_rounded, + color: AppColors.success, + bgColor: AppColors.successBg) + .animate(delay: 160.ms) + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); // Wide desktop (≥ 1100px): 4 cards side by side — full lifecycle view. if (isWideDesktop) { - final c4 = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg) - .animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final c4 = _StatCard( + label: 'Klinik\'te', + value: '$sent', + icon: Icons.local_hospital_outlined, + color: AppColors.accent, + bgColor: AppColors.inProgressBg) + .animate(delay: 120.ms) + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); return Row( children: [ Expanded(child: c1), @@ -883,8 +1119,8 @@ class _StatCard extends StatelessWidget { Container( width: 44, height: 44, - decoration: - BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)), + decoration: BoxDecoration( + color: bgColor, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: color, size: 22), ), const SizedBox(width: 12), @@ -933,85 +1169,85 @@ class _JobCard extends StatelessWidget { button: true, excludeSemantics: true, child: Material( - color: AppColors.surface, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: () => context.push('/clinic/jobs/${job.id}'), + color: AppColors.surface, borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border)), - child: Row( - children: [ - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: statusBg, borderRadius: BorderRadius.circular(12)), - child: Icon(Icons.work_outline_rounded, - color: statusColor, size: 22), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(job.patientCode, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary)), - const SizedBox(height: 2), - Text(job.labName ?? 'Laboratuvar', - style: const TextStyle( - fontSize: 13, color: AppColors.textSecondary)), - const SizedBox(height: 6), - Wrap( - spacing: 6, - children: [ - _Tag( - label: job.prostheticType.label, - color: AppColors.inProgress, - bg: AppColors.inProgressBg), - _Tag( - label: job.status.label, - color: statusColor, - bg: statusBg), - ], - ), - ], + child: InkWell( + onTap: () => context.push('/clinic/jobs/${job.id}'), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border)), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: statusBg, borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.work_outline_rounded, + color: statusColor, size: 22), ), - ), - if (dueText != null) ...[ - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Icon(Icons.calendar_today_outlined, - size: 13, - color: isOverdue - ? AppColors.cancelled - : AppColors.textMuted), - const SizedBox(height: 3), - Text( - dueText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.patientCode, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(job.labName ?? 'Laboratuvar', + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + children: [ + _Tag( + label: job.prostheticType.label, + color: AppColors.inProgress, + bg: AppColors.inProgressBg), + _Tag( + label: job.status.label, + color: statusColor, + bg: statusBg), + ], + ), + ], + ), + ), + if (dueText != null) ...[ + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon(Icons.calendar_today_outlined, + size: 13, color: isOverdue ? AppColors.cancelled - : AppColors.textSecondary), - ), - ], - ), + : AppColors.textMuted), + const SizedBox(height: 3), + Text( + dueText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isOverdue + ? AppColors.cancelled + : AppColors.textSecondary), + ), + ], + ), + ], ], - ], + ), ), ), ), - ), ); } @@ -1096,8 +1332,7 @@ class _EmptyJobs extends StatelessWidget { const Text( 'Yeni iş oluşturduğunuzda\nburada görünecek', textAlign: TextAlign.center, - style: - TextStyle(fontSize: 13, color: AppColors.textSecondary), + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), ), ], ), @@ -1222,8 +1457,8 @@ class _ShimmerBoxState extends State<_ShimmerBox> height: widget.height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(widget.radius), - color: Color.lerp(const Color(0xFFE2E8F0), - const Color(0xFFF1F5F9), _anim.value)), + color: Color.lerp( + const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)), ), ); } diff --git a/lib/features/clinic/finance/clinic_finance_repository.dart b/lib/features/clinic/finance/clinic_finance_repository.dart index 362e4b0..7ff2a73 100644 --- a/lib/features/clinic/finance/clinic_finance_repository.dart +++ b/lib/features/clinic/finance/clinic_finance_repository.dart @@ -16,14 +16,18 @@ class ClinicFinanceRepository { int limit = 30, }) async { final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"']; - if (status != null) filterParts.add('status = "$status"'); + if (status == FinanceStatus.pending.value) { + filterParts.add('(status = "pending" || status = "reported")'); + } else if (status != null) { + filterParts.add('status = "$status"'); + } final result = await _pb.collection('finance_entries').getList( - page: page, - perPage: limit, - filter: filterParts.join(' && '), - expand: 'job_id', - ); + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: 'job_id', + ); return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList() ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); } @@ -32,7 +36,7 @@ class ClinicFinanceRepository { final all = await listEntries(tenantId, limit: 200); double pending = 0, paid = 0; for (final e in all) { - if (e.status == FinanceStatus.pending) { + if (e.status.isOpen) { pending += e.amount; } else { paid += e.amount; @@ -41,15 +45,17 @@ class ClinicFinanceRepository { return {'pending': pending, 'paid': paid}; } - Future> byCounterparty(String tenantId) async { + Future> byCounterparty( + String tenantId) async { final entries = await listEntries(tenantId, limit: 300); final map = {}; for (final entry in entries) { - final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown'; + final key = + entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown'; final current = map[key]; final pending = (current?.pendingAmount ?? 0) + - (entry.status == FinanceStatus.pending ? entry.amount : 0); + (entry.status.isOpen ? entry.amount : 0); final paid = (current?.paidAmount ?? 0) + (entry.status == FinanceStatus.paid ? entry.amount : 0); map[key] = CounterpartyFinanceSummary( @@ -67,16 +73,16 @@ class ClinicFinanceRepository { return list; } - Future markPaid(String entryId) async { + Future reportPayment(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: { - 'status': 'paid', - 'paid_at': DateTime.now().toIso8601String(), + 'status': 'reported', + 'paid_at': null, }); return; } - await FinanceService.instance.markJobPaid(jobId); + await FinanceService.instance.reportJobPayment(jobId); } } diff --git a/lib/features/clinic/finance/clinic_finance_screen.dart b/lib/features/clinic/finance/clinic_finance_screen.dart index a3e6448..732caac 100644 --- a/lib/features/clinic/finance/clinic_finance_screen.dart +++ b/lib/features/clinic/finance/clinic_finance_screen.dart @@ -101,8 +101,7 @@ class _ClinicFinanceScreenState extends ConsumerState future: _headerFuture, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { - return const LinearProgressIndicator( - color: AppColors.accent); + return const LinearProgressIndicator(color: AppColors.accent); } final data = snap.data ?? const _ClinicFinanceHeaderData( @@ -117,7 +116,7 @@ class _ClinicFinanceScreenState extends ConsumerState children: [ Expanded( child: _SummaryCard( - label: s.pendingReceivable, + label: 'Açık Borç', amount: data.summary['pending'] ?? 0.0, currencyCode: currencyCode, color: AppColors.pending, @@ -128,7 +127,7 @@ class _ClinicFinanceScreenState extends ConsumerState const SizedBox(width: 12), Expanded( child: _SummaryCard( - label: s.collected, + label: 'Onaylanan Ödeme', amount: data.summary['paid'] ?? 0.0, currencyCode: currencyCode, color: AppColors.success, @@ -230,8 +229,7 @@ class _SummaryCard extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(12)), + color: bgColor, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: color, size: 22), ), const SizedBox(width: 12), @@ -304,12 +302,10 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { switch (widget.sort) { case _FinanceSort.newestFirst: list.sort((a, b) { - final da = a.dateCreated != null - ? DateTime.tryParse(a.dateCreated!) - : null; - final db = b.dateCreated != null - ? DateTime.tryParse(b.dateCreated!) - : null; + final da = + a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null; + final db = + b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null; if (da == null && db == null) return 0; if (da == null) return 1; if (db == null) return -1; @@ -323,15 +319,15 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { return list; } - Future _markPaid(FinanceEntry entry) async { + Future _reportPayment(FinanceEntry entry) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Ödeme Onayı'), + title: const Text('Ödeme Bildir'), content: Text( '${entry.counterpartyName ?? "Bu kayıt"} için ' '${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu ' - 'ödendi olarak işaretlemek istiyor musunuz?', + 'laboratuvara ödendi olarak bildirmek istiyor musunuz?', ), actions: [ TextButton( @@ -340,19 +336,21 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Ödendi'), + child: const Text('Bildir'), ), ], ), ); if (confirmed != true || !mounted) return; try { - await ClinicFinanceRepository.instance.markPaid(entry.id); + await ClinicFinanceRepository.instance.reportPayment(entry.id); _load(); widget.onPaymentMade(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Ödeme kaydedildi.')), + const SnackBar( + content: Text('Ödeme bildirildi. Laboratuvar onayı bekleniyor.'), + ), ); } } catch (e) { @@ -392,8 +390,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ), const SizedBox(height: 16), Text('Hata: ${snap.error}', - style: const TextStyle( - color: AppColors.textSecondary)), + style: const TextStyle(color: AppColors.textSecondary)), const SizedBox(height: 12), FilledButton.icon( onPressed: _load, @@ -437,10 +434,17 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { itemBuilder: (context, index) { final entry = entries[index]; final isPending = entry.status == FinanceStatus.pending; - final statusColor = - isPending ? AppColors.pending : AppColors.success; - final statusBg = - isPending ? AppColors.pendingBg : AppColors.successBg; + final isReported = entry.status == FinanceStatus.reported; + final statusColor = isPending + ? AppColors.pending + : isReported + ? AppColors.accent + : AppColors.success; + final statusBg = isPending + ? AppColors.pendingBg + : isReported + ? AppColors.inProgressBg + : AppColors.successBg; return Padding( padding: const EdgeInsets.only(bottom: 10), @@ -448,7 +452,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { color: AppColors.surface, borderRadius: BorderRadius.circular(14), child: InkWell( - onTap: isPending ? () => _markPaid(entry) : null, + onTap: isPending ? () => _reportPayment(entry) : null, borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.all(14), @@ -472,7 +476,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { child: Icon( isPending ? Icons.hourglass_empty_rounded - : Icons.check_circle_outline, + : isReported + ? Icons.verified_outlined + : Icons.check_circle_outline, color: statusColor, size: 22, ), @@ -486,8 +492,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { children: [ Expanded( child: Text( - entry.counterpartyName ?? - 'Bilinmiyor', + entry.counterpartyName ?? 'Bilinmiyor', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -522,6 +527,25 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { color: AppColors.textMuted), ), ], + if (isReported) ...[ + const SizedBox(height: 4), + const Text( + 'Ödeme bildirildi, laboratuvar onayı bekleniyor.', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ] else if (isPending) ...[ + const SizedBox(height: 4), + const Text( + 'Dokunarak ödeme bildirimi yapabilirsiniz.', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], ], ), ), diff --git a/lib/features/clinic/jobs/clinic_job_detail_screen.dart b/lib/features/clinic/jobs/clinic_job_detail_screen.dart index aaef644..0a7fffa 100644 --- a/lib/features/clinic/jobs/clinic_job_detail_screen.dart +++ b/lib/features/clinic/jobs/clinic_job_detail_screen.dart @@ -20,39 +20,70 @@ class ClinicJobDetailScreen extends ConsumerStatefulWidget { _ClinicJobDetailScreenState(); } -class _ClinicJobDetailScreenState - extends ConsumerState { +class _ClinicJobDetailScreenState extends ConsumerState { Job? _job; String? _loadError; late Future> _filesFuture; + late Future> _historyFuture; bool _isActing = false; - late UnsubFn _unsub; + final List _unsubs = []; @override void initState() { super.initState(); _load(); _loadFiles(); - _unsub = RealtimeService.instance.watch( + _loadHistory(); + _unsubs.add(RealtimeService.instance.watch( 'jobs', topic: widget.jobId, - onEvent: (_) { if (mounted && !_isActing) _load(); }, - ); + onEvent: (_) { + if (mounted && !_isActing) _load(); + }, + )); + _unsubs.add(RealtimeService.instance.watch( + 'job_files', + filter: 'job_id="${widget.jobId}"', + onEvent: (_) { + if (mounted) _loadFiles(); + }, + )); + _unsubs.add(RealtimeService.instance.watch( + 'job_status_history', + filter: 'job_id="${widget.jobId}"', + onEvent: (_) { + if (mounted) _loadHistory(); + }, + )); } @override void dispose() { - _unsub(); + for (final unsub in _unsubs) { + unsub(); + } super.dispose(); } Future _load() async { - if (mounted) setState(() { _loadError = null; }); + if (mounted) { + setState(() { + _loadError = null; + }); + } try { final job = await ClinicJobsRepository.instance.getJob(widget.jobId); - if (mounted) setState(() { _job = job; }); + if (mounted) { + setState(() { + _job = job; + }); + } } catch (e) { - if (mounted) setState(() { _loadError = e.toString(); }); + if (mounted) { + setState(() { + _loadError = e.toString(); + }); + } } } @@ -62,12 +93,23 @@ class _ClinicJobDetailScreenState }); } + void _loadHistory() { + setState(() { + _historyFuture = JobHistoryService.instance.listForJob(widget.jobId); + }); + } + Future _approve(Job job) async { setState(() => _isActing = true); try { - final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job); + final updated = + await ClinicJobsRepository.instance.approveAtClinic(job.id, job); if (mounted) { - setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + setState(() { + _job = updated.copyWith( + clinicName: job.clinicName, labName: job.labName); + _isActing = false; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('İş onaylandı.')), ); @@ -87,9 +129,12 @@ class _ClinicJobDetailScreenState context: context, builder: (ctx) => AlertDialog( title: const Text('İşi İptal Et'), - content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'), + content: const Text( + 'Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Vazgeç')), FilledButton( style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), onPressed: () => Navigator.pop(ctx, true), @@ -101,15 +146,21 @@ class _ClinicJobDetailScreenState if (confirmed != true || !mounted) return; setState(() => _isActing = true); try { - final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job); + final updated = + await ClinicJobsRepository.instance.cancelJob(job.id, job); if (mounted) { - setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); + setState(() { + _job = _job!.copyWith(status: updated.status); + _isActing = false; + }); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); } } catch (e) { if (mounted) { setState(() => _isActing = false); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); } } } @@ -157,7 +208,11 @@ class _ClinicJobDetailScreenState note: noteController.text.trim(), ); if (mounted) { - setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + setState(() { + _job = updated.copyWith( + clinicName: job.clinicName, labName: job.labName); + _isActing = false; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Revizyon talebi gönderildi.')), ); @@ -212,10 +267,16 @@ class _ClinicJobDetailScreenState setState(() => _isActing = true); try { - final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null; - final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note); + final note = + noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null; + final updated = await ClinicJobsRepository.instance + .markDelivered(job.id, job, note: note); if (mounted) { - setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + setState(() { + _job = updated.copyWith( + clinicName: job.clinicName, labName: job.labName); + _isActing = false; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')), ); @@ -241,7 +302,8 @@ class _ClinicJobDetailScreenState Widget _buildBody() { if (_job == null && _loadError == null) { - return const Center(child: CircularProgressIndicator(color: AppColors.accent)); + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); } if (_loadError != null && _job == null) { return Center( @@ -270,22 +332,28 @@ class _ClinicJobDetailScreenState ), ); } - if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent)); + if (_job == null) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } final job = _job!; final membership = ref.read(authProvider).activeTenant; final canDeliver = membership?.canDeliverJobs ?? true; - final canCancel = membership?.canCancelJobs ?? true; - final canManage = !(membership?.isDeliveryOnly ?? false); + final canCancel = membership?.canCancelJobs ?? true; + final canManage = !(membership?.isDeliveryOnly ?? false); return _JobDetailBody( job: job, filesFuture: _filesFuture, + historyFuture: _historyFuture, isActing: _isActing, canDeliver: canDeliver, canManage: canManage, onApprove: canManage ? () => _approve(job) : () {}, onRevision: canManage ? () => _requestRevision(job) : () {}, onDelivered: () => _markDelivered(job), - onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null, + onCancel: (canCancel && job.status == JobStatus.pending) + ? () => _cancelJob(job) + : null, onFilesRefresh: _loadFiles, ); } @@ -295,6 +363,7 @@ class _JobDetailBody extends StatelessWidget { const _JobDetailBody({ required this.job, required this.filesFuture, + required this.historyFuture, required this.isActing, required this.canDeliver, required this.canManage, @@ -307,6 +376,7 @@ class _JobDetailBody extends StatelessWidget { final Job job; final Future> filesFuture; + final Future> historyFuture; final bool isActing; final bool canDeliver; final bool canManage; @@ -355,7 +425,9 @@ class _JobDetailBody extends StatelessWidget { job.patientName?.isNotEmpty == true ? job.patientName! : job.patientCode, - style: Theme.of(context).textTheme.headlineSmall + style: Theme.of(context) + .textTheme + .headlineSmall ?.copyWith( fontWeight: FontWeight.bold, color: AppColors.textPrimary), @@ -369,7 +441,7 @@ class _JobDetailBody extends StatelessWidget { const SizedBox(height: 12), // Patient + Lab - _SectionLabel(title: 'Hasta & Laboratuvar'), + const _SectionLabel(title: 'Hasta & Laboratuvar'), if (job.patientName != null && job.patientName!.isNotEmpty) _InfoRow(label: 'Hasta', value: job.patientName!), _InfoRow(label: 'Protokol No', value: job.patientCode), @@ -380,12 +452,13 @@ class _JobDetailBody extends StatelessWidget { const SizedBox(height: 12), // Prosthetic - _SectionLabel(title: 'Protez Bilgisi'), + const _SectionLabel(title: 'Protez Bilgisi'), _InfoRow(label: 'Tür', value: job.prostheticType.label), if (job.prostheticName != null && job.prostheticName!.isNotEmpty) _InfoRow(label: 'Ürün', value: job.prostheticName!), if (job.workflowType != null) _InfoRow(label: 'İş Tipi', value: job.workflowType!.label), + _InfoRow(label: 'Akış', value: job.workflowPreset.title), _InfoRow( label: 'Prova', value: job.provaRequired ? 'Provalı' : 'Provasız', @@ -398,7 +471,9 @@ class _JobDetailBody extends StatelessWidget { if (job.description != null && job.description!.isNotEmpty) _InfoRow(label: 'Açıklama', value: job.description!), if (job.dueDate != null) - _InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)), + _InfoRow( + label: 'Son Tarih', + value: _formatDate(job.dueDate!, withTime: true)), if (job.price != null) _InfoRow( label: 'Fiyat', @@ -438,7 +513,8 @@ class _JobDetailBody extends StatelessWidget { _StepperWidget( steps: steps, currentStepIndex: currentStepIndex, - historyFuture: JobHistoryService.instance.listForJob(job.id), + isDelivered: job.status == JobStatus.delivered, + historyFuture: historyFuture, ), ], ), @@ -516,7 +592,8 @@ class _JobDetailBody extends StatelessWidget { } String _formatDate(DateTime d, {bool withTime = false}) { - final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + final s = + '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; if (!withTime || (d.hour == 0 && d.minute == 0)) return s; return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } @@ -526,11 +603,13 @@ class _StepperWidget extends StatelessWidget { const _StepperWidget({ required this.steps, required this.currentStepIndex, + required this.isDelivered, required this.historyFuture, }); final List steps; final int currentStepIndex; + final bool isDelivered; final Future> historyFuture; @override @@ -542,7 +621,8 @@ class _StepperWidget extends StatelessWidget { final Map revisionCounts = {}; final Map> notesByStep = {}; for (final e in history) { - if (e.action == JobHistoryAction.revisionRequested && e.step != null) { + if (e.action == JobHistoryAction.revisionRequested && + e.step != null) { revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1; } if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) { @@ -554,8 +634,8 @@ class _StepperWidget extends StatelessWidget { children: steps.asMap().entries.map((entry) { final index = entry.key; final step = entry.value; - final isCompleted = index < currentStepIndex; - final isCurrent = index == currentStepIndex; + final isCompleted = isDelivered || index < currentStepIndex; + final isCurrent = !isDelivered && index == currentStepIndex; final revCount = revisionCounts[step] ?? 0; final stepNotes = notesByStep[step] ?? const []; @@ -582,7 +662,7 @@ class _StepperWidget extends StatelessWidget { Container( width: 2, height: 44, - color: index < currentStepIndex + color: isDelivered || index < currentStepIndex ? AppColors.success.withValues(alpha: 0.35) : AppColors.border, ), @@ -642,7 +722,8 @@ class _StepperWidget extends StatelessWidget { ), if (stepNotes.isNotEmpty) ...[ const SizedBox(height: 8), - ...stepNotes.map((entry) => _StepNoteCard(entry: entry)), + ...stepNotes + .map((entry) => _StepNoteCard(entry: entry)), ], ], ), @@ -700,6 +781,7 @@ class _StepNoteCard extends StatelessWidget { String _label(JobHistoryAction action) { return switch (action) { JobHistoryAction.revisionRequested => 'Revizyon Notu', + JobHistoryAction.stepCompleted => 'İç Adım Notu', JobHistoryAction.handedToClinic => 'Laboratuvar Notu', JobHistoryAction.approved => 'Onay Notu', JobHistoryAction.delivered => 'Teslim Notu', @@ -745,8 +827,8 @@ class _InfoRow extends StatelessWidget { width: 110, child: Text( label, - style: const TextStyle( - fontSize: 13, color: AppColors.textSecondary), + style: + const TextStyle(fontSize: 13, color: AppColors.textSecondary), ), ), Expanded( diff --git a/lib/features/clinic/jobs/clinic_jobs_repository.dart b/lib/features/clinic/jobs/clinic_jobs_repository.dart index 888ce29..449602f 100644 --- a/lib/features/clinic/jobs/clinic_jobs_repository.dart +++ b/lib/features/clinic/jobs/clinic_jobs_repository.dart @@ -35,17 +35,18 @@ class ClinicJobsRepository { } final result = await _pb.collection('jobs').getList( - page: page, - perPage: limit, - filter: filterParts.join(' && '), - expand: _listExpand, - ); + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: _listExpand, + ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); } Future getJob(String jobId) async { - final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand); + final record = + await _pb.collection('jobs').getOne(jobId, expand: _detailExpand); return Job.fromJson(record.toJson()); } @@ -66,13 +67,15 @@ class ClinicJobsRepository { String? currency, JobWorkflowType? workflowType, bool provaRequired = true, + List workflowSteps = const [], }) async { final record = await _pb.collection('jobs').create(body: { 'clinic_tenant_id': clinicTenantId, 'lab_tenant_id': labTenantId, 'patient_code': patientCode, if (patientId != null) 'patient_id': patientId, - if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId, + if (prostheticId != null && prostheticId.isNotEmpty) + 'prosthetic_id': prostheticId, 'prosthetic_type': prostheticType.value, 'member_count': teeth.length, 'teeth': teeth, @@ -82,6 +85,7 @@ class ClinicJobsRepository { if (price != null) 'price': price, if (currency != null && currency.isNotEmpty) 'currency': currency, if (workflowType != null) 'workflow_type': workflowType.value, + if (workflowSteps.isNotEmpty) 'workflow_steps': workflowSteps, 'status': 'pending', 'location': 'at_clinic', 'prova_required': provaRequired, @@ -126,7 +130,8 @@ class ClinicJobsRepository { return updated; } - Future requestRevision(String jobId, Job job, {required String note}) async { + Future requestRevision(String jobId, Job job, + {required String note}) async { final record = await _pb.collection('jobs').update(jobId, body: { 'location': 'at_lab', }); @@ -170,33 +175,42 @@ class ClinicJobsRepository { return Job.fromJson(record.toJson()); } - Future>> listApprovedLabs(String clinicTenantId) async { + Future>> listApprovedLabs( + String clinicTenantId) async { final result = await _pb.collection('connections').getList( - filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"', - expand: 'lab_tenant_id', - perPage: 100, - ); + filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"', + expand: 'lab_tenant_id', + perPage: 100, + ); return result.items.map((r) { final expand = r.toJson()['expand'] as Map?; - return expand?['lab_tenant_id'] as Map? ?? {'id': r.data['lab_tenant_id']}; + return expand?['lab_tenant_id'] as Map? ?? + {'id': r.data['lab_tenant_id']}; }).toList(); } - Future> listJobsByPatient(String patientId, {int limit = 50}) async { + Future> listJobsByPatient(String patientId, + {int limit = 50}) async { final result = await _pb.collection('jobs').getList( - filter: 'patient_id = "$patientId"', - perPage: limit, - expand: _listExpand, - ); + filter: 'patient_id = "$patientId"', + perPage: limit, + expand: _listExpand, + ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); } - Future countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async { - final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"']; + Future countDelivered(String clinicTenantId, + {DateTime? from, DateTime? to}) async { + final parts = [ + 'clinic_tenant_id = "$clinicTenantId"', + 'status = "delivered"' + ]; if (from != null) parts.add('updated >= "${_date(from)}"'); if (to != null) parts.add('updated < "${_date(to)}"'); - final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && ')); + final r = await _pb + .collection('jobs') + .getList(perPage: 1, filter: parts.join(' && ')); return r.totalItems; } diff --git a/lib/features/clinic/jobs/new_job_screen.dart b/lib/features/clinic/jobs/new_job_screen.dart index 49ca5dc..beb1aca 100644 --- a/lib/features/clinic/jobs/new_job_screen.dart +++ b/lib/features/clinic/jobs/new_job_screen.dart @@ -13,6 +13,7 @@ import '../../../core/theme/app_theme.dart'; import '../../../models/job.dart'; import '../../../models/patient.dart'; import '../../../models/prosthetic_product.dart'; +import '../../../models/tenant.dart'; import '../../lab/discounts/discount_repository.dart'; import '../../lab/products/lab_products_repository.dart'; import 'clinic_jobs_repository.dart'; @@ -111,8 +112,7 @@ class _NewJobScreenState extends ConsumerState { _labsError = null; }); try { - final tenantId = - ref.read(authProvider).activeTenant!.tenant.id; + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final labs = await ClinicJobsRepository.instance.listApprovedLabs(tenantId); setState(() { @@ -149,9 +149,8 @@ class _NewJobScreenState extends ConsumerState { labId, isActive: true, ); - final matchingProducts = products - .where((p) => p.prostheticType == ptValue) - .toList(); + final matchingProducts = + products.where((p) => p.prostheticType == ptValue).toList(); ProstheticProduct? product; if (_selectedProduct != null) { @@ -230,8 +229,7 @@ class _NewJobScreenState extends ConsumerState { _availableProducts.isEmpty; bool get _hasSelectedProductWithoutPrice => - _selectedProduct != null && - _selectedProduct!.unitPrice == null; + _selectedProduct != null && _selectedProduct!.unitPrice == null; bool get _canSubmitJob => !_isSubmitting && @@ -251,8 +249,7 @@ class _NewJobScreenState extends ConsumerState { } setState(() => _patientSearchLoading = true); try { - final tenantId = - ref.read(authProvider).activeTenant!.tenant.id; + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final results = await ClinicPatientsRepository.instance .listPatients(tenantId, search: normalizedQuery, limit: 10); if (!mounted || _patientSearchController.text.trim() != normalizedQuery) { @@ -315,8 +312,11 @@ class _NewJobScreenState extends ConsumerState { if (!mounted) return; setState(() { _dueDate = DateTime( - pickedDate.year, pickedDate.month, pickedDate.day, - pickedTime?.hour ?? 17, pickedTime?.minute ?? 0, + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime?.hour ?? 17, + pickedTime?.minute ?? 0, ); }); } @@ -326,7 +326,8 @@ class _NewJobScreenState extends ConsumerState { final date = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ'; - final rand = List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join(); + final rand = + List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join(); return 'PR-$date-$rand'; } @@ -397,6 +398,13 @@ class _NewJobScreenState extends ConsumerState { lastName: rawLastName.isNotEmpty ? rawLastName : null, ); } + final selectedLabTenant = Tenant.fromJson(_selectedLab!); + final workflowSteps = buildJobWorkflowPreset( + prostheticType: _selectedProstheticType!, + workflowType: _selectedWorkflowType, + provaRequired: _provaRequired, + optionalSteps: selectedLabTenant.workflowOverrideSteps, + ).steps; final job = await ClinicJobsRepository.instance.createJob( clinicTenantId: tenantId, labTenantId: _selectedLab!['id'] as String, @@ -418,24 +426,29 @@ class _NewJobScreenState extends ConsumerState { currency: _labProduct?.currency, workflowType: _selectedWorkflowType, provaRequired: _provaRequired, + workflowSteps: workflowSteps.map((step) => step.value).toList(), ); // Upload pending files if (_pendingFiles.isNotEmpty) { final pb = PocketBaseClient.instance.pb; final token = pb.authStore.token; - final uploaderId = (pb.authStore.record?.id) ?? (auth.profile?.id ?? ''); + final uploaderId = + (pb.authStore.record?.id) ?? (auth.profile?.id ?? ''); for (final file in _pendingFiles) { final bytes = file.bytes; if (bytes == null) continue; final ext = (file.extension ?? '').toLowerCase(); final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply') ? 'scan' - : (ext == 'pdf') ? 'document' : 'image'; + : (ext == 'pdf') + ? 'document' + : 'image'; final mimeType = _mimeFromExt(ext); final req = http.MultipartRequest( 'POST', - Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'), + Uri.parse( + 'https://pocket.kovaksoft.com/api/collections/job_files/records'), ) ..headers['Authorization'] = 'Bearer $token' ..fields['job_id'] = job.id @@ -483,7 +496,7 @@ class _NewJobScreenState extends ConsumerState { padding: const EdgeInsets.all(16), children: [ // Lab selection - _SectionLabel(label: 'Laboratuvar *'), + const _SectionLabel(label: 'Laboratuvar *'), if (_labsLoading) const Center(child: CircularProgressIndicator()) else if (_labsError != null) @@ -523,7 +536,7 @@ class _NewJobScreenState extends ConsumerState { ), const SizedBox(height: 16), - _SectionLabel(label: 'Hasta / Protokol'), + const _SectionLabel(label: 'Hasta / Protokol'), const SizedBox(height: 8), SegmentedButton<_PatientEntryMode>( segments: const [ @@ -566,7 +579,8 @@ class _NewJobScreenState extends ConsumerState { dense: true, leading: Icon(Icons.info_outline), title: Text('Hasta bulunamadı'), - subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'), + subtitle: Text( + 'İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'), ), ..._patientResults.map( (p) => ListTile( @@ -668,7 +682,7 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 16), // Prosthetic type - _SectionLabel(label: 'Protez Türü *'), + const _SectionLabel(label: 'Protez Türü *'), DropdownButtonFormField( initialValue: _selectedProstheticType, decoration: const InputDecoration( @@ -689,12 +703,11 @@ class _NewJobScreenState extends ConsumerState { }); _refreshProductsAndPrice(); }, - validator: (val) => - val == null ? 'Protez türü zorunludur' : null, + validator: (val) => val == null ? 'Protez türü zorunludur' : null, ), const SizedBox(height: 16), - _SectionLabel(label: 'Ürün'), + const _SectionLabel(label: 'Ürün'), DropdownButtonFormField( initialValue: _selectedProduct, decoration: InputDecoration( @@ -716,7 +729,8 @@ class _NewJobScreenState extends ConsumerState { ), ) .toList(), - onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty) + onChanged: (_selectedProstheticType == null || + _availableProducts.isEmpty) ? null : (val) { setState(() => _selectedProduct = val); @@ -733,14 +747,15 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 8), _InlineInfoBanner( message: _productAvailabilityMessage!, - tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice - ? _InfoBannerTone.warning - : _InfoBannerTone.info, + tone: + _hasMissingProductForType || _hasSelectedProductWithoutPrice + ? _InfoBannerTone.warning + : _InfoBannerTone.info, ), ], const SizedBox(height: 16), - _SectionLabel(label: 'İş Tipi'), + const _SectionLabel(label: 'İş Tipi'), DropdownButtonFormField( initialValue: _selectedWorkflowType, decoration: const InputDecoration( @@ -754,19 +769,22 @@ class _NewJobScreenState extends ConsumerState { ), ) .toList(), - onChanged: (val) => - setState(() => _selectedWorkflowType = val), - validator: (val) => - val == null ? 'Lütfen iş tipi seçin' : null, + onChanged: (val) => setState(() => _selectedWorkflowType = val), + validator: (val) => val == null ? 'Lütfen iş tipi seçin' : null, ), // Price preview if (_priceLoading) const Padding( padding: EdgeInsets.only(top: 8), child: Row(children: [ - SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5)), + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 1.5)), SizedBox(width: 8), - Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + Text('Fiyat yükleniyor...', + style: + TextStyle(fontSize: 12, color: AppColors.textMuted)), ]), ) else if (_labProduct != null && _effectivePrice != null) ...[ @@ -784,6 +802,10 @@ class _NewJobScreenState extends ConsumerState { _ProvaToggle( value: _provaRequired, prostheticType: _selectedProstheticType, + workflowType: _selectedWorkflowType, + optionalSteps: _selectedLab != null + ? Tenant.fromJson(_selectedLab!).workflowOverrideSteps + : const [], onChanged: (v) => setState(() => _provaRequired = v), ), const SizedBox(height: 16), @@ -809,7 +831,10 @@ class _NewJobScreenState extends ConsumerState { }, 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)) { _selectedTeeth.removeAll(upper); } else { @@ -820,7 +845,10 @@ class _NewJobScreenState extends ConsumerState { }, 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)) { _selectedTeeth.removeAll(lower); } else { @@ -851,7 +879,7 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 16), // Color (optional) - _SectionLabel(label: 'Renk (İsteğe Bağlı)'), + const _SectionLabel(label: 'Renk (İsteğe Bağlı)'), TextFormField( controller: _colorController, decoration: const InputDecoration( @@ -861,7 +889,7 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 16), // Description (optional) - _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'), + const _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'), TextFormField( controller: _descriptionController, decoration: const InputDecoration( @@ -873,7 +901,7 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 16), // Due date (optional) - _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'), + const _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'), InkWell( onTap: _pickDueDate, child: InputDecorator( @@ -895,7 +923,7 @@ class _NewJobScreenState extends ConsumerState { const SizedBox(height: 16), // File attachments (optional) - _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'), + const _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'), _FilePicker( files: _pendingFiles, onAdd: () async { @@ -958,7 +986,9 @@ class _InlineInfoBanner extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded, + isWarning + ? Icons.warning_amber_rounded + : Icons.info_outline_rounded, size: 18, color: isWarning ? AppColors.pending : AppColors.textSecondary, ), @@ -995,12 +1025,18 @@ class _TeethBulkBar extends StatelessWidget { final VoidCallback onClear; bool _allUpperSelected() { - 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 + ]; return upper.every(selectedTeeth.contains); } bool _allLowerSelected() { - 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 + ]; return lower.every(selectedTeeth.contains); } @@ -1094,9 +1130,7 @@ class _BulkChip extends StatelessWidget { Text( label, style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color), + fontSize: 12, fontWeight: FontWeight.w600, color: color), ), ], ), @@ -1247,7 +1281,8 @@ class _FilePicker extends StatelessWidget { ), child: Row( children: [ - const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary), + const Icon(Icons.attach_file, + size: 16, color: AppColors.textSecondary), const SizedBox(width: 8), Expanded( child: Text( @@ -1265,7 +1300,8 @@ class _FilePicker extends StatelessWidget { const SizedBox(width: 4), GestureDetector( onTap: () => onRemove(i), - child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), + child: const Icon(Icons.close, + size: 16, color: AppColors.textSecondary), ), ], ), @@ -1334,21 +1370,30 @@ class _PricePreviewChip extends StatelessWidget { children: [ Text( '${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)), + style: TextStyle( + fontSize: 11, + color: AppColors.success.withValues(alpha: 0.75)), ), if (hasDiscount) Text( '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 Text( 'Liste fiyatı · İndirim yok', - style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), + style: TextStyle( + fontSize: 11, + color: AppColors.success.withValues(alpha: 0.75)), ), ], ), @@ -1381,18 +1426,28 @@ class _ProvaToggle extends StatelessWidget { const _ProvaToggle({ required this.value, required this.onChanged, + required this.optionalSteps, this.prostheticType, + this.workflowType, }); final bool value; final ValueChanged onChanged; final ProstheticType? prostheticType; + final JobWorkflowType? workflowType; + final List optionalSteps; @override Widget build(BuildContext context) { - final steps = prostheticType != null - ? jobStepTemplate(prostheticType!, value) - : []; + final preset = prostheticType != null + ? buildJobWorkflowPreset( + prostheticType: prostheticType!, + workflowType: workflowType, + provaRequired: value, + optionalSteps: optionalSteps, + ) + : null; + final steps = preset?.steps ?? []; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), @@ -1400,7 +1455,9 @@ class _ProvaToggle extends StatelessWidget { color: value ? AppColors.inProgressBg : AppColors.surfaceVariant, borderRadius: BorderRadius.circular(12), border: Border.all( - color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border, + color: value + ? AppColors.inProgress.withValues(alpha: 0.3) + : AppColors.border, ), ), child: Column( @@ -1422,14 +1479,17 @@ class _ProvaToggle extends StatelessWidget { value ? 'Provalı İş' : 'Provasız İş', style: TextStyle( fontWeight: FontWeight.w700, - color: value ? AppColors.inProgress : AppColors.textPrimary, + color: value + ? AppColors.inProgress + : AppColors.textPrimary, fontSize: 14, ), ), Text( - value - ? 'Lab her adımda klinik onayı bekler' - : 'Lab doğrudan üretip teslime gönderir', + preset?.title ?? + (value + ? 'Lab her adımda klinik onayı bekler' + : 'Lab doğrudan üretip teslime gönderir'), style: const TextStyle( fontSize: 12, color: AppColors.textSecondary), ), @@ -1439,27 +1499,41 @@ class _ProvaToggle extends StatelessWidget { Switch( value: value, onChanged: onChanged, - activeColor: AppColors.inProgress, + activeThumbColor: AppColors.inProgress, ), ], ), if (steps.isNotEmpty) ...[ const SizedBox(height: 8), + if (preset != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + preset.summary, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ), Wrap( spacing: 6, - children: steps.map((s) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: AppColors.border), - ), - child: Text( - s.label, - style: const TextStyle( - fontSize: 11, color: AppColors.textSecondary), - ), - )).toList(), + children: steps + .map((s) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.border), + ), + child: Text( + s.label, + style: const TextStyle( + fontSize: 11, color: AppColors.textSecondary), + ), + )) + .toList(), ), ], ], diff --git a/lib/features/clinic/settings/clinic_settings_screen.dart b/lib/features/clinic/settings/clinic_settings_screen.dart index f24c41d..3667229 100644 --- a/lib/features/clinic/settings/clinic_settings_screen.dart +++ b/lib/features/clinic/settings/clinic_settings_screen.dart @@ -9,7 +9,10 @@ import '../../../core/providers/locale_provider.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_theme.dart'; import '../../../models/tenant.dart'; +import '../../shared/location_completion_banner.dart'; import '../../shared/tenant_team_screen.dart'; +import '../../shared/location_picker_sheet.dart'; +import '../../shared/tenant_location_data.dart'; import '../connections/clinic_connections_screen.dart'; class ClinicSettingsScreen extends ConsumerWidget { @@ -29,6 +32,17 @@ class ClinicSettingsScreen extends ConsumerWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ + if (tenant?.hasLocation != true) ...[ + LocationCompletionBanner( + title: 'Konum eksik', + description: + 'Kliniğiniz harita tabanlı aramalarda doğru eşleşme için koordinat bilgisine ihtiyaç duyuyor.', + buttonLabel: 'Konumu Düzenle', + onTap: () => _showEditSheet(context, ref, tenant, s), + compact: true, + ), + const SizedBox(height: 20), + ], // User card _SectionHeader(title: s.userInfo), _UserCard(profile: profile), @@ -62,6 +76,13 @@ class ClinicSettingsScreen extends ConsumerWidget { label: s.role, value: _roleLabel(membership?.role, s), ), + _InfoTile( + icon: Icons.place_outlined, + label: 'Konum', + value: tenant?.locationLabel.isNotEmpty == true + ? tenant!.locationLabel + : '-', + ), ]), const SizedBox(height: 20), @@ -100,7 +121,9 @@ class ClinicSettingsScreen extends ConsumerWidget { onTap: () { ref.read(authProvider.notifier).setActiveTenant(m); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))), + SnackBar( + content: + Text(s.tenantSelected(m.tenant.companyName))), ); }, ), @@ -120,8 +143,7 @@ class ClinicSettingsScreen extends ConsumerWidget { subtitle: s.teamSub, onTap: () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const TenantTeamScreen()), + MaterialPageRoute(builder: (_) => const TenantTeamScreen()), ), ), _NavTile( @@ -140,6 +162,14 @@ class ClinicSettingsScreen extends ConsumerWidget { subtitle: s.aiAssistantSub, onTap: () => context.push(routeClinicAi), ), + _NavTile( + icon: Icons.workspace_premium_outlined, + iconColor: AppColors.primary, + iconBg: const Color(0xFFEFF6FF), + title: 'Paketler ve AI Kredileri', + subtitle: 'Trial ve paket görünümünü incele', + onTap: () => context.push(routeWelcome), + ), ]), const SizedBox(height: 20), ], @@ -152,7 +182,8 @@ class ClinicSettingsScreen extends ConsumerWidget { iconColor: AppColors.accent, iconBg: AppColors.inProgressBg, title: s.appLanguage, - subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s), + subtitle: _currentLanguageLabel( + ref.watch(localeProvider).languageCode, s), onTap: () => _showLanguagePicker(context, ref, s), ), ]), @@ -176,7 +207,8 @@ class ClinicSettingsScreen extends ConsumerWidget { ); } - void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) { + void _showEditSheet( + BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) { if (tenant == null) return; showModalBottomSheet( context: context, @@ -185,10 +217,12 @@ class ClinicSettingsScreen extends ConsumerWidget { builder: (_) => _EditTenantSheet( tenant: tenant, s: s, - onSave: (name) async { - await ref - .read(authProvider.notifier) - .updateTenantInfo(tenantId: tenant.id, companyName: name); + onSave: (name, location) async { + await ref.read(authProvider.notifier).updateTenantInfo( + tenantId: tenant.id, + companyName: name, + location: location, + ); }, ), ); @@ -202,7 +236,8 @@ class ClinicSettingsScreen extends ConsumerWidget { ); } - static String _currentLanguageLabel(String code, AppStrings s) => switch (code) { + static String _currentLanguageLabel(String code, AppStrings s) => + switch (code) { 'en' => s.languageEnglish, 'ru' => s.languageRussian, 'ar' => s.languageArabic, @@ -316,7 +351,10 @@ class _EditTenantSheet extends StatefulWidget { }); final Tenant tenant; final AppStrings s; - final Future Function(String companyName) onSave; + final Future Function( + String companyName, + TenantLocationData location, + ) onSave; @override State<_EditTenantSheet> createState() => _EditTenantSheetState(); @@ -324,32 +362,49 @@ class _EditTenantSheet extends StatefulWidget { class _EditTenantSheetState extends State<_EditTenantSheet> { late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final TextEditingController _cityController; + late final TextEditingController _districtController; + late TenantLocationData _location; bool _saving = false; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.tenant.companyName); + _location = TenantLocationData.fromTenant(widget.tenant); + _addressController = TextEditingController(text: _location.address ?? ''); + _cityController = TextEditingController(text: _location.city ?? ''); + _districtController = TextEditingController(text: _location.district ?? ''); } @override void dispose() { _nameController.dispose(); + _addressController.dispose(); + _cityController.dispose(); + _districtController.dispose(); super.dispose(); } Future _submit() async { final name = _nameController.text.trim(); if (name.isEmpty) return; + final location = _location.copyWith( + address: _addressController.text.trim(), + city: _cityController.text.trim(), + district: _districtController.text.trim(), + ); + if (!location.hasDetails) return; setState(() => _saving = true); final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); try { - await widget.onSave(name); + await widget.onSave(name, location); navigator.pop(); } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); + messenger + .showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); } finally { if (mounted) setState(() => _saving = false); } @@ -395,13 +450,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> { ), textCapitalization: TextCapitalization.words, ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Konum', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + _location.fullLabel.isNotEmpty + ? _location.fullLabel + : 'Henüz konum veya adres bilgisi girilmedi.', + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: () async { + final picked = await showLocationPickerSheet( + context, + initialLocation: _location, + title: 'Klinik Konumu', + ); + if (picked != null) { + setState(() => _location = picked); + } + }, + icon: const Icon(Icons.map_outlined), + label: const Text('Haritadan Konum Seç'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _addressController, + decoration: const InputDecoration( + labelText: 'Açık Adres', + hintText: 'Cadde, sokak, mahalle bilgisi', + ), + maxLines: 2, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _cityController, + decoration: const InputDecoration( + labelText: 'Şehir', + ), + textCapitalization: TextCapitalization.words, + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: _districtController, + decoration: const InputDecoration( + labelText: 'İlçe', + ), + textCapitalization: TextCapitalization.words, + ), + ), + ], + ), + ], + ), + ), const SizedBox(height: 20), if (_saving) const Center( child: CircularProgressIndicator(color: AppColors.accent)) else FilledButton( - onPressed: _submit, + onPressed: _saving ? null : _submit, style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 48)), child: Text(s.save), @@ -534,7 +667,10 @@ class _InfoCard extends StatelessWidget { children[i], if (i < children.length - 1) const Divider( - height: 1, indent: 16, endIndent: 16, color: AppColors.border), + height: 1, + indent: 16, + endIndent: 16, + color: AppColors.border), ], ], ), @@ -599,8 +735,7 @@ class _NavTile extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), leading: Container( width: 36, height: 36, @@ -615,8 +750,7 @@ class _NavTile extends StatelessWidget { ? Text(subtitle!, style: const TextStyle(color: AppColors.textSecondary)) : null, - trailing: - const Icon(Icons.chevron_right, color: AppColors.textSecondary), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), onTap: onTap, ); } @@ -642,16 +776,14 @@ class _SignOutCard extends StatelessWidget { ], ), child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), leading: Container( width: 36, height: 36, decoration: BoxDecoration( color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(9)), - child: const Icon(Icons.logout, - color: AppColors.cancelled, size: 18), + child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18), ), title: Text(s.signOut, style: const TextStyle( diff --git a/lib/features/lab/dashboard/lab_dashboard_screen.dart b/lib/features/lab/dashboard/lab_dashboard_screen.dart index 16e390d..fef791d 100644 --- a/lib/features/lab/dashboard/lab_dashboard_screen.dart +++ b/lib/features/lab/dashboard/lab_dashboard_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; @@ -9,6 +10,7 @@ import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/tooth_logo.dart'; import '../../../core/services/realtime_service.dart'; import '../../../models/job.dart'; +import '../../shared/location_completion_banner.dart'; import '../jobs/lab_jobs_repository.dart'; class LabDashboardScreen extends ConsumerStatefulWidget { @@ -20,27 +22,47 @@ class LabDashboardScreen extends ConsumerStatefulWidget { class _LabDashboardScreenState extends ConsumerState { late Future<_DashboardData> _future; bool _acceptingAll = false; - late UnsubFn _unsub; + UnsubFn? _unsub; + Timer? _reloadDebounce; + String? _subscribedTenantId; @override void initState() { super.initState(); _load(); - final tenantId = ref.read(authProvider).activeTenant!.tenant.id; - _unsub = RealtimeService.instance.watch( - 'jobs', - filter: "lab_tenant_id='$tenantId'", - onEvent: (_) { if (mounted) _load(); }, - ); + _ensureRealtimeSubscription(); } @override void dispose() { - _unsub(); + _reloadDebounce?.cancel(); + _unsub?.call(); super.dispose(); } + void _ensureRealtimeSubscription() { + final tenantId = ref.read(authProvider).activeTenant?.tenant.id; + if (tenantId == null || tenantId == _subscribedTenantId) return; + _unsub?.call(); + _subscribedTenantId = tenantId; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: "lab_tenant_id='$tenantId'", + onEvent: (_) { + _scheduleReload(); + }, + ); + } + + void _scheduleReload() { + _reloadDebounce?.cancel(); + _reloadDebounce = Timer(const Duration(milliseconds: 250), () { + if (mounted) _load(); + }); + } + void _load() { + _ensureRealtimeSubscription(); final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final now = DateTime.now(); final thisMonthStart = DateTime(now.year, now.month, 1); @@ -50,13 +72,19 @@ class _LabDashboardScreenState extends ConsumerState { Future.wait>([ LabJobsRepository.instance.listInbound(tenantId, status: 'pending'), LabJobsRepository.instance.listInProgress(tenantId), - LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'), - LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'), - LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200), - LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200), + LabJobsRepository.instance + .listInProgress(tenantId, location: 'at_lab'), + LabJobsRepository.instance + .listInProgress(tenantId, location: 'at_clinic'), + LabJobsRepository.instance + .listInbound(tenantId, status: 'sent', limit: 200), + LabJobsRepository.instance + .listInbound(tenantId, status: 'delivered', limit: 200), ]), - LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart), - LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart), + LabJobsRepository.instance + .countDelivered(tenantId, from: thisMonthStart), + LabJobsRepository.instance + .countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart), ]).then((r) { final jobs = r[0] as List>; return _DashboardData( @@ -82,7 +110,8 @@ class _LabDashboardScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating), + SnackBar( + content: Text('Hata: $e'), behavior: SnackBarBehavior.floating), ); } } finally { @@ -92,7 +121,10 @@ class _LabDashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? ''; + _ensureRealtimeSubscription(); + final activeTenant = ref.watch(authProvider).activeTenant?.tenant; + final companyName = activeTenant?.companyName ?? ''; + final showLocationWarning = activeTenant?.hasLocation != true; return Scaffold( backgroundColor: AppColors.background, body: LayoutBuilder( @@ -109,14 +141,29 @@ class _LabDashboardScreenState extends ConsumerState { future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { - return _DashboardSkeleton(companyName: companyName, hPad: hPad); + return _DashboardSkeleton( + companyName: companyName, hPad: hPad); } if (snap.hasError) return _ErrorBody(onRetry: _load); final data = snap.data!; - final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; return CustomScrollView( slivers: [ _DashboardHeader(companyName: companyName), + if (showLocationWarning) + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0), + sliver: SliverToBoxAdapter( + child: LocationCompletionBanner( + title: 'Konum kaydı eksik', + description: + 'Haritada görünmek ve kliniklerin sizi yakın laboratuvar olarak bulabilmesi için konumunuzu tamamlayın.', + buttonLabel: 'Konumu Tamamla', + onTap: () => context.go(routeLabSettings), + ), + ), + ), if (isDesktop) SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), @@ -137,7 +184,10 @@ class _LabDashboardScreenState extends ConsumerState { count: data.pendingJobs.length, loading: _acceptingAll, onTap: _bulkAccept, - ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0), + ) + .animate() + .fadeIn(duration: 300.ms) + .slideY(begin: 0.1, end: 0), ), ), if (isDesktop) ...[ @@ -145,14 +195,18 @@ class _LabDashboardScreenState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), sliver: SliverToBoxAdapter( child: _MonthlyReportSection(data: data) - .animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0), + .animate() + .fadeIn(duration: 300.ms) + .slideY(begin: 0.08, end: 0), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), sliver: SliverToBoxAdapter( child: _GamificationRow(data: data) - .animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0), + .animate() + .fadeIn(duration: 300.ms, delay: 60.ms) + .slideY(begin: 0.08, end: 0), ), ), ], @@ -163,10 +217,14 @@ class _LabDashboardScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium), + Text('Yapılacaklar', + style: Theme.of(context).textTheme.titleMedium), TextButton( onPressed: () => context.go(routeLabJobsAll), - style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric( + horizontal: 8)), child: const Text('Tümünü Gör'), ), ], @@ -185,11 +243,13 @@ class _LabDashboardScreenState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), sliver: SliverList.separated( itemCount: data.atLabJobs.take(5).length, - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i]) - .animate(delay: (i * 60).ms) - .fadeIn(duration: 300.ms) - .slideY(begin: 0.12, end: 0), + separatorBuilder: (_, __) => + const SizedBox(height: 10), + itemBuilder: (ctx, i) => + _JobCard(job: data.atLabJobs[i]) + .animate(delay: (i * 60).ms) + .fadeIn(duration: 300.ms) + .slideY(begin: 0.12, end: 0), ), ), // ── Klinikte Onay Bekliyor (at_clinic) ─────────────── @@ -197,18 +257,21 @@ class _LabDashboardScreenState extends ConsumerState { SliverPadding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), sliver: SliverToBoxAdapter( - child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium), + child: Text('Klinikte Onay Bekliyor', + style: Theme.of(context).textTheme.titleMedium), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), sliver: SliverList.separated( itemCount: data.atClinicJobs.take(5).length, - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i]) - .animate(delay: (i * 60).ms) - .fadeIn(duration: 300.ms) - .slideY(begin: 0.12, end: 0), + separatorBuilder: (_, __) => + const SizedBox(height: 10), + itemBuilder: (ctx, i) => + _JobCard(job: data.atClinicJobs[i]) + .animate(delay: (i * 60).ms) + .fadeIn(duration: 300.ms) + .slideY(begin: 0.12, end: 0), ), ), ], @@ -233,7 +296,8 @@ class _DashboardHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; if (isDesktop) { return SliverAppBar( @@ -252,15 +316,24 @@ class _DashboardHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)), - const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + Text('Genel Bakış', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary.withValues(alpha: 0.8), + letterSpacing: 0.3)), + const Text('Bugünkü Durum', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary)), ], ), ), actions: [ IconButton( onPressed: () => context.go(routeLabSettings), - icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22), + icon: const Icon(Icons.settings_outlined, + color: AppColors.textSecondary, size: 22), ), const SizedBox(width: 8), ], @@ -291,14 +364,26 @@ class _DashboardHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)), - Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis), + Text('DLS', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.5)), + Text(companyName, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis), ], ), actions: [ IconButton( onPressed: () => context.go(routeLabSettings), - icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22), + icon: const Icon(Icons.settings_outlined, + color: Colors.white, size: 22), ), ], flexibleSpace: FlexibleSpaceBar( @@ -317,9 +402,19 @@ class _DashboardHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.end, children: [ - Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)), + Text('Genel Bakış', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5)), const SizedBox(height: 4), - const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)), + const Text('Bugünkü Durum', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: -0.5)), ], ), ), @@ -343,18 +438,47 @@ class _StatsRow extends StatelessWidget { @override Widget build(BuildContext context) { - final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint; + final isWideDesktop = + MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint; - final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg) - .animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); - final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg) - .animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final pendingCard = _StatCard( + label: 'Bekleyen', + value: '$pending', + icon: Icons.hourglass_top_rounded, + color: AppColors.pending, + bgColor: AppColors.pendingBg) + .animate() + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); + final inProgressCard = _StatCard( + label: 'Devam Eden', + value: '$inProgress', + icon: Icons.autorenew_rounded, + color: AppColors.inProgress, + bgColor: AppColors.inProgressBg) + .animate(delay: 80.ms) + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); if (isWideDesktop) { - final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg) - .animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); - final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg) - .animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final sentCard = _StatCard( + label: 'Klinik\'te', + value: '$sent', + icon: Icons.local_hospital_outlined, + color: AppColors.accent, + bgColor: AppColors.inProgressBg) + .animate(delay: 120.ms) + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); + final deliveredCard = _StatCard( + label: 'Tamamlanan', + value: '$delivered', + icon: Icons.task_alt, + color: AppColors.success, + bgColor: AppColors.successBg) + .animate(delay: 160.ms) + .fadeIn(duration: 350.ms) + .slideY(begin: 0.2, end: 0); return Row( children: [ @@ -380,7 +504,12 @@ class _StatsRow extends StatelessWidget { } class _StatCard extends StatelessWidget { - const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor}); + const _StatCard( + {required this.label, + required this.value, + required this.icon, + required this.color, + required this.bgColor}); final String label; final String value; final IconData icon; @@ -394,22 +523,38 @@ class _StatCard extends StatelessWidget { color: AppColors.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4))], + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4)) + ], ), child: Row( children: [ Container( - width: 44, height: 44, - decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)), + width: 44, + height: 44, + decoration: BoxDecoration( + color: bgColor, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: color, size: 22), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: color, height: 1)), + Text(value, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: color, + height: 1)), const SizedBox(height: 3), - Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + Text(label, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500)), ], ), ], @@ -419,7 +564,8 @@ class _StatCard extends StatelessWidget { } class _AcceptAllBanner extends StatelessWidget { - const _AcceptAllBanner({required this.count, required this.loading, required this.onTap}); + const _AcceptAllBanner( + {required this.count, required this.loading, required this.onTap}); final int count; final bool loading; final VoidCallback onTap; @@ -433,30 +579,54 @@ class _AcceptAllBanner extends StatelessWidget { borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: + Border.all(color: AppColors.pending.withValues(alpha: 0.35))), child: Row( children: [ Container( - width: 38, height: 38, - decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)), - child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18), + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.pending.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10)), + child: const Icon(Icons.notifications_active_outlined, + color: AppColors.pending, size: 18), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), - const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + Text('$count yeni iş bekliyor', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const Text('Tümünü hızlıca kabul et', + style: TextStyle( + fontSize: 12, color: AppColors.textSecondary)), ], ), ), loading - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending)) + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: AppColors.pending)) : Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)), - child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: AppColors.pending, + borderRadius: BorderRadius.circular(8)), + child: const Text('Kabul Et', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600)), ), ], ), @@ -473,62 +643,97 @@ class _JobCard extends StatelessWidget { Widget build(BuildContext context) { final due = job.dueDate; final isOverdue = due != null && due.isBefore(DateTime.now()); - final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null; + final dueText = due != null + ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' + : null; return Semantics( label: job.patientCode, button: true, excludeSemantics: true, child: Material( - color: AppColors.surface, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: () => context.push('/lab/jobs/${job.id}'), + color: AppColors.surface, borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)), - child: Row( - children: [ - Container( - width: 46, height: 46, - decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)), - child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), - const SizedBox(height: 2), - Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), - const SizedBox(height: 6), - Wrap( - spacing: 6, - children: [ - _Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg), - if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg), - ], - ), - ], + child: InkWell( + onTap: () => context.push('/lab/jobs/${job.id}'), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border)), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(12)), + child: Center( + child: Text('${job.memberCount}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.inProgress))), ), - ), - if (dueText != null) ...[ - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted), - const SizedBox(height: 3), - Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)), - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.patientCode, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(job.clinicName ?? 'Klinik', + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + children: [ + _Tag( + label: job.prostheticType.label, + color: AppColors.inProgress, + bg: AppColors.inProgressBg), + if (job.currentStep != null) + _Tag( + label: job.currentStep!.label, + color: AppColors.success, + bg: AppColors.successBg), + ], + ), + ], + ), ), + if (dueText != null) ...[ + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon(Icons.calendar_today_outlined, + size: 13, + color: isOverdue + ? AppColors.cancelled + : AppColors.textMuted), + const SizedBox(height: 3), + Text(dueText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isOverdue + ? AppColors.cancelled + : AppColors.textSecondary)), + ], + ), + ], ], - ], + ), ), ), ), - ), ); } } @@ -542,13 +747,15 @@ class _Tag extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), - child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + child: Text(label, + style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w600, color: color)), ); } } - class _EmptySection extends StatelessWidget { const _EmptySection({required this.message}); final String message; @@ -563,9 +770,12 @@ class _EmptySection extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20), + Icon(Icons.check_circle_outline_rounded, + color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20), const SizedBox(width: 10), - Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), + Text(message, + style: const TextStyle( + fontSize: 14, color: AppColors.textSecondary)), ], ), ); @@ -584,14 +794,25 @@ class _ErrorBody extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - width: 64, height: 64, - decoration: BoxDecoration(color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(16)), - child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30), + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), ), const SizedBox(height: 16), - const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const Text('Bağlantı hatası', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), const SizedBox(height: 12), - FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene')), ], ), ), @@ -623,7 +844,9 @@ class _DashboardSkeleton extends StatelessWidget { padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0), sliver: SliverList.builder( itemCount: 4, - itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)), + itemBuilder: (_, i) => const Padding( + padding: EdgeInsets.only(bottom: 10), + child: _ShimmerBox(height: 92, radius: 14)), ), ), ], @@ -639,24 +862,35 @@ class _ShimmerBox extends StatefulWidget { State<_ShimmerBox> createState() => _ShimmerBoxState(); } -class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin { +class _ShimmerBoxState extends State<_ShimmerBox> + with SingleTickerProviderStateMixin { late AnimationController _ctrl; late Animation _anim; @override void initState() { super.initState(); - _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true); + _ctrl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1100)) + ..repeat(reverse: true); _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut); } + @override - void dispose() { _ctrl.dispose(); super.dispose(); } + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _anim, builder: (_, __) => Container( height: widget.height, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.radius), + color: Color.lerp( + const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)), ), ); } @@ -685,9 +919,14 @@ class _MonthlyReportSection extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent), + const Icon(Icons.bar_chart_rounded, + size: 18, color: AppColors.accent), const SizedBox(width: 6), - Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text('Aylık Rapor', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w600)), ], ), const SizedBox(height: 12), @@ -710,7 +949,8 @@ class _MonthlyReportSection extends StatelessWidget { ), const SizedBox(width: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: isUp ? AppColors.successBg : AppColors.cancelledBg, borderRadius: BorderRadius.circular(8), @@ -719,7 +959,9 @@ class _MonthlyReportSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, + isUp + ? Icons.trending_up_rounded + : Icons.trending_down_rounded, size: 16, color: isUp ? AppColors.success : AppColors.cancelled, ), @@ -744,7 +986,8 @@ class _MonthlyReportSection extends StatelessWidget { } class _MonthStat extends StatelessWidget { - const _MonthStat({required this.label, required this.value, required this.highlighted}); + const _MonthStat( + {required this.label, required this.value, required this.highlighted}); final String label; final int value; final bool highlighted; @@ -754,14 +997,22 @@ class _MonthStat extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background, + color: highlighted + ? AppColors.accent.withValues(alpha: 0.06) + : AppColors.background, borderRadius: BorderRadius.circular(8), - border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null, + border: highlighted + ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + Text(label, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500)), const SizedBox(height: 2), Text( '$value iş', @@ -788,7 +1039,8 @@ class _GamificationRow extends StatelessWidget { @override Widget build(BuildContext context) { final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0); - final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal); + final remaining = + (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -803,7 +1055,11 @@ class _GamificationRow extends StatelessWidget { children: [ const Text('🏆', style: TextStyle(fontSize: 16)), const SizedBox(width: 6), - Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text('Aylık Hedef', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w600)), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), @@ -813,7 +1069,10 @@ class _GamificationRow extends StatelessWidget { ), child: Text( '${data.points} puan', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary), ), ), ], @@ -836,14 +1095,17 @@ class _GamificationRow extends StatelessWidget { children: [ Text( '${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), ), Text( progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary, + color: progress >= 1.0 + ? AppColors.success + : AppColors.textSecondary, ), ), ], diff --git a/lib/features/lab/finance/lab_finance_repository.dart b/lib/features/lab/finance/lab_finance_repository.dart index 8c53464..a1f89df 100644 --- a/lib/features/lab/finance/lab_finance_repository.dart +++ b/lib/features/lab/finance/lab_finance_repository.dart @@ -1,5 +1,6 @@ import 'package:pocketbase/pocketbase.dart'; import '../../../core/api/pocketbase_client.dart'; +import '../../../core/services/finance_service.dart'; import '../../../models/finance_entry.dart'; class LabFinanceRepository { @@ -15,14 +16,18 @@ class LabFinanceRepository { int limit = 30, }) async { final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"']; - if (status != null) filterParts.add('status = "$status"'); + if (status == FinanceStatus.pending.value) { + filterParts.add('(status = "pending" || status = "reported")'); + } else if (status != null) { + filterParts.add('status = "$status"'); + } final result = await _pb.collection('finance_entries').getList( - page: page, - perPage: limit, - filter: filterParts.join(' && '), - expand: 'job_id', - ); + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: 'job_id', + ); return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList() ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); } @@ -31,7 +36,7 @@ class LabFinanceRepository { final all = await listEntries(tenantId, limit: 200); double pending = 0, paid = 0; for (final e in all) { - if (e.status == FinanceStatus.pending) { + if (e.status.isOpen) { pending += e.amount; } else { paid += e.amount; @@ -40,15 +45,17 @@ class LabFinanceRepository { return {'pending': pending, 'paid': paid}; } - Future> byCounterparty(String tenantId) async { + Future> byCounterparty( + String tenantId) async { final entries = await listEntries(tenantId, limit: 300); final map = {}; for (final entry in entries) { - final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown'; + final key = + entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown'; final current = map[key]; final pending = (current?.pendingAmount ?? 0) + - (entry.status == FinanceStatus.pending ? entry.amount : 0); + (entry.status.isOpen ? entry.amount : 0); final paid = (current?.paidAmount ?? 0) + (entry.status == FinanceStatus.paid ? entry.amount : 0); map[key] = CounterpartyFinanceSummary( @@ -65,4 +72,17 @@ class LabFinanceRepository { list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount)); return list; } + + Future confirmPayment(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: { + 'status': 'paid', + 'paid_at': DateTime.now().toIso8601String(), + }); + return; + } + await FinanceService.instance.confirmJobPayment(jobId); + } } diff --git a/lib/features/lab/finance/lab_finance_screen.dart b/lib/features/lab/finance/lab_finance_screen.dart index 23b6eb7..3abd534 100644 --- a/lib/features/lab/finance/lab_finance_screen.dart +++ b/lib/features/lab/finance/lab_finance_screen.dart @@ -76,8 +76,10 @@ class _LabFinanceScreenState extends ConsumerState switch (_sort) { case _FinanceSort.newestFirst: list.sort((a, b) { - final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null; - final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null; + final da = + a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null; + final db = + b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null; if (da == null && db == null) return 0; if (da == null) return 1; if (db == null) return -1; @@ -101,6 +103,49 @@ class _LabFinanceScreenState extends ConsumerState } } + Future _confirmPayment( + FinanceEntry entry, + String Function(double) formatAmount, + ) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ödeme Onayla'), + content: Text( + '${entry.counterpartyName ?? "Bu kayıt"} için ' + '${formatAmount(entry.amount)} tutarındaki ödemenin ' + 'hesabınıza ulaştığını onaylıyor musunuz?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Onayla'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await LabFinanceRepository.instance.confirmPayment(entry.id); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ödeme onaylandı.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + @override Widget build(BuildContext context) { final isSortActive = _sort != _FinanceSort.newestFirst; @@ -154,8 +199,7 @@ class _LabFinanceScreenState extends ConsumerState ), const SizedBox(height: 16), Text('Hata: ${snap.error}', - style: const TextStyle( - color: AppColors.textSecondary)), + style: const TextStyle(color: AppColors.textSecondary)), const SizedBox(height: 12), FilledButton.icon( onPressed: _load, @@ -181,7 +225,7 @@ class _LabFinanceScreenState extends ConsumerState children: [ Expanded( child: _SummaryCard( - label: s.pendingReceivable, + label: 'Açık Alacak', amount: formatAmount(pendingTotal), color: AppColors.pending, bgColor: AppColors.pendingBg, @@ -191,7 +235,7 @@ class _LabFinanceScreenState extends ConsumerState const SizedBox(width: 12), Expanded( child: _SummaryCard( - label: s.collected, + label: 'Onaylanan Tahsilat', amount: formatAmount(paidTotal), color: AppColors.success, bgColor: AppColors.successBg, @@ -226,6 +270,8 @@ class _LabFinanceScreenState extends ConsumerState emptyIcon: Icons.hourglass_empty_rounded, formatDate: _formatDate, formatAmount: formatAmount, + onConfirmPayment: (entry) => + _confirmPayment(entry, formatAmount), ), _EntriesList( entries: paid, @@ -334,6 +380,7 @@ class _EntriesList extends StatelessWidget { required this.emptyIcon, required this.formatDate, required this.formatAmount, + this.onConfirmPayment, }); final List entries; @@ -341,6 +388,7 @@ class _EntriesList extends StatelessWidget { final IconData emptyIcon; final String Function(String?) formatDate; final String Function(double) formatAmount; + final Future Function(FinanceEntry entry)? onConfirmPayment; @override Widget build(BuildContext context) { @@ -374,103 +422,139 @@ class _EntriesList extends StatelessWidget { itemBuilder: (ctx, i) { final entry = entries[i]; final isPending = entry.status == FinanceStatus.pending; - final statusColor = isPending ? AppColors.pending : AppColors.success; - final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg; + final isReported = entry.status == FinanceStatus.reported; + final statusColor = isPending + ? AppColors.pending + : isReported + ? AppColors.accent + : AppColors.success; + final statusBg = isPending + ? AppColors.pendingBg + : isReported + ? AppColors.inProgressBg + : AppColors.successBg; return Padding( padding: const EdgeInsets.only(bottom: 10), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.surface, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: isReported && onConfirmPayment != null + ? () => onConfirmPayment!(entry) + : null, borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2)) - ], - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: statusBg, - borderRadius: BorderRadius.circular(12)), - child: Icon( - isPending - ? Icons.hourglass_empty_rounded - : Icons.check_circle_outline, - color: statusColor, - size: 22, - ), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ], ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.counterpartyName ?? 'Klinik', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), - ), - if (entry.patientCode != null) ...[ - const SizedBox(height: 2), - Text( - 'Protokol: ${entry.patientCode}', - style: const TextStyle( - fontSize: 12, color: AppColors.textSecondary), - ), - ], - if (entry.dateCreated != null) ...[ - const SizedBox(height: 2), - Text( - formatDate(entry.dateCreated), - style: const TextStyle( - fontSize: 12, color: AppColors.textMuted), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, + child: Row( children: [ - Text( - formatAmount(entry.amount), - style: TextStyle( - fontWeight: FontWeight.w700, + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon( + isPending + ? Icons.hourglass_empty_rounded + : isReported + ? Icons.verified_outlined + : Icons.check_circle_outline, color: statusColor, - fontSize: 15, + size: 22, ), ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: statusBg, - borderRadius: BorderRadius.circular(8), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.counterpartyName ?? 'Klinik', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + if (entry.patientCode != null) ...[ + const SizedBox(height: 2), + Text( + 'Protokol: ${entry.patientCode}', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + ), + ], + if (entry.dateCreated != null) ...[ + const SizedBox(height: 2), + Text( + formatDate(entry.dateCreated), + style: const TextStyle( + fontSize: 12, color: AppColors.textMuted), + ), + ], + if (isReported) ...[ + const SizedBox(height: 4), + const Text( + 'Dokunarak ödeme onayı verebilirsiniz.', + style: TextStyle( + fontSize: 12, color: AppColors.textSecondary), + ), + ] else if (isPending) ...[ + const SizedBox(height: 4), + const Text( + 'Klinikten ödeme bildirimi bekleniyor.', + style: TextStyle( + fontSize: 12, color: AppColors.textSecondary), + ), + ], + ], ), - child: Text( - entry.status.label, - style: TextStyle( - color: statusColor, - fontSize: 11, - fontWeight: FontWeight.w600, + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + formatAmount(entry.amount), + style: TextStyle( + fontWeight: FontWeight.w700, + color: statusColor, + fontSize: 15, + ), ), - ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + entry.status.label, + style: TextStyle( + color: statusColor, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ], ), - ], + ), ), ), ); diff --git a/lib/features/lab/jobs/lab_job_detail_screen.dart b/lib/features/lab/jobs/lab_job_detail_screen.dart index 74b86ee..c494eeb 100644 --- a/lib/features/lab/jobs/lab_job_detail_screen.dart +++ b/lib/features/lab/jobs/lab_job_detail_screen.dart @@ -14,13 +14,13 @@ import 'lab_jobs_repository.dart'; // ── Adaptive sheet helper ──────────────────────────────────────────────────── void _showAdaptive(BuildContext context, Widget content) { - final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; if (isDesktop) { showDialog( context: context, builder: (_) => Dialog( - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: content, @@ -51,33 +51,66 @@ class _LabJobDetailScreenState extends ConsumerState { String? _loadError; bool _isActing = false; late Future> _filesFuture; - late UnsubFn _unsub; + late Future> _historyFuture; + final List _unsubs = []; @override void initState() { super.initState(); _load(); _loadFiles(); - _unsub = RealtimeService.instance.watch( + _loadHistory(); + _unsubs.add(RealtimeService.instance.watch( 'jobs', topic: widget.jobId, - onEvent: (_) { if (mounted && !_isActing) _load(); }, - ); + onEvent: (_) { + if (mounted && !_isActing) _load(); + }, + )); + _unsubs.add(RealtimeService.instance.watch( + 'job_files', + filter: 'job_id="${widget.jobId}"', + onEvent: (_) { + if (mounted) _loadFiles(); + }, + )); + _unsubs.add(RealtimeService.instance.watch( + 'job_status_history', + filter: 'job_id="${widget.jobId}"', + onEvent: (_) { + if (mounted) _loadHistory(); + }, + )); } @override void dispose() { - _unsub(); + for (final unsub in _unsubs) { + unsub(); + } super.dispose(); } Future _load() async { - setState(() { _loadingJob = true; _loadError = null; }); + setState(() { + _loadingJob = true; + _loadError = null; + }); try { final job = await LabJobsRepository.instance.getJob(widget.jobId); - if (mounted) setState(() { _job = job; _loadingJob = false; }); + if (mounted) { + setState(() { + _job = job; + _loadingJob = false; + }); + } } catch (e) { - if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; }); + if (mounted) { + setState(() { + _loadError = e.toString(); + _loadingJob = false; + }); + } } } @@ -87,14 +120,23 @@ class _LabJobDetailScreenState extends ConsumerState { }); } + void _loadHistory() { + setState(() { + _historyFuture = JobHistoryService.instance.listForJob(widget.jobId); + }); + } + Future _cancelJob(Job job) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('İşi İptal Et'), - content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'), + content: const Text( + 'Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Vazgeç')), FilledButton( style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), onPressed: () => Navigator.pop(ctx, true), @@ -108,13 +150,18 @@ class _LabJobDetailScreenState extends ConsumerState { try { final updated = await LabJobsRepository.instance.cancelJob(job.id, job); if (mounted) { - setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); + setState(() { + _job = _job!.copyWith(status: updated.status); + _isActing = false; + }); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); } } catch (e) { if (mounted) { setState(() => _isActing = false); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); } } } @@ -124,7 +171,11 @@ class _LabJobDetailScreenState extends ConsumerState { try { final updated = await LabJobsRepository.instance.acceptJob(job); if (mounted) { - setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + setState(() { + _job = updated.copyWith( + clinicName: job.clinicName, labName: job.labName); + _isActing = false; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('İş kabul edildi')), ); @@ -132,7 +183,8 @@ class _LabJobDetailScreenState extends ConsumerState { } catch (e) { if (mounted) { setState(() => _isActing = false); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); } } } @@ -143,7 +195,10 @@ class _LabJobDetailScreenState extends ConsumerState { _HandToClinicSheet( job: job, onDone: (Job updated) { - if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName)); + if (mounted) { + setState(() => _job = updated.copyWith( + clinicName: job.clinicName, labName: job.labName)); + } }, ), ); @@ -170,7 +225,8 @@ class _LabJobDetailScreenState extends ConsumerState { } String _formatDate(DateTime dt, {bool withTime = false}) { - final d = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; + final d = + '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d; return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } @@ -234,260 +290,261 @@ class _LabJobDetailScreenState extends ConsumerState { job.location == JobLocation.atLab; final canAccept = !isDeliveryOnly && job.status == JobStatus.pending; - return ListView( + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Header card + Container( padding: const EdgeInsets.all(16), - children: [ - // Header card - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 12, - offset: const Offset(0, 4)) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - job.patientName?.isNotEmpty == true - ? job.patientName! - : job.patientCode, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.bold, - color: AppColors.textPrimary), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: _statusBg(job.status), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - job.status.label, - style: TextStyle( - color: _statusColor(job.status), - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - _InfoRow( - icon: Icons.business, - label: 'Klinik', - value: job.clinicName ?? '-'), - if (job.patientName != null && - job.patientName!.isNotEmpty) - _InfoRow( - icon: Icons.person_outline, - label: 'Hasta', - value: job.patientName!, - ), - _InfoRow( - icon: Icons.tag_outlined, - label: 'Protokol No', - value: job.patientCode, - ), - _InfoRow( - icon: Icons.medical_services_outlined, - label: 'Protez Tipi', - value: job.prostheticType.label), - if (job.prostheticName != null && - job.prostheticName!.isNotEmpty) - _InfoRow( - icon: Icons.category_outlined, - label: 'Ürün', - value: job.prostheticName!, - ), - if (job.workflowType != null) - _InfoRow( - icon: Icons.tune_rounded, - label: 'İş Tipi', - value: job.workflowType!.label, - ), - _InfoRow( - icon: Icons.fact_check_outlined, - label: 'Prova', - value: job.provaRequired ? 'Provalı' : 'Provasız', - ), - _InfoRow( - icon: Icons.format_list_numbered, - label: 'Üye Sayısı', - value: '${job.memberCount} üye'), - if (job.color != null) - _InfoRow( - icon: Icons.color_lens_outlined, - label: 'Renk', - value: job.color!), - if (job.dueDate != null) - _InfoRow( - icon: Icons.calendar_today, - label: 'Teslim Tarihi', - value: _formatDate(job.dueDate!, withTime: true), - valueColor: job.dueDate!.isBefore(DateTime.now()) - ? AppColors.cancelled - : null), - _InfoRow( - icon: Icons.add_circle_outline, - label: 'Oluşturulma', - value: _formatDate(job.dateCreated)), - if (job.price != null && job.currency != null) - _InfoRow( - icon: Icons.attach_money, - label: 'Fiyat', - value: - '${job.price!.toStringAsFixed(2)} ${job.currency}'), - if (job.description != null && - job.description!.isNotEmpty) - _InfoRow( - icon: Icons.notes, - label: 'Açıklama', - value: job.description!), - ], - ), - ), - - const SizedBox(height: 16), - - // Stepper - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 12, - offset: const Offset(0, 4)) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'İş Adımları', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600, - color: AppColors.textPrimary), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: job.provaRequired - ? AppColors.inProgressBg - : AppColors.successBg, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - job.provaRequired ? 'Provalı' : 'Provasız', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: job.provaRequired - ? AppColors.inProgress - : AppColors.success, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - _JobStepper( - steps: job.stepTemplate, - currentStep: job.currentStep, - historyFuture: JobHistoryService.instance - .listForJob(job.id), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Action buttons - if (_isActing) - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Center(child: CircularProgressIndicator(color: AppColors.accent)), - ) - else ...[ - if (canAccept) - FilledButton.icon( - onPressed: () => _acceptJob(job), - icon: const Icon(Icons.check_circle_outline), - label: const Text('Kabul Et'), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - backgroundColor: AppColors.success, - ), - ), - - if (canSendToClinic) - FilledButton.icon( - onPressed: () => _showHandToClinicSheet(job), - icon: const Icon(Icons.send_outlined), - label: Text( - (job.isLastStep) - ? 'Son Prova - Teslime Gönder' - : 'Prova için Kliniğe Gönder', - ), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - backgroundColor: (job.isLastStep) - ? AppColors.success - : AppColors.inProgress, - ), - ), - - if (canCancelJobs && job.status == JobStatus.pending) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _cancelJob(job), - icon: const Icon(Icons.close_rounded), - label: const Text('İşi İptal Et'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(50), - foregroundColor: AppColors.cancelled, - side: const BorderSide(color: AppColors.cancelled), - ), - ), - ], + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + job.patientName?.isNotEmpty == true + ? job.patientName! + : job.patientCode, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: _statusBg(job.status), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + job.status.label, + style: TextStyle( + color: _statusColor(job.status), + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _InfoRow( + icon: Icons.business, + label: 'Klinik', + value: job.clinicName ?? '-'), + if (job.patientName != null && job.patientName!.isNotEmpty) + _InfoRow( + icon: Icons.person_outline, + label: 'Hasta', + value: job.patientName!, + ), + _InfoRow( + icon: Icons.tag_outlined, + label: 'Protokol No', + value: job.patientCode, + ), + _InfoRow( + icon: Icons.medical_services_outlined, + label: 'Protez Tipi', + value: job.prostheticType.label), + if (job.prostheticName != null && + job.prostheticName!.isNotEmpty) + _InfoRow( + icon: Icons.category_outlined, + label: 'Ürün', + value: job.prostheticName!, + ), + if (job.workflowType != null) + _InfoRow( + icon: Icons.tune_rounded, + label: 'İş Tipi', + value: job.workflowType!.label, + ), + _InfoRow( + icon: Icons.route_outlined, + label: 'Akış', + value: job.workflowPreset.title, + ), + _InfoRow( + icon: Icons.fact_check_outlined, + label: 'Prova', + value: job.provaRequired ? 'Provalı' : 'Provasız', + ), + _InfoRow( + icon: Icons.format_list_numbered, + label: 'Üye Sayısı', + value: '${job.memberCount} üye'), + if (job.color != null) + _InfoRow( + icon: Icons.color_lens_outlined, + label: 'Renk', + value: job.color!), + if (job.dueDate != null) + _InfoRow( + icon: Icons.calendar_today, + label: 'Teslim Tarihi', + value: _formatDate(job.dueDate!, withTime: true), + valueColor: job.dueDate!.isBefore(DateTime.now()) + ? AppColors.cancelled + : null), + _InfoRow( + icon: Icons.add_circle_outline, + label: 'Oluşturulma', + value: _formatDate(job.dateCreated)), + if (job.price != null && job.currency != null) + _InfoRow( + icon: Icons.attach_money, + label: 'Fiyat', + value: + '${job.price!.toStringAsFixed(2)} ${job.currency}'), + if (job.description != null && job.description!.isNotEmpty) + _InfoRow( + icon: Icons.notes, + label: 'Açıklama', + value: job.description!), + ], + ), + ), - const SizedBox(height: 20), + const SizedBox(height: 16), - JobFilesPanel( - job: job, - filesFuture: _filesFuture, - onRefresh: _loadFiles, + // Stepper + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'İş Adımları', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: job.provaRequired + ? AppColors.inProgressBg + : AppColors.successBg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + job.provaRequired ? 'Provalı' : 'Provasız', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: job.provaRequired + ? AppColors.inProgress + : AppColors.success, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + _JobStepper( + steps: job.stepTemplate, + currentStep: job.currentStep, + isDelivered: job.status == JobStatus.delivered, + historyFuture: _historyFuture, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + if (_isActing) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Center( + child: CircularProgressIndicator(color: AppColors.accent)), + ) + else ...[ + if (canAccept) + FilledButton.icon( + onPressed: () => _acceptJob(job), + icon: const Icon(Icons.check_circle_outline), + label: const Text('Kabul Et'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: AppColors.success, + ), + ), + if (canSendToClinic) + FilledButton.icon( + onPressed: () => _showHandToClinicSheet(job), + icon: const Icon(Icons.send_outlined), + label: Text( + (job.isLastStep) + ? 'Son Prova - Teslime Gönder' + : 'Prova için Kliniğe Gönder', + ), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: (job.isLastStep) + ? AppColors.success + : AppColors.inProgress, + ), + ), + if (canCancelJobs && job.status == JobStatus.pending) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _cancelJob(job), + icon: const Icon(Icons.close_rounded), + label: const Text('İşi İptal Et'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + foregroundColor: AppColors.cancelled, + side: const BorderSide(color: AppColors.cancelled), + ), ), - - const SizedBox(height: 16), ], - ); + ], + + const SizedBox(height: 20), + + JobFilesPanel( + job: job, + filesFuture: _filesFuture, + onRefresh: _loadFiles, + ), + + const SizedBox(height: 16), + ], + ); } } } @@ -515,12 +572,19 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { @override Widget build(BuildContext context) { - final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final currentStep = widget.job.currentStep; final isLast = widget.job.isLastStep; - final stepLabel = widget.job.currentStep?.label ?? ''; + final stepLabel = currentStep?.label ?? ''; + final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true; final buttonLabel = isLast - ? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder') - : '$stepLabel için Kliniğe Gönder'; + ? (widget.job.provaRequired + ? 'Son Prova · Teslime Gönder' + : 'Teslime Gönder') + : requiresClinicApproval + ? '$stepLabel için Kliniğe Gönder' + : '$stepLabel tamamlandı, sonraki adıma geç'; final buttonColor = isLast ? AppColors.success : AppColors.inProgress; return Container( @@ -534,9 +598,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { left: 20, right: 20, top: 24, - bottom: isDesktop - ? 24 - : MediaQuery.of(context).viewInsets.bottom + 24, + bottom: isDesktop ? 24 : MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -544,17 +606,16 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { children: [ Text( buttonLabel, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.bold, - color: AppColors.textPrimary), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, color: AppColors.textPrimary), ), const SizedBox(height: 8), Text( isLast ? 'İş teslim edilecek olarak işaretlenecek.' - : 'İş klinikteki prova için gönderilecek.', + : requiresClinicApproval + ? 'İş klinikteki prova veya onay için gönderilecek.' + : 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.', style: const TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 16), @@ -575,7 +636,8 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); try { - final updated = await LabJobsRepository.instance.handToClinic( + final updated = + await LabJobsRepository.instance.handToClinic( widget.job.id, widget.job, note: _noteController.text.trim().isEmpty @@ -587,7 +649,9 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { SnackBar( content: Text(isLast ? 'İş teslim için gönderildi' - : 'Prova için klinik\'e gönderildi')), + : requiresClinicApproval + ? 'Onay için kliniğe gönderildi' + : 'İş bir sonraki iç adıma geçirildi')), ); if (context.mounted) widget.onDone(updated); } catch (e) { @@ -645,7 +709,8 @@ class _InfoRow extends StatelessWidget { width: 110, child: Text( label, - style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + style: + const TextStyle(color: AppColors.textSecondary, fontSize: 13), ), ), Expanded( @@ -670,10 +735,12 @@ class _JobStepper extends StatelessWidget { const _JobStepper({ required this.steps, required this.currentStep, + required this.isDelivered, required this.historyFuture, }); final List steps; final JobStep? currentStep; + final bool isDelivered; final Future> historyFuture; @override @@ -686,7 +753,8 @@ class _JobStepper extends StatelessWidget { final Map revisionCounts = {}; final Map> notesByStep = {}; for (final e in history) { - if (e.action == JobHistoryAction.revisionRequested && e.step != null) { + if (e.action == JobHistoryAction.revisionRequested && + e.step != null) { revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1; } if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) { @@ -699,8 +767,8 @@ class _JobStepper extends StatelessWidget { return Column( children: List.generate(steps.length, (i) { final step = steps[i]; - final isCompleted = i < currentIndex; - final isCurrent = i == currentIndex; + final isCompleted = isDelivered || i < currentIndex; + final isCurrent = !isDelivered && i == currentIndex; final isLastItem = i == steps.length - 1; final revCount = revisionCounts[step] ?? 0; final stepNotes = notesByStep[step] ?? const []; @@ -728,7 +796,7 @@ class _JobStepper extends StatelessWidget { Container( width: 2, height: 44, - color: i < currentIndex + color: isDelivered || i < currentIndex ? AppColors.success.withValues(alpha: 0.35) : AppColors.border, ), @@ -788,7 +856,8 @@ class _JobStepper extends StatelessWidget { ), if (stepNotes.isNotEmpty) ...[ const SizedBox(height: 8), - ...stepNotes.map((entry) => _StepNoteCard(entry: entry)), + ...stepNotes + .map((entry) => _StepNoteCard(entry: entry)), ], ], ), @@ -846,6 +915,7 @@ class _StepNoteCard extends StatelessWidget { String _label(JobHistoryAction action) { return switch (action) { JobHistoryAction.revisionRequested => 'Revizyon Notu', + JobHistoryAction.stepCompleted => 'İç Adım Notu', JobHistoryAction.handedToClinic => 'Laboratuvar Notu', JobHistoryAction.approved => 'Onay Notu', JobHistoryAction.delivered => 'Teslim Notu', diff --git a/lib/features/lab/jobs/lab_jobs_repository.dart b/lib/features/lab/jobs/lab_jobs_repository.dart index 1b878ab..7dcd68f 100644 --- a/lib/features/lab/jobs/lab_jobs_repository.dart +++ b/lib/features/lab/jobs/lab_jobs_repository.dart @@ -24,26 +24,32 @@ class LabJobsRepository { if (status != null) filterParts.add('status = "$status"'); final result = await _pb.collection('jobs').getList( - page: page, - perPage: limit, - filter: filterParts.join(' && '), - expand: _listExpand, - ); + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: _listExpand, + ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); } - Future> listInProgress(String labTenantId, {int limit = 50, String? location}) async { - final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"']; + Future> listInProgress(String labTenantId, + {int limit = 50, String? location}) async { + final filterParts = [ + 'lab_tenant_id = "$labTenantId"', + 'status = "in_progress"' + ]; if (location != null) filterParts.add('location = "$location"'); final result = await _pb.collection('jobs').getList( - perPage: limit, - filter: filterParts.join(' && '), - expand: _listExpand, - ); + perPage: limit, + filter: filterParts.join(' && '), + expand: _listExpand, + ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) { - if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated); + if (a.dueDate == null && b.dueDate == null) { + return b.dateCreated.compareTo(a.dateCreated); + } if (a.dueDate == null) return 1; if (b.dueDate == null) return -1; final cmp = a.dueDate!.compareTo(b.dueDate!); @@ -52,7 +58,8 @@ class LabJobsRepository { } Future getJob(String jobId) async { - final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand); + final record = + await _pb.collection('jobs').getOne(jobId, expand: _detailExpand); return Job.fromJson(record.toJson()); } @@ -75,10 +82,21 @@ class LabJobsRepository { } Future handToClinic(String jobId, Job job, {String? note}) async { - final isFinal = job.currentStep == JobStep.cilaBitim; + final currentStep = job.currentStep; + if (currentStep == null) { + throw Exception('Geçerli bir iş adımı bulunamadı.'); + } + + final isFinal = currentStep == JobStep.cilaBitim; + final nextStep = job.nextStep; final patch = isFinal ? {'status': 'sent', 'location': 'at_clinic'} - : {'location': 'at_clinic'}; + : currentStep.requiresClinicApproval + ? {'location': 'at_clinic'} + : { + 'current_step': nextStep?.value, + 'location': 'at_lab', + }; final record = await _pb.collection('jobs').update(jobId, body: patch); final updated = Job.fromJson(record.toJson()); @@ -86,8 +104,10 @@ class LabJobsRepository { jobId: jobId, clinicTenantId: job.clinicTenantId, labTenantId: job.labTenantId, - action: JobHistoryAction.handedToClinic, - step: job.currentStep, + action: currentStep.requiresClinicApproval || isFinal + ? JobHistoryAction.handedToClinic + : JobHistoryAction.stepCompleted, + step: currentStep, note: note, )); return updated; @@ -109,7 +129,8 @@ class LabJobsRepository { } Future bulkAcceptPending(String labTenantId) async { - final pending = await listInbound(labTenantId, status: 'pending', limit: 200); + final pending = + await listInbound(labTenantId, status: 'pending', limit: 200); await Future.wait(pending.map((j) => acceptJob(j))); } @@ -121,11 +142,14 @@ class LabJobsRepository { return r.totalItems; } - Future countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async { + Future countDelivered(String labTenantId, + {DateTime? from, DateTime? to}) async { final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"']; if (from != null) parts.add('updated >= "${_date(from)}"'); if (to != null) parts.add('updated < "${_date(to)}"'); - final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && ')); + final r = await _pb + .collection('jobs') + .getList(perPage: 1, filter: parts.join(' && ')); return r.totalItems; } diff --git a/lib/features/lab/settings/lab_settings_screen.dart b/lib/features/lab/settings/lab_settings_screen.dart index 4516fa3..6fb3284 100644 --- a/lib/features/lab/settings/lab_settings_screen.dart +++ b/lib/features/lab/settings/lab_settings_screen.dart @@ -8,8 +8,12 @@ import '../../../core/providers/auth_provider.dart'; import '../../../core/providers/locale_provider.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../models/job.dart'; import '../../../models/tenant.dart'; +import '../../shared/location_completion_banner.dart'; import '../../shared/tenant_team_screen.dart'; +import '../../shared/location_picker_sheet.dart'; +import '../../shared/tenant_location_data.dart'; import '../connections/lab_connections_screen.dart'; class LabSettingsScreen extends ConsumerWidget { @@ -29,6 +33,17 @@ class LabSettingsScreen extends ConsumerWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ + if (tenant?.hasLocation != true) ...[ + LocationCompletionBanner( + title: 'Konum eksik', + description: + 'Laboratuvarınızın haritada görünmesi ve kliniklerin sizi yakın sonuçlarda bulabilmesi için koordinat kaydı tamamlanmalı.', + buttonLabel: 'Konumu Düzenle', + onTap: () => _showEditSheet(context, ref, tenant, s), + compact: true, + ), + const SizedBox(height: 20), + ], // User card _SectionHeader(title: s.userInfo), _UserCard(profile: profile), @@ -60,7 +75,9 @@ class LabSettingsScreen extends ConsumerWidget { _InfoTileBadge( icon: Icons.circle_outlined, label: s.status, - value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'), + value: tenant?.status == 'active' + ? s.active + : (tenant?.status ?? '-'), badgeColor: AppColors.success, badgeBg: AppColors.successBg, ), @@ -69,9 +86,42 @@ class LabSettingsScreen extends ConsumerWidget { label: s.role, value: _roleLabel(membership?.role, s), ), + _InfoTile( + icon: Icons.place_outlined, + label: 'Konum', + value: tenant?.locationLabel.isNotEmpty == true + ? tenant!.locationLabel + : '-', + ), ]), const SizedBox(height: 20), + if (tenant != null && tenant.isLab) ...[ + _SectionHeader( + title: 'İş Akışı', + action: canEdit + ? IconButton( + icon: const Icon(Icons.tune_rounded, + size: 18, color: AppColors.accent), + tooltip: 'Akışı Düzenle', + onPressed: () => _showWorkflowSheet(context, ref, tenant), + ) + : null, + ), + _InfoCard( + children: [ + _WorkflowPreviewTile( + enabledSteps: tenant.workflowOverrideSteps, + canEdit: canEdit, + onTap: canEdit + ? () => _showWorkflowSheet(context, ref, tenant) + : null, + ), + ], + ), + const SizedBox(height: 20), + ], + // Connections if (membership?.showConnections ?? false) ...[ _SectionHeader(title: s.connections), @@ -107,7 +157,9 @@ class LabSettingsScreen extends ConsumerWidget { onTap: () { ref.read(authProvider.notifier).setActiveTenant(m); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))), + SnackBar( + content: + Text(s.tenantSelected(m.tenant.companyName))), ); }, ), @@ -127,8 +179,7 @@ class LabSettingsScreen extends ConsumerWidget { subtitle: s.teamSub, onTap: () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const TenantTeamScreen()), + MaterialPageRoute(builder: (_) => const TenantTeamScreen()), ), ), _NavTile( @@ -155,6 +206,14 @@ class LabSettingsScreen extends ConsumerWidget { subtitle: s.aiAssistantSub, onTap: () => context.push(routeLabAi), ), + _NavTile( + icon: Icons.workspace_premium_outlined, + iconColor: AppColors.primary, + iconBg: const Color(0xFFEFF6FF), + title: 'Paketler ve AI Kredileri', + subtitle: 'Trial ve paket görünümünü incele', + onTap: () => context.push(routeWelcome), + ), ]), const SizedBox(height: 20), ], @@ -167,7 +226,8 @@ class LabSettingsScreen extends ConsumerWidget { iconColor: AppColors.accent, iconBg: AppColors.inProgressBg, title: s.appLanguage, - subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s), + subtitle: _currentLanguageLabel( + ref.watch(localeProvider).languageCode, s), onTap: () => _showLanguagePicker(context, ref, s), ), ]), @@ -191,7 +251,8 @@ class LabSettingsScreen extends ConsumerWidget { ); } - void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) { + void _showEditSheet( + BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) { if (tenant == null) return; showModalBottomSheet( context: context, @@ -200,11 +261,12 @@ class LabSettingsScreen extends ConsumerWidget { builder: (_) => _EditTenantSheet( tenant: tenant, s: s, - onSave: (name, currency) async { + onSave: (name, currency, location) async { await ref.read(authProvider.notifier).updateTenantInfo( tenantId: tenant.id, companyName: name, defaultCurrency: currency, + location: location, ); }, ), @@ -219,6 +281,29 @@ class LabSettingsScreen extends ConsumerWidget { ); } + void _showWorkflowSheet( + BuildContext context, + WidgetRef ref, + Tenant tenant, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _WorkflowSettingsSheet( + tenant: tenant, + onSave: (steps) async { + await ref.read(authProvider.notifier).updateTenantInfo( + tenantId: tenant.id, + companyName: tenant.companyName, + defaultCurrency: tenant.defaultCurrency, + workflowOverrides: steps.map((step) => step.value).toList(), + ); + }, + ), + ); + } + static String _tenantKindLabel(TenantKind? kind, AppStrings s) => switch (kind) { TenantKind.clinic => s.tenantKindClinic, @@ -226,7 +311,8 @@ class LabSettingsScreen extends ConsumerWidget { null => '-', }; - static String _currentLanguageLabel(String code, AppStrings s) => switch (code) { + static String _currentLanguageLabel(String code, AppStrings s) => + switch (code) { 'en' => s.languageEnglish, 'ru' => s.languageRussian, 'ar' => s.languageArabic, @@ -334,7 +420,11 @@ class _EditTenantSheet extends StatefulWidget { }); final Tenant tenant; final AppStrings s; - final Future Function(String companyName, String currency) onSave; + final Future Function( + String companyName, + String currency, + TenantLocationData location, + ) onSave; @override State<_EditTenantSheet> createState() => _EditTenantSheetState(); @@ -342,7 +432,11 @@ class _EditTenantSheet extends StatefulWidget { class _EditTenantSheetState extends State<_EditTenantSheet> { late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final TextEditingController _cityController; + late final TextEditingController _districtController; late String _selectedCurrency; + late TenantLocationData _location; bool _saving = false; static const _currencies = [ @@ -358,26 +452,39 @@ class _EditTenantSheetState extends State<_EditTenantSheet> { super.initState(); _nameController = TextEditingController(text: widget.tenant.companyName); _selectedCurrency = widget.tenant.defaultCurrency; + _location = TenantLocationData.fromTenant(widget.tenant); + _addressController = TextEditingController(text: _location.address ?? ''); + _cityController = TextEditingController(text: _location.city ?? ''); + _districtController = TextEditingController(text: _location.district ?? ''); } @override void dispose() { _nameController.dispose(); + _addressController.dispose(); + _cityController.dispose(); + _districtController.dispose(); super.dispose(); } Future _submit() async { final name = _nameController.text.trim(); if (name.isEmpty) return; + final location = _location.copyWith( + address: _addressController.text.trim(), + city: _cityController.text.trim(), + district: _districtController.text.trim(), + ); + if (!location.hasDetails) return; setState(() => _saving = true); final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); try { - await widget.onSave(name, _selectedCurrency); + await widget.onSave(name, _selectedCurrency, location); navigator.pop(); } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); + messenger + .showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); } finally { if (mounted) setState(() => _saving = false); } @@ -431,7 +538,7 @@ class _EditTenantSheetState extends State<_EditTenantSheet> { color: AppColors.textSecondary)), const SizedBox(height: 8), DropdownButtonFormField( - value: _selectedCurrency, + initialValue: _selectedCurrency, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), @@ -454,13 +561,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> { if (v != null) setState(() => _selectedCurrency = v); }, ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Konum', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + _location.fullLabel.isNotEmpty + ? _location.fullLabel + : 'Henüz konum veya adres bilgisi girilmedi.', + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: () async { + final picked = await showLocationPickerSheet( + context, + initialLocation: _location, + title: 'Laboratuvar Konumu', + ); + if (picked != null) { + setState(() => _location = picked); + } + }, + icon: const Icon(Icons.map_outlined), + label: const Text('Haritadan Konum Seç'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _addressController, + decoration: const InputDecoration( + labelText: 'Açık Adres', + hintText: 'Cadde, sokak, mahalle bilgisi', + ), + maxLines: 2, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _cityController, + decoration: const InputDecoration( + labelText: 'Şehir', + ), + textCapitalization: TextCapitalization.words, + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: _districtController, + decoration: const InputDecoration( + labelText: 'İlçe', + ), + textCapitalization: TextCapitalization.words, + ), + ), + ], + ), + ], + ), + ), const SizedBox(height: 20), if (_saving) const Center( child: CircularProgressIndicator(color: AppColors.accent)) else FilledButton( - onPressed: _submit, + onPressed: _saving ? null : _submit, style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 48)), child: Text(s.save), @@ -593,7 +778,10 @@ class _InfoCard extends StatelessWidget { children[i], if (i < children.length - 1) const Divider( - height: 1, indent: 16, endIndent: 16, color: AppColors.border), + height: 1, + indent: 16, + endIndent: 16, + color: AppColors.border), ], ], ), @@ -662,12 +850,11 @@ class _InfoTileBadge extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text(label, - style: const TextStyle( - fontSize: 11, color: AppColors.textMuted)), + style: + const TextStyle(fontSize: 11, color: AppColors.textMuted)), ), Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: badgeBg, borderRadius: BorderRadius.circular(8), @@ -704,8 +891,7 @@ class _NavTile extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), leading: Container( width: 36, height: 36, @@ -720,13 +906,170 @@ class _NavTile extends StatelessWidget { ? Text(subtitle!, style: const TextStyle(color: AppColors.textSecondary)) : null, - trailing: - const Icon(Icons.chevron_right, color: AppColors.textSecondary), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), onTap: onTap, ); } } +class _WorkflowPreviewTile extends StatelessWidget { + const _WorkflowPreviewTile({ + required this.enabledSteps, + required this.canEdit, + this.onTap, + }); + + final List enabledSteps; + final bool canEdit; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final summary = enabledSteps.isEmpty + ? 'Varsayılan preset akışı kullanılıyor.' + : 'Ekstra adımlar: ${enabledSteps.map((step) => step.label).join(', ')}'; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(9), + ), + child: + const Icon(Icons.route_outlined, color: AppColors.accent, size: 18), + ), + title: const Text( + 'Ekstra Laboratuvar Adımları', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + subtitle: Text( + summary, + style: const TextStyle(color: AppColors.textSecondary), + ), + trailing: canEdit + ? const Icon(Icons.chevron_right, color: AppColors.textSecondary) + : null, + onTap: onTap, + ); + } +} + +class _WorkflowSettingsSheet extends StatefulWidget { + const _WorkflowSettingsSheet({ + required this.tenant, + required this.onSave, + }); + + final Tenant tenant; + final Future Function(List steps) onSave; + + @override + State<_WorkflowSettingsSheet> createState() => _WorkflowSettingsSheetState(); +} + +class _WorkflowSettingsSheetState extends State<_WorkflowSettingsSheet> { + late final Set _selected = + widget.tenant.workflowOverrideSteps.toSet(); + bool _saving = false; + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ekstra İş Adımları', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Bunlar preset akışın üstüne eklenir. Bazı adımlar klinik onayı ister, bazıları laboratuvar içidir.', + style: TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + children: optionalLabStepCatalog.map((step) { + final selected = _selected.contains(step); + return CheckboxListTile( + value: selected, + onChanged: (value) { + setState(() { + if (value == true) { + _selected.add(step); + } else { + _selected.remove(step); + } + }); + }, + title: Text(step.label), + subtitle: Text( + '${step.description} · ${step.requiresClinicApproval ? "Klinik onayı gerekir" : "Laboratuvar iç adımı"}', + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _saving + ? null + : () async { + final navigator = Navigator.of(context); + setState(() => _saving = true); + try { + await widget.onSave(_selected.toList()); + if (mounted) navigator.pop(); + } finally { + if (mounted) setState(() => _saving = false); + } + }, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Kaydet'), + ), + ], + ), + ); + } +} + class _SignOutCard extends StatelessWidget { const _SignOutCard({required this.ref, required this.s}); final WidgetRef ref; @@ -747,16 +1090,14 @@ class _SignOutCard extends StatelessWidget { ], ), child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), leading: Container( width: 36, height: 36, decoration: BoxDecoration( color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(9)), - child: const Icon(Icons.logout, - color: AppColors.cancelled, size: 18), + child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18), ), title: Text(s.signOut, style: const TextStyle( diff --git a/lib/features/shared/location_completion_banner.dart b/lib/features/shared/location_completion_banner.dart new file mode 100644 index 0000000..e15dd89 --- /dev/null +++ b/lib/features/shared/location_completion_banner.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/app_theme.dart'; + +class LocationCompletionBanner extends StatelessWidget { + const LocationCompletionBanner({ + super.key, + required this.title, + required this.description, + required this.buttonLabel, + required this.onTap, + this.compact = false, + }); + + final String title; + final String description; + final String buttonLabel; + final VoidCallback onTap; + final bool compact; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(compact ? 14 : 16), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFDBA74)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: compact ? 36 : 40, + height: compact ? 36 : 40, + decoration: BoxDecoration( + color: const Color(0xFFFFEDD5), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.location_off_rounded, + color: Color(0xFFEA580C), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.edit_location_alt_outlined, size: 18), + label: Text(buttonLabel), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/shared/location_picker_sheet.dart b/lib/features/shared/location_picker_sheet.dart new file mode 100644 index 0000000..afe6b7a --- /dev/null +++ b/lib/features/shared/location_picker_sheet.dart @@ -0,0 +1,358 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../../core/location/location_access_service.dart'; +import '../../core/maps/open_free_map.dart'; +import '../../core/theme/app_theme.dart'; +import 'tenant_location_data.dart'; + +Future showLocationPickerSheet( + BuildContext context, { + TenantLocationData? initialLocation, + String title = 'Konum Seç', +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _LocationPickerSheet( + initialLocation: initialLocation, + title: title, + ), + ); +} + +class _LocationPickerSheet extends StatefulWidget { + const _LocationPickerSheet({ + required this.initialLocation, + required this.title, + }); + + final TenantLocationData? initialLocation; + final String title; + + @override + State<_LocationPickerSheet> createState() => _LocationPickerSheetState(); +} + +class _LocationPickerSheetState extends State<_LocationPickerSheet> { + static const _fallback = LatLng(41.0082, 28.9784); + + MapLibreMapController? _mapController; + TenantLocationData? _selection; + bool _styleReady = false; + bool _locating = false; + bool _resolvingAddress = false; + String? _error; + + LatLng get _selectedPoint => LatLng( + _selection?.latitude ?? + widget.initialLocation?.latitude ?? + _fallback.latitude, + _selection?.longitude ?? + widget.initialLocation?.longitude ?? + _fallback.longitude, + ); + + @override + void initState() { + super.initState(); + _selection = widget.initialLocation; + if (_selection?.hasCoordinates == true && + ((_selection?.address ?? '').trim().isEmpty)) { + unawaited(_updateAddress(_selectedPoint)); + } + } + + Future _pickCurrentLocation() async { + setState(() { + _locating = true; + _error = null; + }); + try { + final position = await LocationAccessService.getCurrentPosition(); + final point = LatLng(position.latitude, position.longitude); + await _moveCamera(point, 14); + setState(() { + _selection = (_selection ?? const TenantLocationData()).copyWith( + latitude: point.latitude, + longitude: point.longitude, + ); + }); + await _refreshSelectionMarker(); + await _updateAddress(point); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _locating = false); + } + } + + Future _moveCamera(LatLng point, double zoom) async { + final controller = _mapController; + if (controller == null) return; + await controller.animateCamera( + CameraUpdate.newLatLngZoom(point, zoom), + ); + } + + Future _refreshSelectionMarker() async { + final controller = _mapController; + if (controller == null || + !_styleReady || + _selection?.hasCoordinates != true) { + return; + } + + await controller.clearCircles(); + await controller.addCircle( + CircleOptions( + geometry: _selectedPoint, + circleRadius: 8, + circleColor: '#4F46E5', + circleStrokeWidth: 3, + circleStrokeColor: '#FFFFFF', + ), + ); + } + + Future _selectPoint(LatLng point, {double zoom = 15}) async { + setState(() { + _selection = (_selection ?? const TenantLocationData()).copyWith( + latitude: point.latitude, + longitude: point.longitude, + ); + }); + await _refreshSelectionMarker(); + await _moveCamera(point, zoom); + await _updateAddress(point); + } + + Future _updateAddress(LatLng point) async { + setState(() { + _resolvingAddress = true; + _error = null; + }); + try { + final placemarks = await placemarkFromCoordinates( + point.latitude, + point.longitude, + ); + final placemark = placemarks.isNotEmpty ? placemarks.first : null; + final addressParts = [ + placemark?.street, + placemark?.subLocality, + placemark?.locality, + ].where((part) => (part ?? '').trim().isNotEmpty).cast().toList(); + + setState(() { + _selection = (_selection ?? const TenantLocationData()).copyWith( + latitude: point.latitude, + longitude: point.longitude, + address: addressParts.join(', '), + district: placemark?.subAdministrativeArea?.trim().isNotEmpty == true + ? placemark!.subAdministrativeArea!.trim() + : placemark?.subLocality?.trim(), + city: placemark?.administrativeArea?.trim().isNotEmpty == true + ? placemark!.administrativeArea!.trim() + : placemark?.locality?.trim(), + ); + }); + } catch (_) { + setState(() { + _selection = (_selection ?? const TenantLocationData()).copyWith( + latitude: point.latitude, + longitude: point.longitude, + ); + }); + } finally { + if (mounted) setState(() => _resolvingAddress = false); + } + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.paddingOf(context).bottom; + + return Container( + height: MediaQuery.sizeOf(context).height * 0.88, + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + const SizedBox(height: 12), + Container( + width: 42, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + TextButton.icon( + onPressed: _locating ? null : _pickCurrentLocation, + icon: _locating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location_rounded, size: 18), + label: const Text('Konumum'), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + MapLibreMap( + styleString: OpenFreeMap.libertyStyle, + initialCameraPosition: CameraPosition( + target: _selectedPoint, + zoom: 13, + ), + onMapCreated: (controller) => _mapController = controller, + onStyleLoadedCallback: () async { + _styleReady = true; + await _refreshSelectionMarker(); + }, + onMapClick: (_, point) async { + await _selectPoint(point); + }, + compassEnabled: false, + tiltGesturesEnabled: false, + rotateGesturesEnabled: false, + myLocationEnabled: false, + myLocationTrackingMode: MyLocationTrackingMode.none, + ), + Positioned( + top: 12, + left: 12, + right: 12, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Haritada bir noktaya dokunarak konum seçin.', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.place_outlined, + size: 18, color: AppColors.accent), + const SizedBox(width: 8), + const Text( + 'Seçilen Konum', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (_resolvingAddress) ...[ + const SizedBox(width: 10), + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ], + ), + const SizedBox(height: 10), + Text( + _selection?.fullLabel.isNotEmpty == true + ? _selection!.fullLabel + : 'Haritada bir nokta seçin.', + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + Text( + 'Koordinatlar: ' + '${_selectedPoint.latitude.toStringAsFixed(6)}, ' + '${_selectedPoint.longitude.toStringAsFixed(6)}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + style: const TextStyle(color: AppColors.cancelled), + ), + ], + ], + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, bottom + 12), + child: FilledButton( + onPressed: _selection?.hasCoordinates == true + ? () => Navigator.of(context).pop(_selection) + : null, + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + ), + child: const Text('Bu Konumu Kullan'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/shared/tenant_location_data.dart b/lib/features/shared/tenant_location_data.dart new file mode 100644 index 0000000..c7023ad --- /dev/null +++ b/lib/features/shared/tenant_location_data.dart @@ -0,0 +1,75 @@ +import '../../models/tenant.dart'; + +class TenantLocationData { + const TenantLocationData({ + this.address, + this.city, + this.district, + this.latitude, + this.longitude, + }); + + final String? address; + final String? city; + final String? district; + final double? latitude; + final double? longitude; + + bool get hasCoordinates => latitude != null && longitude != null; + bool get hasDetails => + (address ?? '').trim().isNotEmpty || + (city ?? '').trim().isNotEmpty || + (district ?? '').trim().isNotEmpty || + hasCoordinates; + + String get shortLabel { + final parts = [ + if ((district ?? '').trim().isNotEmpty) district!.trim(), + if ((city ?? '').trim().isNotEmpty) city!.trim(), + ]; + if (parts.isNotEmpty) return parts.join(' / '); + return (address ?? '').trim(); + } + + String get fullLabel { + final parts = [ + if ((address ?? '').trim().isNotEmpty) address!.trim(), + if ((district ?? '').trim().isNotEmpty) district!.trim(), + if ((city ?? '').trim().isNotEmpty) city!.trim(), + ]; + return parts.join(', '); + } + + TenantLocationData copyWith({ + String? address, + String? city, + String? district, + double? latitude, + double? longitude, + bool clearAddress = false, + }) { + return TenantLocationData( + address: clearAddress ? null : (address ?? this.address), + city: city ?? this.city, + district: district ?? this.district, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + Map toTenantBody() => { + 'company_address': address, + 'city': city, + 'district': district, + 'latitude': latitude, + 'longitude': longitude, + }; + + static TenantLocationData fromTenant(Tenant tenant) => TenantLocationData( + address: tenant.companyAddress, + city: tenant.city, + district: tenant.district, + latitude: tenant.latitude, + longitude: tenant.longitude, + ); +} diff --git a/lib/features/super_admin/super_admin_repository.dart b/lib/features/super_admin/super_admin_repository.dart new file mode 100644 index 0000000..407c88f --- /dev/null +++ b/lib/features/super_admin/super_admin_repository.dart @@ -0,0 +1,142 @@ +import 'package:pocketbase/pocketbase.dart'; + +import '../../core/api/pocketbase_client.dart'; +import '../../models/platform_admin.dart'; +import '../../models/tenant.dart'; +import '../../models/user_profile.dart'; + +class SuperAdminRepository { + SuperAdminRepository._(); + static final instance = SuperAdminRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listPlatformMemberships() async { + final result = await _pb.collection('platform_memberships').getList( + perPage: 100, + sort: '-created', + ); + return result.items + .map((record) => PlatformMembership.fromJson(record.toJson())) + .toList(); + } + + Future> listSubscriptions() async { + final result = await _pb.collection('tenant_subscriptions').getList( + perPage: 200, + sort: '-updated', + ); + return result.items + .map((record) => TenantSubscription.fromJson(record.toJson())) + .toList(); + } + + Future> listCreditLedger( + String tenantId, { + int limit = 200, + }) async { + final result = await _pb.collection('ai_credit_ledger').getList( + perPage: limit, + sort: '-created', + filter: 'tenant_id = "$tenantId"', + ); + return result.items + .map((record) => AiCreditLedgerEntry.fromJson(record.toJson())) + .toList(); + } + + Future> listAiUsage({ + String? tenantId, + int limit = 200, + }) async { + final result = await _pb.collection('ai_usage_logs').getList( + perPage: limit, + sort: '-created', + filter: tenantId != null ? 'tenant_id = "$tenantId"' : '', + ); + return result.items + .map((record) => AiUsageLog.fromJson(record.toJson())) + .toList(); + } + + Future> listAuditLogs({int limit = 200}) async { + final result = await _pb.collection('admin_audit_logs').getList( + perPage: limit, + sort: '-created', + ); + return result.items + .map((record) => AdminAuditLog.fromJson(record.toJson())) + .toList(); + } + + Future assignPlatformRole({ + required String userId, + required PlatformRole role, + String status = 'active', + }) async { + final existing = await _pb.collection('platform_memberships').getList( + perPage: 1, + filter: 'user_id = "$userId"', + ); + if (existing.items.isEmpty) { + await _pb.collection('platform_memberships').create(body: { + 'user_id': userId, + 'role': role.value, + 'status': status, + }); + return; + } + + await _pb.collection('platform_memberships').update( + existing.items.first.id, + body: { + 'role': role.value, + 'status': status, + }, + ); + } + + Future upsertSubscription({ + required String tenantId, + required TenantPlan plan, + required SubscriptionStatus status, + String? billingProvider, + int? aiMonthlyCredits, + int? aiBonusCredits, + }) async { + final existing = await _pb.collection('tenant_subscriptions').getList( + perPage: 1, + filter: 'tenant_id = "$tenantId"', + ); + final body = { + 'tenant_id': tenantId, + 'plan': plan.name, + 'status': status.value, + if (billingProvider != null) 'billing_provider': billingProvider, + if (aiMonthlyCredits != null) 'ai_monthly_credits': aiMonthlyCredits, + if (aiBonusCredits != null) 'ai_bonus_credits': aiBonusCredits, + }; + if (existing.items.isEmpty) { + await _pb.collection('tenant_subscriptions').create(body: body); + return; + } + await _pb.collection('tenant_subscriptions').update( + existing.items.first.id, + body: body, + ); + } +} + +class SuperAdminDashboardSnapshot { + const SuperAdminDashboardSnapshot({ + required this.platformUsers, + required this.tenants, + required this.activeSubscriptions, + required this.aiUsageLogs, + }); + + final List platformUsers; + final List tenants; + final List activeSubscriptions; + final List aiUsageLogs; +} diff --git a/lib/models/finance_entry.dart b/lib/models/finance_entry.dart index 240a407..ea42c9b 100644 --- a/lib/models/finance_entry.dart +++ b/lib/models/finance_entry.dart @@ -5,11 +5,23 @@ extension FinanceTypeX on FinanceType { String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç'; } -enum FinanceStatus { pending, paid } +enum FinanceStatus { pending, reported, paid } extension FinanceStatusX on FinanceStatus { String get value => name; - String get label => this == FinanceStatus.pending ? 'Bekliyor' : 'Ödendi'; + String get label { + switch (this) { + case FinanceStatus.pending: + return 'Bekliyor'; + case FinanceStatus.reported: + return 'Onay Bekliyor'; + case FinanceStatus.paid: + return 'Onaylandı'; + } + } + + bool get isOpen => + this == FinanceStatus.pending || this == FinanceStatus.reported; } class FinanceEntry { @@ -44,7 +56,11 @@ class FinanceEntry { factory FinanceEntry.fromJson(Map j) { final expand = j['expand'] as Map?; final jobExp = expand?['job_id'] as Map?; - String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; } + String? parseOptionalString(dynamic v) { + final s = v as String?; + return (s == null || s.isEmpty) ? null : s; + } + return FinanceEntry( id: j['id'] as String, tenantId: j['tenant_id'] as String, @@ -55,9 +71,9 @@ class FinanceEntry { currency: j['currency'] as String? ?? 'TRY', status: FinanceStatus.values.firstWhere((e) => e.value == j['status'], orElse: () => FinanceStatus.pending), - counterpartyTenantId: _str(j['counterparty_tenant_id']), - paidAt: _str(j['paid_at']), - counterpartyName: _str(j['counterparty_name']), + counterpartyTenantId: parseOptionalString(j['counterparty_tenant_id']), + paidAt: parseOptionalString(j['paid_at']), + counterpartyName: parseOptionalString(j['counterparty_name']), patientCode: jobExp?['patient_code'] as String?, dateCreated: j['created'] as String?, ); diff --git a/lib/models/job.dart b/lib/models/job.dart index 3b1fc53..f9859f7 100644 --- a/lib/models/job.dart +++ b/lib/models/job.dart @@ -1,20 +1,28 @@ enum JobStatus { pending, inProgress, sent, delivered, cancelled } enum JobStep { - olcu, // legacy fallback - altYapiProva, // sabit seramik/metal — alt yapı (coping) - ustYapiProva, // sabit seramik — bisküvi prova - mumProva, // hareketli protez — mum prova - dislerProva, // hareketli protez — dişler prova - dayanakProva, // implant — dayanak prova - kronProva, // implant — kron prova - cilaBitim, // son cila / bitim (her şablonda son adım) + olcu, // legacy fallback + olcuKontrol, // geleneksel/arjinat ölçü veya model kontrolü + dijitalTasarim, // dijital tasarım klinik onayı + modelHazirlik, // internal hazırlık/model döküm + altYapiProva, // sabit seramik/metal — alt yapı (coping) + ustYapiProva, // sabit seramik — bisküvi prova + mumProva, // hareketli protez — mum prova + dislerProva, // hareketli protez — dişler prova + dayanakProva, // implant — dayanak prova + kronProva, // implant — kron prova + fotografOnay, // foto/mockup ile klinik onayı + kaliteKontrol, // internal kalite kontrol + teslimOncesiKontrol, // internal final kontrol + cilaBitim, // son cila / bitim (her şablonda son adım) } enum JobLocation { atClinic, atLab } enum JobWorkflowType { arjinat, geleneksel, dijital } +enum ProstheticFamily { sabit, implant, hareketli, gecici, ozel } + enum ProstheticType { metalPorselen, zirkonyum, @@ -30,18 +38,18 @@ enum ProstheticType { extension JobStatusExt on JobStatus { String get label => switch (this) { - JobStatus.pending => 'Bekliyor', + JobStatus.pending => 'Bekliyor', JobStatus.inProgress => 'İşlemde', - JobStatus.sent => 'Gönderildi', - JobStatus.delivered => 'Teslim Alındı', - JobStatus.cancelled => 'İptal', + JobStatus.sent => 'Gönderildi', + JobStatus.delivered => 'Teslim Alındı', + JobStatus.cancelled => 'İptal', }; String get value => switch (this) { - JobStatus.pending => 'pending', + JobStatus.pending => 'pending', JobStatus.inProgress => 'in_progress', - JobStatus.sent => 'sent', - JobStatus.delivered => 'delivered', - JobStatus.cancelled => 'cancelled', + JobStatus.sent => 'sent', + JobStatus.delivered => 'delivered', + JobStatus.cancelled => 'cancelled', }; } @@ -49,37 +57,74 @@ extension JobStatusExt on JobStatus { extension JobStepExt on JobStep { String get label => switch (this) { - JobStep.olcu => 'Ölçü', + JobStep.olcu => 'Ölçü', + JobStep.olcuKontrol => 'Ölçü / Model Kontrol', + JobStep.dijitalTasarim => 'Dijital Tasarım Onayı', + JobStep.modelHazirlik => 'Model Hazırlık', JobStep.altYapiProva => 'Alt Yapı Prova', JobStep.ustYapiProva => 'Üst Yapı Prova', - JobStep.mumProva => 'Mum Prova', - JobStep.dislerProva => 'Dişler Prova', + JobStep.mumProva => 'Mum Prova', + JobStep.dislerProva => 'Dişler Prova', JobStep.dayanakProva => 'Dayanak Prova', - JobStep.kronProva => 'Kron Prova', - JobStep.cilaBitim => 'Cila / Bitim', + JobStep.kronProva => 'Kron Prova', + JobStep.fotografOnay => 'Fotoğraf / Mockup Onayı', + JobStep.kaliteKontrol => 'Kalite Kontrol', + JobStep.teslimOncesiKontrol => 'Teslim Öncesi Kontrol', + JobStep.cilaBitim => 'Cila / Bitim', }; /// One-liner shown under the step on the stepper String get description => switch (this) { - JobStep.olcu => 'İlk ölçü alındı', + JobStep.olcu => 'İlk ölçü alındı', + JobStep.olcuKontrol => 'Ölçü, model veya kapanış kaydı kontrolü', + JobStep.dijitalTasarim => + 'Dijital tasarım ekranı veya mockup klinik onayı', + JobStep.modelHazirlik => + 'Model hazırlık, döküm veya artikülatör aşaması', JobStep.altYapiProva => 'Metal/zirkonyum coping klinik onayı', JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı', - JobStep.mumProva => 'Mum prova klinik onayı', - JobStep.dislerProva => 'Diş dizimi klinik onayı', + JobStep.mumProva => 'Mum prova klinik onayı', + JobStep.dislerProva => 'Diş dizimi klinik onayı', JobStep.dayanakProva => 'Dayanak klinik onayı', - JobStep.kronProva => 'Kron klinik onayı', - JobStep.cilaBitim => 'Son cila ve teslim hazırlığı', + JobStep.kronProva => 'Kron klinik onayı', + JobStep.fotografOnay => 'Fotoğraf veya mockup üzerinden klinik teyidi', + JobStep.kaliteKontrol => 'Laboratuvar iç kalite kontrol aşaması', + JobStep.teslimOncesiKontrol => 'Teslimat öncesi son iç kontrol', + JobStep.cilaBitim => 'Son cila ve teslim hazırlığı', }; String get value => switch (this) { - JobStep.olcu => 'olcu', + JobStep.olcu => 'olcu', + JobStep.olcuKontrol => 'olcu_kontrol', + JobStep.dijitalTasarim => 'dijital_tasarim', + JobStep.modelHazirlik => 'model_hazirlik', JobStep.altYapiProva => 'alt_yapi_prova', JobStep.ustYapiProva => 'ust_yapi_prova', - JobStep.mumProva => 'mum_prova', - JobStep.dislerProva => 'disler_prova', + JobStep.mumProva => 'mum_prova', + JobStep.dislerProva => 'disler_prova', JobStep.dayanakProva => 'dayanak_prova', - JobStep.kronProva => 'kron_prova', - JobStep.cilaBitim => 'cila_bitim', + JobStep.kronProva => 'kron_prova', + JobStep.fotografOnay => 'fotograf_onay', + JobStep.kaliteKontrol => 'kalite_kontrol', + JobStep.teslimOncesiKontrol => 'teslim_oncesi_kontrol', + JobStep.cilaBitim => 'cila_bitim', + }; + + bool get requiresClinicApproval => switch (this) { + JobStep.modelHazirlik || + JobStep.kaliteKontrol || + JobStep.teslimOncesiKontrol => + false, + _ => true, + }; + + bool get isLabOptional => switch (this) { + JobStep.modelHazirlik || + JobStep.fotografOnay || + JobStep.kaliteKontrol || + JobStep.teslimOncesiKontrol => + true, + _ => false, }; } @@ -101,52 +146,277 @@ extension JobWorkflowTypeExt on JobWorkflowType { extension ProstheticTypeExt on ProstheticType { String get label => switch (this) { - ProstheticType.metalPorselen => 'Metal Porselen', - ProstheticType.zirkonyum => 'Zirkonyum', + ProstheticType.metalPorselen => 'Metal Porselen', + ProstheticType.zirkonyum => 'Zirkonyum', ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum', - ProstheticType.gecici => 'Geçici', - ProstheticType.eMax => 'E-Max', - ProstheticType.tamProtez => 'Tam Protez', - ProstheticType.parsiyel => 'Parsiyel Protez', - ProstheticType.diger => 'Diğer', + ProstheticType.gecici => 'Geçici', + ProstheticType.eMax => 'E-Max', + ProstheticType.tamProtez => 'Tam Protez', + ProstheticType.parsiyel => 'Parsiyel Protez', + ProstheticType.diger => 'Diğer', }; String get value => switch (this) { - ProstheticType.metalPorselen => 'metal_porselen', - ProstheticType.zirkonyum => 'zirkonyum', + ProstheticType.metalPorselen => 'metal_porselen', + ProstheticType.zirkonyum => 'zirkonyum', ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum', - ProstheticType.gecici => 'gecici', - ProstheticType.eMax => 'e_max', - ProstheticType.tamProtez => 'tam_protez', - ProstheticType.parsiyel => 'parsiyel', - ProstheticType.diger => 'diger', + ProstheticType.gecici => 'gecici', + ProstheticType.eMax => 'e_max', + ProstheticType.tamProtez => 'tam_protez', + ProstheticType.parsiyel => 'parsiyel', + ProstheticType.diger => 'diger', + }; + + ProstheticFamily get family => switch (this) { + ProstheticType.metalPorselen || + ProstheticType.zirkonyum || + ProstheticType.eMax => + ProstheticFamily.sabit, + ProstheticType.implantUstuZirkonyum => ProstheticFamily.implant, + ProstheticType.tamProtez || + ProstheticType.parsiyel => + ProstheticFamily.hareketli, + ProstheticType.gecici => ProstheticFamily.gecici, + ProstheticType.diger => ProstheticFamily.ozel, }; } // ── Step template ───────────────────────────────────────────────────────────── -/// Returns the ordered step list for a given prosthetic type + prova flag. -List jobStepTemplate(ProstheticType type, bool provaRequired) { - if (!provaRequired) return const [JobStep.cilaBitim]; - return switch (type) { - // Sabit seramik: alt yapı coping + bisküvi prova + cila - ProstheticType.metalPorselen || - ProstheticType.zirkonyum || - ProstheticType.eMax => - const [JobStep.altYapiProva, JobStep.ustYapiProva, JobStep.cilaBitim], - // İmplant: dayanak + kron prova + cila - ProstheticType.implantUstuZirkonyum => - const [JobStep.dayanakProva, JobStep.kronProva, JobStep.cilaBitim], - // Hareketli protez: mum + dişler prova + cila - ProstheticType.tamProtez || - ProstheticType.parsiyel => - const [JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim], - // Geçici: sadece cila (prova gereksiz) - ProstheticType.gecici => - const [JobStep.cilaBitim], - // Diğer: tek ara prova + cila - _ => - const [JobStep.altYapiProva, JobStep.cilaBitim], +class JobWorkflowPreset { + const JobWorkflowPreset({ + required this.title, + required this.summary, + required this.steps, + }); + + final String title; + final String summary; + final List steps; +} + +const optionalLabStepCatalog = [ + JobStep.modelHazirlik, + JobStep.fotografOnay, + JobStep.kaliteKontrol, + JobStep.teslimOncesiKontrol, +]; + +bool isOptionalStepApplicable( + JobStep step, { + required JobWorkflowType workflowType, + required ProstheticFamily family, + required bool provaRequired, +}) { + switch (step) { + case JobStep.modelHazirlik: + return workflowType != JobWorkflowType.dijital && + family != ProstheticFamily.gecici; + case JobStep.fotografOnay: + return family != ProstheticFamily.hareketli || provaRequired; + case JobStep.kaliteKontrol: + case JobStep.teslimOncesiKontrol: + return true; + default: + return false; + } +} + +List mergeOptionalLabSteps({ + required List baseSteps, + required List optionalSteps, + required JobWorkflowType workflowType, + required ProstheticFamily family, + required bool provaRequired, +}) { + final merged = List.from(baseSteps); + for (final step in optionalSteps) { + if (!step.isLabOptional || + merged.contains(step) || + !isOptionalStepApplicable( + step, + workflowType: workflowType, + family: family, + provaRequired: provaRequired, + )) { + continue; + } + + final finalIndex = merged.indexOf(JobStep.cilaBitim); + switch (step) { + case JobStep.modelHazirlik: + final afterControl = merged.contains(JobStep.olcuKontrol) + ? merged.indexOf(JobStep.olcuKontrol) + 1 + : 0; + merged.insert(afterControl, step); + case JobStep.fotografOnay: + case JobStep.kaliteKontrol: + case JobStep.teslimOncesiKontrol: + merged.insert(finalIndex.clamp(0, merged.length), step); + default: + break; + } + } + return merged; +} + +JobWorkflowPreset buildJobWorkflowPreset({ + required ProstheticType prostheticType, + JobWorkflowType? workflowType, + required bool provaRequired, + List optionalSteps = const [], +}) { + final normalizedWorkflow = workflowType ?? JobWorkflowType.geleneksel; + final family = prostheticType.family; + + List steps; + switch (normalizedWorkflow) { + case JobWorkflowType.dijital: + steps = switch (family) { + ProstheticFamily.sabit => provaRequired + ? const [ + JobStep.dijitalTasarim, + JobStep.ustYapiProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.dijitalTasarim, + JobStep.cilaBitim, + ], + ProstheticFamily.implant => provaRequired + ? const [ + JobStep.dijitalTasarim, + JobStep.dayanakProva, + JobStep.kronProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.dijitalTasarim, + JobStep.cilaBitim, + ], + ProstheticFamily.hareketli => provaRequired + ? const [ + JobStep.dijitalTasarim, + JobStep.mumProva, + JobStep.dislerProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.dijitalTasarim, + JobStep.cilaBitim, + ], + ProstheticFamily.gecici => const [ + JobStep.dijitalTasarim, + JobStep.cilaBitim, + ], + ProstheticFamily.ozel => provaRequired + ? const [ + JobStep.dijitalTasarim, + JobStep.altYapiProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.dijitalTasarim, + JobStep.cilaBitim, + ], + }; + case JobWorkflowType.arjinat: + case JobWorkflowType.geleneksel: + steps = switch (family) { + ProstheticFamily.sabit => provaRequired + ? const [ + JobStep.olcuKontrol, + JobStep.altYapiProva, + JobStep.ustYapiProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.olcuKontrol, + JobStep.cilaBitim, + ], + ProstheticFamily.implant => provaRequired + ? const [ + JobStep.olcuKontrol, + JobStep.dayanakProva, + JobStep.kronProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.olcuKontrol, + JobStep.cilaBitim, + ], + ProstheticFamily.hareketli => provaRequired + ? const [ + JobStep.olcuKontrol, + JobStep.mumProva, + JobStep.dislerProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.olcuKontrol, + JobStep.cilaBitim, + ], + ProstheticFamily.gecici => const [ + JobStep.olcuKontrol, + JobStep.cilaBitim, + ], + ProstheticFamily.ozel => provaRequired + ? const [ + JobStep.olcuKontrol, + JobStep.altYapiProva, + JobStep.cilaBitim, + ] + : const [ + JobStep.olcuKontrol, + JobStep.cilaBitim, + ], + }; + } + + final title = + '${normalizedWorkflow.label} · ${provaRequired ? "Provalı" : "Provasız"}'; + final summary = switch ((normalizedWorkflow, family, provaRequired)) { + (JobWorkflowType.dijital, ProstheticFamily.sabit, false) => + 'Dijital tasarım onayı sonrası üretim ve teslim odaklı akış.', + (JobWorkflowType.dijital, _, false) => + 'Fiziksel prova azaltılmış, dijital onay ve hızlı teslim akışı.', + (JobWorkflowType.dijital, _, true) => + 'Dijital hazırlık üzerine klinik prova kapıları eklenmiş hibrit akış.', + (JobWorkflowType.arjinat, _, false) => + 'Ölçü/model kontrolü sonrası doğrudan üretim ve teslim akışı.', + (JobWorkflowType.arjinat, _, true) => + 'Arjinat ölçüden gelen işlerde klinik prova kapılarıyla ilerleyen akış.', + (JobWorkflowType.geleneksel, _, false) => + 'Klasik ölçüden gelen, minimum temaslı ve hızlı teslim akışı.', + (JobWorkflowType.geleneksel, _, true) => + 'Klasik laboratuvar süreçlerine uygun, prova bazlı aşamalı akış.', }; + + return JobWorkflowPreset( + title: title, + summary: summary, + steps: mergeOptionalLabSteps( + baseSteps: steps, + optionalSteps: optionalSteps, + workflowType: normalizedWorkflow, + family: family, + provaRequired: provaRequired, + ), + ); +} + +/// Returns the ordered clinic-facing approval steps for a job. +List jobStepTemplate( + ProstheticType type, + bool provaRequired, { + JobWorkflowType? workflowType, + List optionalSteps = const [], +}) { + return buildJobWorkflowPreset( + prostheticType: type, + workflowType: workflowType, + provaRequired: provaRequired, + optionalSteps: optionalSteps, + ).steps; } // ── Job ─────────────────────────────────────────────────────────────────────── @@ -178,6 +448,7 @@ class Job { this.labName, this.attachments = const [], this.provaRequired = true, + this.workflowSteps = const [], }); final String id; @@ -203,6 +474,7 @@ class Job { final DateTime dateCreated; final List attachments; final bool provaRequired; + final List workflowSteps; // Denormalized from relation joins — list views only final String? clinicName; @@ -236,20 +508,45 @@ class Job { price: price, currency: currency, status: status ?? this.status, - currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep), + currentStep: + clearCurrentStep ? null : (currentStep ?? this.currentStep), location: location ?? this.location, workflowType: workflowType ?? this.workflowType, dueDate: dueDate, dateCreated: dateCreated, attachments: attachments, provaRequired: provaRequired, + workflowSteps: workflowSteps, clinicName: clinicName ?? this.clinicName, labName: labName ?? this.labName, ); // ── Step helpers ────────────────────────────────────────────────────────── - List get stepTemplate => jobStepTemplate(prostheticType, provaRequired); + List get stepTemplate => workflowSteps.isNotEmpty + ? workflowSteps + : jobStepTemplate( + prostheticType, + provaRequired, + workflowType: workflowType, + optionalSteps: + workflowSteps.where((step) => step.isLabOptional).toList(), + ); + + JobWorkflowPreset get workflowPreset { + final preset = buildJobWorkflowPreset( + prostheticType: prostheticType, + workflowType: workflowType, + provaRequired: provaRequired, + optionalSteps: workflowSteps.where((step) => step.isLabOptional).toList(), + ); + if (workflowSteps.isEmpty) return preset; + return JobWorkflowPreset( + title: preset.title, + summary: preset.summary, + steps: workflowSteps, + ); + } bool get isLastStep => currentStep != null && currentStep == stepTemplate.last; @@ -310,28 +607,41 @@ class Job { ? (j['attachments'] as List).map((e) => e.toString()).toList() : [], provaRequired: (j['prova_required'] as bool?) ?? true, + workflowSteps: j['workflow_steps'] is List + ? (j['workflow_steps'] as List) + .map((e) => _parseStep(e.toString())) + .toList() + : const [], ); } static JobStatus _parseStatus(String s) => switch (s) { 'in_progress' => JobStatus.inProgress, - 'sent' => JobStatus.sent, - 'delivered' => JobStatus.delivered, - 'cancelled' => JobStatus.cancelled, - _ => JobStatus.pending, + 'sent' => JobStatus.sent, + 'delivered' => JobStatus.delivered, + 'cancelled' => JobStatus.cancelled, + _ => JobStatus.pending, }; static JobStep _parseStep(String s) => switch (s) { + 'olcu_kontrol' => JobStep.olcuKontrol, + 'dijital_tasarim' => JobStep.dijitalTasarim, + 'model_hazirlik' => JobStep.modelHazirlik, 'alt_yapi_prova' => JobStep.altYapiProva, 'ust_yapi_prova' => JobStep.ustYapiProva, - 'mum_prova' => JobStep.mumProva, - 'disler_prova' => JobStep.dislerProva, - 'dayanak_prova' => JobStep.dayanakProva, - 'kron_prova' => JobStep.kronProva, - 'cila_bitim' => JobStep.cilaBitim, - _ => JobStep.olcu, + 'mum_prova' => JobStep.mumProva, + 'disler_prova' => JobStep.dislerProva, + 'dayanak_prova' => JobStep.dayanakProva, + 'kron_prova' => JobStep.kronProva, + 'fotograf_onay' => JobStep.fotografOnay, + 'kalite_kontrol' => JobStep.kaliteKontrol, + 'teslim_oncesi_kontrol' => JobStep.teslimOncesiKontrol, + 'cila_bitim' => JobStep.cilaBitim, + _ => JobStep.olcu, }; + static JobStep parseStepValue(String s) => _parseStep(s); + static JobWorkflowType _parseWorkflowType(String s) => switch (s) { 'arjinat' => JobWorkflowType.arjinat, 'dijital' => JobWorkflowType.dijital, @@ -352,13 +662,13 @@ class Job { } static ProstheticType _parseProstheticType(String s) => switch (s) { - 'zirkonyum' => ProstheticType.zirkonyum, - 'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum, - 'gecici' => ProstheticType.gecici, - 'e_max' => ProstheticType.eMax, - 'tam_protez' => ProstheticType.tamProtez, - 'parsiyel' => ProstheticType.parsiyel, - 'diger' => ProstheticType.diger, - _ => ProstheticType.metalPorselen, + 'zirkonyum' => ProstheticType.zirkonyum, + 'implant_ustu_zirkonyum' => ProstheticType.implantUstuZirkonyum, + 'gecici' => ProstheticType.gecici, + 'e_max' => ProstheticType.eMax, + 'tam_protez' => ProstheticType.tamProtez, + 'parsiyel' => ProstheticType.parsiyel, + 'diger' => ProstheticType.diger, + _ => ProstheticType.metalPorselen, }; } diff --git a/lib/models/platform_admin.dart b/lib/models/platform_admin.dart new file mode 100644 index 0000000..f835262 --- /dev/null +++ b/lib/models/platform_admin.dart @@ -0,0 +1,298 @@ +import 'tenant.dart'; + +enum PlatformRole { + superAdmin, + support, + financeOps, + operations, + readOnly, + ; + + String get value => switch (this) { + PlatformRole.superAdmin => 'super_admin', + PlatformRole.support => 'support', + PlatformRole.financeOps => 'finance_ops', + PlatformRole.operations => 'operations', + PlatformRole.readOnly => 'read_only', + }; + + String get label => switch (this) { + PlatformRole.superAdmin => 'Super Admin', + PlatformRole.support => 'Destek', + PlatformRole.financeOps => 'Finans Operasyon', + PlatformRole.operations => 'Operasyon', + PlatformRole.readOnly => 'Sadece Görüntüleme', + }; + + static PlatformRole parse(String raw) => switch (raw) { + 'super_admin' => PlatformRole.superAdmin, + 'support' => PlatformRole.support, + 'finance_ops' => PlatformRole.financeOps, + 'operations' => PlatformRole.operations, + 'read_only' => PlatformRole.readOnly, + _ => PlatformRole.readOnly, + }; +} + +class PlatformMembership { + const PlatformMembership({ + required this.id, + required this.userId, + required this.role, + this.status = 'active', + }); + + final String id; + final String userId; + final PlatformRole role; + final String status; + + bool get isActive => status == 'active'; + bool get isSuperAdmin => role == PlatformRole.superAdmin && isActive; + bool get canManageBilling => + isActive && + (role == PlatformRole.superAdmin || role == PlatformRole.financeOps); + bool get canManageTenants => + isActive && + (role == PlatformRole.superAdmin || + role == PlatformRole.operations || + role == PlatformRole.support); + + factory PlatformMembership.fromJson(Map json) { + return PlatformMembership( + id: json['id'] as String, + userId: json['user_id'] as String, + role: PlatformRole.parse(json['role'] as String? ?? ''), + status: (json['status'] as String?) ?? 'active', + ); + } +} + +enum SubscriptionStatus { trialing, active, pastDue, cancelled, paused } + +extension SubscriptionStatusX on SubscriptionStatus { + String get value => switch (this) { + SubscriptionStatus.trialing => 'trialing', + SubscriptionStatus.active => 'active', + SubscriptionStatus.pastDue => 'past_due', + SubscriptionStatus.cancelled => 'cancelled', + SubscriptionStatus.paused => 'paused', + }; + + static SubscriptionStatus parse(String raw) => switch (raw) { + 'trialing' => SubscriptionStatus.trialing, + 'active' => SubscriptionStatus.active, + 'past_due' => SubscriptionStatus.pastDue, + 'cancelled' => SubscriptionStatus.cancelled, + 'paused' => SubscriptionStatus.paused, + _ => SubscriptionStatus.trialing, + }; +} + +class TenantSubscription { + const TenantSubscription({ + required this.id, + required this.tenantId, + required this.plan, + required this.status, + this.billingProvider, + this.providerCustomerId, + this.providerSubscriptionId, + this.periodStart, + this.periodEnd, + this.aiMonthlyCredits = 0, + this.aiBonusCredits = 0, + }); + + final String id; + final String tenantId; + final TenantPlan plan; + final SubscriptionStatus status; + final String? billingProvider; + final String? providerCustomerId; + final String? providerSubscriptionId; + final DateTime? periodStart; + final DateTime? periodEnd; + final int aiMonthlyCredits; + final int aiBonusCredits; + + int get totalAiCredits => aiMonthlyCredits + aiBonusCredits; + + factory TenantSubscription.fromJson(Map json) { + return TenantSubscription( + id: json['id'] as String, + tenantId: json['tenant_id'] as String, + plan: Tenant.parsePlanValue(json['plan'] as String?), + status: SubscriptionStatusX.parse(json['status'] as String? ?? ''), + billingProvider: json['billing_provider'] as String?, + providerCustomerId: json['provider_customer_id'] as String?, + providerSubscriptionId: json['provider_subscription_id'] as String?, + periodStart: _parseDate(json['period_start']), + periodEnd: _parseDate(json['period_end']), + aiMonthlyCredits: (json['ai_monthly_credits'] as num?)?.toInt() ?? 0, + aiBonusCredits: (json['ai_bonus_credits'] as num?)?.toInt() ?? 0, + ); + } +} + +enum AiCreditEntryType { + monthlyAllocation, + bonusAllocation, + usageDebit, + manualAdjustment, + refund, + expire, + ; + + String get value => switch (this) { + AiCreditEntryType.monthlyAllocation => 'monthly_allocation', + AiCreditEntryType.bonusAllocation => 'bonus_allocation', + AiCreditEntryType.usageDebit => 'usage_debit', + AiCreditEntryType.manualAdjustment => 'manual_adjustment', + AiCreditEntryType.refund => 'refund', + AiCreditEntryType.expire => 'expire', + }; + + static AiCreditEntryType parse(String raw) => switch (raw) { + 'monthly_allocation' => AiCreditEntryType.monthlyAllocation, + 'bonus_allocation' => AiCreditEntryType.bonusAllocation, + 'usage_debit' => AiCreditEntryType.usageDebit, + 'manual_adjustment' => AiCreditEntryType.manualAdjustment, + 'refund' => AiCreditEntryType.refund, + 'expire' => AiCreditEntryType.expire, + _ => AiCreditEntryType.manualAdjustment, + }; +} + +class AiCreditLedgerEntry { + const AiCreditLedgerEntry({ + required this.id, + required this.tenantId, + required this.entryType, + required this.delta, + required this.balanceAfter, + this.referenceType, + this.referenceId, + this.note, + this.createdByUserId, + this.createdAt, + }); + + final String id; + final String tenantId; + final AiCreditEntryType entryType; + final int delta; + final int balanceAfter; + final String? referenceType; + final String? referenceId; + final String? note; + final String? createdByUserId; + final DateTime? createdAt; + + factory AiCreditLedgerEntry.fromJson(Map json) { + return AiCreditLedgerEntry( + id: json['id'] as String, + tenantId: json['tenant_id'] as String, + entryType: AiCreditEntryType.parse(json['entry_type'] as String? ?? ''), + delta: (json['delta'] as num?)?.toInt() ?? 0, + balanceAfter: (json['balance_after'] as num?)?.toInt() ?? 0, + referenceType: json['reference_type'] as String?, + referenceId: json['reference_id'] as String?, + note: json['note'] as String?, + createdByUserId: json['created_by_user_id'] as String?, + createdAt: _parseDate(json['created']), + ); + } +} + +class AiUsageLog { + const AiUsageLog({ + required this.id, + required this.tenantId, + required this.userId, + required this.action, + required this.creditCost, + this.model, + this.jobId, + this.tokenInput, + this.tokenOutput, + this.latencyMs, + this.createdAt, + }); + + final String id; + final String tenantId; + final String userId; + final String action; + final int creditCost; + final String? model; + final String? jobId; + final int? tokenInput; + final int? tokenOutput; + final int? latencyMs; + final DateTime? createdAt; + + factory AiUsageLog.fromJson(Map json) { + return AiUsageLog( + id: json['id'] as String, + tenantId: json['tenant_id'] as String, + userId: json['user_id'] as String, + action: json['action'] as String? ?? '', + creditCost: (json['credit_cost'] as num?)?.toInt() ?? 0, + model: json['model'] as String?, + jobId: json['job_id'] as String?, + tokenInput: (json['token_input'] as num?)?.toInt(), + tokenOutput: (json['token_output'] as num?)?.toInt(), + latencyMs: (json['latency_ms'] as num?)?.toInt(), + createdAt: _parseDate(json['created']), + ); + } +} + +class AdminAuditLog { + const AdminAuditLog({ + required this.id, + required this.actorUserId, + required this.actorRole, + required this.actionType, + this.targetCollection, + this.targetRecordId, + this.targetTenantId, + this.summary, + this.metadata, + this.createdAt, + }); + + final String id; + final String actorUserId; + final PlatformRole actorRole; + final String actionType; + final String? targetCollection; + final String? targetRecordId; + final String? targetTenantId; + final String? summary; + final Map? metadata; + final DateTime? createdAt; + + factory AdminAuditLog.fromJson(Map json) { + final metadata = json['metadata']; + return AdminAuditLog( + id: json['id'] as String, + actorUserId: json['actor_user_id'] as String, + actorRole: PlatformRole.parse(json['actor_role'] as String? ?? ''), + actionType: json['action_type'] as String? ?? '', + targetCollection: json['target_collection'] as String?, + targetRecordId: json['target_record_id'] as String?, + targetTenantId: json['target_tenant_id'] as String?, + summary: json['summary'] as String?, + metadata: metadata is Map ? metadata : null, + createdAt: _parseDate(json['created']), + ); + } +} + +DateTime? _parseDate(dynamic raw) { + final value = raw as String?; + if (value == null || value.isEmpty) return null; + return DateTime.tryParse(value); +} diff --git a/lib/models/tenant.dart b/lib/models/tenant.dart index 863bc44..e10f7f3 100644 --- a/lib/models/tenant.dart +++ b/lib/models/tenant.dart @@ -1,25 +1,27 @@ +import 'job.dart'; + enum TenantKind { lab, clinic } enum TenantRole { owner, admin, technician, // lab: işler + ürünler - delivery, // lab: işler - finance, // lab+clinic: finans - doctor, // clinic: işler + hastalar - member, // legacy — full access + delivery, // lab: işler + finance, // lab+clinic: finans + doctor, // clinic: işler + hastalar + member, // legacy — full access ; String get value => name; String get label => switch (this) { - TenantRole.owner => 'Sahibi', - TenantRole.admin => 'Yönetici', + TenantRole.owner => 'Sahibi', + TenantRole.admin => 'Yönetici', TenantRole.technician => 'Teknisyen', - TenantRole.delivery => 'Teslimat Elemanı', - TenantRole.finance => 'Finans Elemanı', - TenantRole.doctor => 'Hekim', - TenantRole.member => 'Üye', + TenantRole.delivery => 'Teslimat Elemanı', + TenantRole.finance => 'Finans Elemanı', + TenantRole.doctor => 'Hekim', + TenantRole.member => 'Üye', }; } @@ -31,36 +33,71 @@ class Tenant { required this.kind, required this.memberNumber, required this.companyName, + this.companyAddress, + this.city, + this.district, + this.latitude, + this.longitude, this.logo, this.defaultCurrency = 'TRY', this.status = 'active', this.plan, this.maxMembers, + this.workflowOverrideStepKeys = const [], }); final String id; final TenantKind kind; final String memberNumber; final String companyName; + final String? companyAddress; + final String? city; + final String? district; + final double? latitude; + final double? longitude; final String? logo; final String defaultCurrency; final String status; final TenantPlan? plan; final int? maxMembers; + final List workflowOverrideStepKeys; bool get isLab => kind == TenantKind.lab; bool get isClinic => kind == TenantKind.clinic; + bool get hasLocation => latitude != null && longitude != null; + List get workflowOverrideSteps => workflowOverrideStepKeys + .map(Job.parseStepValue) + .where((step) => step.isLabOptional) + .toList(); + String get locationLabel { + final parts = [ + if ((district ?? '').trim().isNotEmpty) district!.trim(), + if ((city ?? '').trim().isNotEmpty) city!.trim(), + ]; + if (parts.isNotEmpty) return parts.join(' / '); + return (companyAddress ?? '').trim(); + } factory Tenant.fromJson(Map j) => Tenant( id: j['id'] as String, kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic, memberNumber: (j['member_number'] as String?) ?? '', companyName: j['company_name'] as String, + companyAddress: j['company_address'] as String?, + city: j['city'] as String?, + district: j['district'] as String?, + latitude: (j['latitude'] as num?)?.toDouble(), + longitude: (j['longitude'] as num?)?.toDouble(), logo: j['logo'] as String?, defaultCurrency: (j['default_currency'] as String?) ?? 'TRY', status: (j['status'] as String?) ?? 'active', plan: _parsePlan(j['plan'] as String?), maxMembers: (j['max_members'] as num?)?.toInt(), + workflowOverrideStepKeys: j['workflow_overrides'] is List + ? (j['workflow_overrides'] as List) + .map((e) => e.toString()) + .toList() + : const [], ); static TenantPlan? _parsePlan(String? p) => switch (p) { @@ -69,6 +106,9 @@ class Tenant { 'enterprise' => TenantPlan.enterprise, _ => null, }; + + static TenantPlan parsePlanValue(String? value) => + _parsePlan(value) ?? TenantPlan.starter; } class TenantMembership { @@ -85,22 +125,43 @@ class TenantMembership { // ── Access helpers ──────────────────────────────────────────────────────── bool get isOwner => role == TenantRole.owner; bool get isAdmin => role == TenantRole.admin || role == TenantRole.owner; - bool get canManageUsers => role == TenantRole.owner || role == TenantRole.admin; + bool get canManageUsers => + role == TenantRole.owner || role == TenantRole.admin; bool get canManageJobs => role != TenantRole.finance; - bool get canManageFinance => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.finance || role == TenantRole.member; - bool get canManageProducts => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.technician || role == TenantRole.member; - bool get canViewPatients => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.doctor || role == TenantRole.member; - bool get canManageConnections => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member; + bool get canManageFinance => + role == TenantRole.owner || + role == TenantRole.admin || + role == TenantRole.finance || + role == TenantRole.member; + bool get canManageProducts => + role == TenantRole.owner || + role == TenantRole.admin || + role == TenantRole.technician || + role == TenantRole.member; + bool get canViewPatients => + role == TenantRole.owner || + role == TenantRole.admin || + role == TenantRole.doctor || + role == TenantRole.member; + bool get canManageConnections => + role == TenantRole.owner || + role == TenantRole.admin || + role == TenantRole.member; // ── Fine-grained job actions ────────────────────────────────────────────── /// Can create new jobs (clinic side: owner/admin/doctor/member; not delivery/finance) - bool get canCreateJobs => role != TenantRole.delivery && role != TenantRole.finance; + bool get canCreateJobs => + role != TenantRole.delivery && role != TenantRole.finance; /// Can confirm physical delivery (delivery role + supervisors) bool get canDeliverJobs => role != TenantRole.finance; /// Can cancel or fully manage job lifecycle (not delivery-only or finance) - bool get canCancelJobs => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member || role == TenantRole.doctor; + bool get canCancelJobs => + role == TenantRole.owner || + role == TenantRole.admin || + role == TenantRole.member || + role == TenantRole.doctor; /// Primary focus is delivery — restrict to delivery-relevant UI bool get isDeliveryOnly => role == TenantRole.delivery; @@ -124,22 +185,22 @@ class TenantMembership { } static TenantRole parseRole(String r) => switch (r) { - 'owner' => TenantRole.owner, - 'admin' => TenantRole.admin, + 'owner' => TenantRole.owner, + 'admin' => TenantRole.admin, 'technician' => TenantRole.technician, - 'delivery' => TenantRole.delivery, - 'finance' => TenantRole.finance, - 'doctor' => TenantRole.doctor, - _ => TenantRole.member, + 'delivery' => TenantRole.delivery, + 'finance' => TenantRole.finance, + 'doctor' => TenantRole.doctor, + _ => TenantRole.member, }; String get roleLabel => switch (role) { - TenantRole.owner => 'Sahibi', - TenantRole.admin => 'Yönetici', + TenantRole.owner => 'Sahibi', + TenantRole.admin => 'Yönetici', TenantRole.technician => 'Teknisyen', - TenantRole.delivery => 'Teslimat Elemanı', - TenantRole.finance => 'Finans Elemanı', - TenantRole.doctor => 'Hekim', - TenantRole.member => 'Üye', + TenantRole.delivery => 'Teslimat Elemanı', + TenantRole.finance => 'Finans Elemanı', + TenantRole.doctor => 'Hekim', + TenantRole.member => 'Üye', }; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8431fc6..9b1ce9c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import file_picker +import geolocator_apple import path_provider_foundation import share_plus import shared_preferences_foundation @@ -13,6 +14,7 @@ import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 70a956a..63d4daf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -445,6 +453,86 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: d580c801cba9386b4fac5047c4c785a4e19554f46be42f4f5e5b7deacd088a66 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "1b13eca79b11c497c434678fed109c2be020b158cec7512c848c102bc7232603" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: dde05dae7d584db6e82feb87dd9fb0b4b4c83ed68065667b4bef637be38e13a7 + url: "https://pub.dev" + source: hosted + version: "4.2.7" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: @@ -501,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" intl: dependency: "direct main" description: @@ -557,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.9.5" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -597,6 +701,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: "3c383a7e81f0ec5882c377b7d1686047d8bce35b6a3a9798dad8e57a03423e05" + url: "https://pub.dev" + source: hosted + version: "0.26.1" + maplibre_gl_platform_interface: + dependency: transitive + description: + name: maplibre_gl_platform_interface + sha256: "4989f157fdb98ad346b31067cd30aefa7e67d0b235599e37b75608c9284cb459" + url: "https://pub.dev" + source: hosted + version: "0.26.1" + maplibre_gl_web: + dependency: transitive + description: + name: maplibre_gl_web + sha256: "55a03e586d2007b646f163902388c3c17df536ea44e4b1c381754863201862e6" + url: "https://pub.dev" + source: hosted + version: "0.26.1" matcher: dependency: transitive description: @@ -765,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d734f8..c335b4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,10 @@ dependencies: intl: ^0.20.2 google_fonts: ^6.2.1 flutter_animate: ^4.5.0 + maplibre_gl: ^0.26.1 + latlong2: ^0.9.1 + geolocator: ^13.0.2 + geocoding: ^3.0.0 # Utilities freezed_annotation: ^2.4.1 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c3384ec..cf2eeb8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 01d3836..ee80937 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + geolocator_windows share_plus url_launcher_windows )