Add pricing entry flow and platform admin foundations

This commit is contained in:
egecankomur
2026-06-20 18:24:40 +03:00
parent 1d36ccdf30
commit ac42681f7e
44 changed files with 6567 additions and 1419 deletions
+3
View File
@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application <application
android:label="@string/app_name" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
+122
View File
@@ -0,0 +1,122 @@
# Super Admin Scope
## Neden tenant rolünden ayrı?
- `tenant_members` bir kullanıcının bir veya daha fazla tenant içindeki rolünü taşır.
- `platform_memberships` ise kullanıcının ürün genelindeki yetkisini taşır.
- Böylece aynı kullanıcı:
- birden fazla klinik/lab tenant'ına bağlı olabilir
- aynı anda platformda `super_admin` veya `support` olabilir
- tenant rolü ile platform rolü birbirine karışmaz
## İlk faz modülleri
### 1. Tenant & Plan Yönetimi
- tenant listeleme
- tenant detay
- plan atama/değiştirme
- aktif/pasif durumu
- üye limiti
- workflow/feature override görüntüleme
### 2. Abonelik & AI Kredisi
- tenant subscription durumu
- billing provider bilgileri
- aylık AI kredi tahsisi
- bonus kredi
- manuel kredi düzeltmeleri
- kredi hareket geçmişi
### 3. Kullanım & Operasyon
- AI kullanım logları
- tenant başına maliyet/yoğunluk
- en çok kredi tüketen tenant ve kullanıcılar
- admin müdahale logları
### 4. Platform Erişimi
- super admin atama
- support / finance ops / operations rolleri
- kim hangi işlemi yaptı audit takibi
## Veri modeli
### `platform_memberships`
- `user_id`
- `role`: `super_admin | support | finance_ops | operations | read_only`
- `status`: `active | suspended`
### `tenant_subscriptions`
- `tenant_id`
- `plan`: `starter | pro | enterprise`
- `status`: `trialing | active | past_due | cancelled | paused`
- `billing_provider`
- `provider_customer_id`
- `provider_subscription_id`
- `period_start`
- `period_end`
- `ai_monthly_credits`
- `ai_bonus_credits`
### `ai_credit_ledger`
- `tenant_id`
- `entry_type`: `monthly_allocation | bonus_allocation | usage_debit | manual_adjustment | refund | expire`
- `delta`
- `balance_after`
- `reference_type`
- `reference_id`
- `created_by_user_id`
- `note`
### `ai_usage_logs`
- `tenant_id`
- `user_id`
- `job_id`
- `action`
- `model`
- `credit_cost`
- `token_input`
- `token_output`
- `latency_ms`
### `admin_audit_logs`
- `actor_user_id`
- `actor_role`
- `action_type`
- `target_collection`
- `target_record_id`
- `target_tenant_id`
- `summary`
- `metadata`
## Yetki ilkeleri
- `super_admin`: tüm platform yetkileri
- `finance_ops`: abonelik ve kredi yönetimi
- `operations`: tenant ve operasyonel müdahaleler
- `support`: destek amaçlı görünüm + sınırlı düzeltmeler
- `read_only`: sadece raporlama ve denetim
## Multi-tenant kullanıcı kuralı
- bir kullanıcı `tenant_members` üzerinde birden fazla kayda sahip olabilir
- her kayıt `(tenant_id, user_id)` bazında benzersiz kalır
- aktif tenant seçimi UI durumudur, kimlik modeli değildir
- platform erişimi tenant seçimine bağlı değildir
## Uygulama akışı
1. kullanıcı login olur
2. auth katmanı:
- `tenant_members`
- `platform_memberships`
kayıtlarını ayrı çeker
3. normal uygulama tenant seçimiyle çalışır
4. super admin paneli `platform_memberships` üzerinden açılır
## Sonraki implementasyon adımları
1. super admin route guard
2. dashboard ekranı
3. tenant subscription listesi
4. AI kredi müdahale ekranı
5. audit log görünümü
+23
View File
@@ -34,6 +34,15 @@ PODS:
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- geocoding_ios (1.0.5):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- MapLibre (6.26.0)
- maplibre_gl (0.26.1):
- Flutter
- MapLibre (= 6.26.0)
- onesignal_flutter (5.5.8): - onesignal_flutter (5.5.8):
- Flutter - Flutter
- OneSignalXCFramework (= 5.5.2) - OneSignalXCFramework (= 5.5.2)
@@ -105,6 +114,9 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- geocoding_ios (from `.symlinks/plugins/geocoding_ios/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`) - onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -115,6 +127,7 @@ SPEC REPOS:
trunk: trunk:
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- MapLibre
- OneSignalXCFramework - OneSignalXCFramework
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
@@ -124,6 +137,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_picker/ios" :path: ".symlinks/plugins/file_picker/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
geocoding_ios:
:path: ".symlinks/plugins/geocoding_ios/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
onesignal_flutter: onesignal_flutter:
:path: ".symlinks/plugins/onesignal_flutter/ios" :path: ".symlinks/plugins/onesignal_flutter/ios"
path_provider_foundation: path_provider_foundation:
@@ -140,6 +159,10 @@ SPEC CHECKSUMS:
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
geocoding_ios: 33776c9ebb98d037b5e025bb0e7537f6dd19646e
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
MapLibre: b7ef5623a1a61468c4266a048e494181c10173ad
maplibre_gl: 11d1987a778d0a5905ba6677f81a0e7a491ed8f5
onesignal_flutter: 75c70a45a8d97e685273a14f04521ec121611458 onesignal_flutter: 75c70a45a8d97e685273a14f04521ec121611458
OneSignalXCFramework: 2f46ff87ccefd9afe8e3b5f9fe357072191205ff OneSignalXCFramework: 2f46ff87ccefd9afe8e3b5f9fe357072191205ff
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+42 -2
View File
@@ -1,5 +1,6 @@
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import '../api/pocketbase_client.dart'; import '../api/pocketbase_client.dart';
import '../../models/platform_admin.dart';
import '../../models/tenant.dart'; import '../../models/tenant.dart';
import '../../models/user_profile.dart'; import '../../models/user_profile.dart';
@@ -73,10 +74,24 @@ class AuthRepository {
String id, { String id, {
String? companyName, String? companyName,
String? defaultCurrency, String? defaultCurrency,
String? companyAddress,
String? city,
String? district,
double? latitude,
double? longitude,
List<String>? workflowOverrides,
}) async { }) async {
final body = <String, dynamic>{}; final body = <String, dynamic>{};
if (companyName != null) body['company_name'] = companyName; if (companyName != null) body['company_name'] = companyName;
if (defaultCurrency != null) body['default_currency'] = defaultCurrency; 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; if (body.isEmpty) return;
await _pb.collection('tenants').update(id, body: body); await _pb.collection('tenants').update(id, body: body);
} }
@@ -85,10 +100,18 @@ class AuthRepository {
final record = _pb.authStore.record!; final record = _pb.authStore.record!;
final user = UserProfile.fromJson(record.toJson()); final user = UserProfile.fromJson(record.toJson());
List<TenantMembership> tenants = []; List<TenantMembership> tenants = [];
List<PlatformMembership> platformMemberships = [];
try { try {
tenants = await _fetchUserTenants(record.id); tenants = await _fetchUserTenants(record.id);
} catch (_) {} } catch (_) {}
return AuthResult(user: user, tenants: tenants); try {
platformMemberships = await _fetchPlatformMemberships(record.id);
} catch (_) {}
return AuthResult(
user: user,
tenants: tenants,
platformMemberships: platformMemberships,
);
} }
Future<List<TenantMembership>> _fetchUserTenants(String userId) async { Future<List<TenantMembership>> _fetchUserTenants(String userId) async {
@@ -101,10 +124,27 @@ class AuthRepository {
.map((r) => TenantMembership.fromJson(r.toJson())) .map((r) => TenantMembership.fromJson(r.toJson()))
.toList(); .toList();
} }
Future<List<PlatformMembership>> _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 { 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 UserProfile user;
final List<TenantMembership> tenants; final List<TenantMembership> tenants;
final List<PlatformMembership> platformMemberships;
} }
@@ -0,0 +1,30 @@
import 'package:geolocator/geolocator.dart';
class LocationAccessService {
LocationAccessService._();
static Future<Position> 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();
}
}
+12
View File
@@ -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';
}
+33 -10
View File
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import '../auth/auth_repository.dart'; import '../auth/auth_repository.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../../models/platform_admin.dart';
import '../../models/tenant.dart'; import '../../models/tenant.dart';
import '../../models/user_profile.dart'; import '../../models/user_profile.dart';
import '../../features/shared/tenant_location_data.dart';
import 'locale_provider.dart'; import 'locale_provider.dart';
class AuthState { class AuthState {
@@ -12,6 +14,7 @@ class AuthState {
this.profile, this.profile,
this.activeTenant, this.activeTenant,
this.memberships = const [], this.memberships = const [],
this.platformMemberships = const [],
this.isLoading = true, this.isLoading = true,
this.error, this.error,
}); });
@@ -19,15 +22,24 @@ class AuthState {
final UserProfile? profile; final UserProfile? profile;
final TenantMembership? activeTenant; final TenantMembership? activeTenant;
final List<TenantMembership> memberships; final List<TenantMembership> memberships;
final List<PlatformMembership> platformMemberships;
final bool isLoading; final bool isLoading;
final String? error; final String? error;
bool get isAuthenticated => profile != null; 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({ AuthState copyWith({
UserProfile? profile, UserProfile? profile,
TenantMembership? activeTenant, TenantMembership? activeTenant,
List<TenantMembership>? memberships, List<TenantMembership>? memberships,
List<PlatformMembership>? platformMemberships,
bool? isLoading, bool? isLoading,
String? error, String? error,
bool clearError = false, bool clearError = false,
@@ -36,6 +48,7 @@ class AuthState {
profile: profile ?? this.profile, profile: profile ?? this.profile,
activeTenant: activeTenant ?? this.activeTenant, activeTenant: activeTenant ?? this.activeTenant,
memberships: memberships ?? this.memberships, memberships: memberships ?? this.memberships,
platformMemberships: platformMemberships ?? this.platformMemberships,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error), error: clearError ? null : (error ?? this.error),
); );
@@ -60,11 +73,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthState( state = AuthState(
profile: result.user, profile: result.user,
memberships: result.tenants, memberships: result.tenants,
activeTenant: platformMemberships: result.platformMemberships,
result.tenants.isEmpty ? null : result.tenants.first, activeTenant: result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false, 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); NotificationService.loginUser(result.user.id, isLab: isLab);
_applyLocale(result.user.preferredLanguage); _applyLocale(result.user.preferredLanguage);
} catch (_) { } catch (_) {
@@ -93,11 +107,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthState( state = AuthState(
profile: result.user, profile: result.user,
memberships: result.tenants, memberships: result.tenants,
activeTenant: platformMemberships: result.platformMemberships,
result.tenants.isEmpty ? null : result.tenants.first, activeTenant: result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false, 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); NotificationService.loginUser(result.user.id, isLab: isLab);
_applyLocale(result.user.preferredLanguage); _applyLocale(result.user.preferredLanguage);
} catch (e) { } catch (e) {
@@ -122,8 +137,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = AuthState( state = AuthState(
profile: result.user, profile: result.user,
memberships: result.tenants, memberships: result.tenants,
activeTenant: platformMemberships: result.platformMemberships,
result.tenants.isEmpty ? null : result.tenants.first, activeTenant: result.tenants.isEmpty ? null : result.tenants.first,
isLoading: false, isLoading: false,
); );
} catch (e) { } catch (e) {
@@ -157,6 +172,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = state.copyWith( state = state.copyWith(
profile: result.user, profile: result.user,
memberships: result.tenants, memberships: result.tenants,
platformMemberships: result.platformMemberships,
activeTenant: newActive, activeTenant: newActive,
); );
} catch (_) {} } catch (_) {}
@@ -172,11 +188,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
required String tenantId, required String tenantId,
required String companyName, required String companyName,
String? defaultCurrency, String? defaultCurrency,
TenantLocationData? location,
List<String>? workflowOverrides,
}) async { }) async {
await _repo.updateTenant( await _repo.updateTenant(
tenantId, tenantId,
companyName: companyName, companyName: companyName,
defaultCurrency: defaultCurrency, defaultCurrency: defaultCurrency,
companyAddress: location?.address,
city: location?.city,
district: location?.district,
latitude: location?.latitude,
longitude: location?.longitude,
workflowOverrides: workflowOverrides,
); );
await refresh(); await refresh();
} }
@@ -194,8 +218,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
} }
final authProvider = final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier( return AuthNotifier(
onLocaleLoaded: (code) => onLocaleLoaded: (code) =>
ref.read(localeProvider.notifier).setLocale(Locale(code)), ref.read(localeProvider.notifier).setLocale(Locale(code)),
+275 -68
View File
@@ -10,6 +10,7 @@ import '../../models/tenant.dart';
import '../../features/auth/sign_in_screen.dart'; import '../../features/auth/sign_in_screen.dart';
import '../../features/auth/sign_up_screen.dart'; import '../../features/auth/sign_up_screen.dart';
import '../../features/auth/onboarding_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/dashboard/clinic_dashboard_screen.dart';
import '../../features/clinic/jobs/clinic_jobs_screen.dart'; import '../../features/clinic/jobs/clinic_jobs_screen.dart';
import '../../features/clinic/jobs/clinic_job_detail_screen.dart'; import '../../features/clinic/jobs/clinic_job_detail_screen.dart';
@@ -37,6 +38,7 @@ import '../../models/connection.dart';
const routeSignIn = '/sign-in'; const routeSignIn = '/sign-in';
const routeSignUp = '/sign-up'; const routeSignUp = '/sign-up';
const routeOnboarding = '/onboarding'; const routeOnboarding = '/onboarding';
const routeWelcome = '/welcome';
// Clinic routes // Clinic routes
const routeClinicDashboard = '/clinic/dashboard'; const routeClinicDashboard = '/clinic/dashboard';
@@ -65,15 +67,20 @@ const routeLabAi = '/lab/ai';
const routeLabDiscounts = '/lab/discounts'; const routeLabDiscounts = '/lab/discounts';
List<RouteBase> buildRoutes() => [ List<RouteBase> buildRoutes() => [
GoRoute(
path: routeWelcome, builder: (_, __) => const WelcomePricingScreen()),
GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()), GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()),
GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()), GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()),
GoRoute(path: routeOnboarding, builder: (_, __) => const OnboardingScreen()), GoRoute(
path: routeOnboarding, builder: (_, __) => const OnboardingScreen()),
// ── Clinic shell ────────────────────────────────────────────────────── // ── Clinic shell ──────────────────────────────────────────────────────
ShellRoute( ShellRoute(
builder: (context, state, child) => _ClinicShell(child: child), builder: (context, state, child) => _ClinicShell(child: child),
routes: [ routes: [
GoRoute(path: routeClinicDashboard, builder: (_, __) => const ClinicDashboardScreen()), GoRoute(
path: routeClinicDashboard,
builder: (_, __) => const ClinicDashboardScreen()),
GoRoute( GoRoute(
path: routeClinicJobs, path: routeClinicJobs,
builder: (_, __) => const ClinicJobsScreen(), builder: (_, __) => const ClinicJobsScreen(),
@@ -81,7 +88,8 @@ List<RouteBase> buildRoutes() => [
GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()), GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()),
GoRoute( GoRoute(
path: ':jobId', path: ':jobId',
builder: (_, s) => ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!), builder: (_, s) =>
ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!),
), ),
], ],
), ),
@@ -91,15 +99,25 @@ List<RouteBase> buildRoutes() => [
routes: [ routes: [
GoRoute( GoRoute(
path: ':patientId', path: ':patientId',
builder: (_, s) => ClinicPatientDetailScreen(patientId: s.pathParameters['patientId']!), builder: (_, s) => ClinicPatientDetailScreen(
patientId: s.pathParameters['patientId']!),
), ),
], ],
), ),
GoRoute(path: routeClinicConnections, builder: (_, __) => const ClinicConnectionsScreen()), GoRoute(
GoRoute(path: routeClinicFinance, builder: (_, __) => const ClinicFinanceScreen()), path: routeClinicConnections,
GoRoute(path: routeClinicSettings, builder: (_, __) => const ClinicSettingsScreen()), builder: (_, __) => const ClinicConnectionsScreen()),
GoRoute(path: routeClinicReports, builder: (_, __) => const ReportsScreen()), GoRoute(
GoRoute(path: routeClinicAi, builder: (_, __) => const AiChatScreen()), 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<RouteBase> buildRoutes() => [
ShellRoute( ShellRoute(
builder: (context, state, child) => _LabShell(child: child), builder: (context, state, child) => _LabShell(child: child),
routes: [ routes: [
GoRoute(path: routeLabDashboard, builder: (_, __) => const LabDashboardScreen()), GoRoute(
GoRoute(path: routeLabJobsInbound, builder: (_, __) => const LabJobsInboundScreen()), path: routeLabDashboard,
builder: (_, __) => const LabDashboardScreen()),
GoRoute(
path: routeLabJobsInbound,
builder: (_, __) => const LabJobsInboundScreen()),
GoRoute( GoRoute(
path: routeLabJobsAll, path: routeLabJobsAll,
builder: (_, __) => const LabAllJobsScreen(), builder: (_, __) => const LabAllJobsScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: ':jobId', 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( GoRoute(
path: routeLabConnections, path: routeLabConnections,
builder: (_, __) => const LabConnectionsScreen(), builder: (_, __) => const LabConnectionsScreen(),
@@ -141,10 +166,17 @@ List<RouteBase> buildRoutes() => [
), ),
], ],
), ),
GoRoute(path: routeLabDiscounts, builder: (_, __) => const DiscountsScreen()), GoRoute(
GoRoute(path: routeLabFinance, builder: (_, __) => const LabFinanceScreen()), path: routeLabDiscounts,
GoRoute(path: routeLabSettings, builder: (_, __) => const LabSettingsScreen()), builder: (_, __) => const DiscountsScreen()),
GoRoute(path: routeLabReports, builder: (_, __) => const ReportsScreen()), GoRoute(
path: routeLabFinance,
builder: (_, __) => const LabFinanceScreen()),
GoRoute(
path: routeLabSettings,
builder: (_, __) => const LabSettingsScreen()),
GoRoute(
path: routeLabReports, builder: (_, __) => const ReportsScreen()),
GoRoute(path: routeLabAi, builder: (_, __) => const AiChatScreen()), GoRoute(path: routeLabAi, builder: (_, __) => const AiChatScreen()),
], ],
), ),
@@ -216,11 +248,36 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
String _selectedRoute = routeClinicDashboard; String _selectedRoute = routeClinicDashboard;
List<_NavItem> _clinicTopSingles(AppStrings s) => [ 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(
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), route: routeClinicDashboard,
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true), icon: const Icon(Icons.home_outlined),
_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), selectedIcon: const Icon(Icons.home_rounded),
_NavItem(route: routeClinicAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true), 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) => [ List<_NavGroup> _clinicGroups(AppStrings s) => [
@@ -229,22 +286,62 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
icon: Icons.tune_rounded, icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded, selectedIcon: Icons.tune_rounded,
items: [ items: [
_NavItem(route: routeClinicConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true), _NavItem(
_NavItem(route: routeClinicReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true), 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) => [ 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) => [ 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(
_NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), route: routeClinicDashboard,
_NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: s.patientsTitle, visible: (m) => m?.showPatients ?? true), icon: const Icon(Icons.home_outlined),
_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), selectedIcon: const Icon(Icons.home_rounded),
_NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), 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) { List<_SidebarEntry> _allEntries(AppStrings s) {
@@ -265,7 +362,8 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = ref.watch(stringsProvider); final s = ref.watch(stringsProvider);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) { if (isDesktop) {
final entries = _allEntries(s); final entries = _allEntries(s);
@@ -289,7 +387,8 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
// Mobile: only core items in bottom nav // Mobile: only core items in bottom nav
final membership = ref.read(authProvider).activeTenant; final membership = ref.read(authProvider).activeTenant;
final items = _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 flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
final clampedIndex = flatIndex >= 0 ? flatIndex : 0; final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
@@ -317,7 +416,10 @@ class _ClinicShellState extends ConsumerState<_ClinicShell> {
Semantics( Semantics(
label: it.label, label: it.label,
button: true, 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; String _selectedRoute = routeLabDashboard;
List<_NavItem> _labTopSingles(AppStrings s) => [ 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(
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), route: routeLabDashboard,
_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), icon: const Icon(Icons.home_outlined),
_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), selectedIcon: const Icon(Icons.home_rounded),
_NavItem(route: routeLabAi, icon: const Icon(Icons.auto_awesome_outlined), selectedIcon: const Icon(Icons.auto_awesome_rounded), label: s.aiAssistant, visible: (_) => true), 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) => [ List<_NavGroup> _labGroups(AppStrings s) => [
@@ -352,23 +479,68 @@ class _LabShellState extends ConsumerState<_LabShell> {
icon: Icons.tune_rounded, icon: Icons.tune_rounded,
selectedIcon: Icons.tune_rounded, selectedIcon: Icons.tune_rounded,
items: [ items: [
_NavItem(route: routeLabConnections, icon: const Icon(Icons.link_rounded), selectedIcon: const Icon(Icons.link_rounded), label: s.connections, visible: (_) => true), _NavItem(
_NavItem(route: routeLabDiscounts, icon: const Icon(Icons.local_offer_outlined), selectedIcon: const Icon(Icons.local_offer_rounded), label: s.discounts, visible: (_) => true), route: routeLabConnections,
_NavItem(route: routeLabReports, icon: const Icon(Icons.bar_chart_outlined), selectedIcon: const Icon(Icons.bar_chart_rounded), label: s.reports, visible: (_) => true), 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) => [ 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) => [ 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(
_NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: s.jobsTitle, visible: (m) => m?.showJobs ?? true), route: routeLabDashboard,
_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), icon: const Icon(Icons.home_outlined),
_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), selectedIcon: const Icon(Icons.home_rounded),
_NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: s.settings, visible: (_) => true), 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) { List<_SidebarEntry> _allEntries(AppStrings s) {
@@ -389,7 +561,8 @@ class _LabShellState extends ConsumerState<_LabShell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = ref.watch(stringsProvider); final s = ref.watch(stringsProvider);
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) { if (isDesktop) {
final entries = _allEntries(s); final entries = _allEntries(s);
@@ -413,7 +586,8 @@ class _LabShellState extends ConsumerState<_LabShell> {
// Mobile: only core items in bottom nav // Mobile: only core items in bottom nav
final membership = ref.read(authProvider).activeTenant; final membership = ref.read(authProvider).activeTenant;
final items = _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 flatIndex = items.indexWhere((it) => it.route == _selectedRoute);
final clampedIndex = flatIndex >= 0 ? flatIndex : 0; final clampedIndex = flatIndex >= 0 ? flatIndex : 0;
@@ -441,7 +615,10 @@ class _LabShellState extends ConsumerState<_LabShell> {
Semantics( Semantics(
label: it.label, label: it.label,
button: true, 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( decoration: const BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
border: Border(right: BorderSide(color: AppColors.border)), 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: ClipRect(
child: Column( child: Column(
@@ -492,7 +672,8 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
Container( Container(
height: _DesktopSidebar.headerHeight, height: _DesktopSidebar.headerHeight,
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient(colors: [AppColors.primary, AppColors.accent]), gradient: LinearGradient(
colors: [AppColors.primary, AppColors.accent]),
border: Border(bottom: BorderSide(color: AppColors.border)), border: Border(bottom: BorderSide(color: AppColors.border)),
), ),
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -504,15 +685,21 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15), color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9), 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) ...[ if (_open) ...[
const SizedBox(width: 10), const SizedBox(width: 10),
const Text( const Text(
'DLS', '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( child: Column(
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
for (final entry in widget.entries) for (final entry in widget.entries) _buildEntry(entry),
_buildEntry(entry),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -545,17 +731,24 @@ class _DesktopSidebarState extends State<_DesktopSidebar> {
child: SizedBox( child: SizedBox(
height: 48, height: 48,
child: Row( child: Row(
mainAxisAlignment: _open ? MainAxisAlignment.start : MainAxisAlignment.center, mainAxisAlignment: _open
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [ children: [
if (_open) const SizedBox(width: 20), if (_open) const SizedBox(width: 20),
AnimatedRotation( AnimatedRotation(
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
turns: _open ? 0.5 : 0, 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) ...[ if (_open) ...[
const SizedBox(width: 8), 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: [ children: [
IconTheme( IconTheme(
data: IconThemeData( data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary, color: selected
? AppColors.primary
: AppColors.textSecondary,
size: 20, size: 20,
), ),
child: selected ? selectedIcon : icon, child: selected ? selectedIcon : icon,
@@ -639,8 +834,11 @@ class _SidebarItem extends StatelessWidget {
label, label,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500, fontWeight:
color: selected ? AppColors.primary : AppColors.textSecondary, selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? AppColors.primary
: AppColors.textSecondary,
), ),
), ),
], ],
@@ -649,7 +847,9 @@ class _SidebarItem extends StatelessWidget {
: Center( : Center(
child: IconTheme( child: IconTheme(
data: IconThemeData( data: IconThemeData(
color: selected ? AppColors.primary : AppColors.textSecondary, color: selected
? AppColors.primary
: AppColors.textSecondary,
size: 20, size: 20,
), ),
child: selected ? selectedIcon : icon, child: selected ? selectedIcon : icon,
@@ -740,7 +940,9 @@ class _SidebarGroupState extends State<_SidebarGroup> {
child: Icon( child: Icon(
isSelected ? widget.group.selectedIcon : widget.group.icon, isSelected ? widget.group.selectedIcon : widget.group.icon,
size: 20, size: 20,
color: isSelected ? AppColors.primary : AppColors.textSecondary, color: isSelected
? AppColors.primary
: AppColors.textSecondary,
), ),
), ),
), ),
@@ -768,9 +970,13 @@ class _SidebarGroupState extends State<_SidebarGroup> {
child: Row( child: Row(
children: [ children: [
Icon( Icon(
isSelected ? widget.group.selectedIcon : widget.group.icon, isSelected
? widget.group.selectedIcon
: widget.group.icon,
size: 20, size: 20,
color: isSelected ? AppColors.primary : AppColors.textSecondary, color: isSelected
? AppColors.primary
: AppColors.textSecondary,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -803,7 +1009,8 @@ class _SidebarGroupState extends State<_SidebarGroup> {
// Sub-items (animated expand/collapse) // Sub-items (animated expand/collapse)
AnimatedCrossFade( AnimatedCrossFade(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, crossFadeState:
_expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond,
firstChild: Column( firstChild: Column(
children: [ children: [
for (final item in widget.group.items) for (final item in widget.group.items)
+4 -3
View File
@@ -17,18 +17,19 @@ final routerProvider = Provider<GoRouter>((ref) {
return GoRouter( return GoRouter(
refreshListenable: notifier, refreshListenable: notifier,
initialLocation: routeSignIn, initialLocation: routeWelcome,
redirect: (context, state) { redirect: (context, state) {
final auth = ref.read(authProvider); final auth = ref.read(authProvider);
if (auth.isLoading) return null; if (auth.isLoading) return null;
final loc = state.matchedLocation; final loc = state.matchedLocation;
final onLoginOrRegister = loc == routeSignIn || loc == routeSignUp; final onLoginOrRegister =
loc == routeSignIn || loc == routeSignUp || loc == routeWelcome;
final onAuthPage = onLoginOrRegister || loc == routeOnboarding; final onAuthPage = onLoginOrRegister || loc == routeOnboarding;
if (!auth.isAuthenticated) { if (!auth.isAuthenticated) {
return onAuthPage ? null : routeSignIn; return onAuthPage ? null : routeWelcome;
} }
// Authenticated but no tenant → onboarding // Authenticated but no tenant → onboarding
+20 -4
View File
@@ -47,7 +47,23 @@ class FinanceService {
); );
} }
Future<void> markJobPaid(String jobId) async { Future<void> reportJobPayment(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList(
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<void> confirmJobPayment(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList( final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId"', filter: 'job_id = "$jobId"',
batch: 200, batch: 200,
@@ -66,7 +82,8 @@ class FinanceService {
Future<void> deletePendingEntriesForJob(String jobId) async { Future<void> deletePendingEntriesForJob(String jobId) async {
final existing = await _pb.collection('finance_entries').getFullList( final existing = await _pb.collection('finance_entries').getFullList(
filter: 'job_id = "$jobId" && status = "pending"', filter:
'job_id = "$jobId" && (status = "pending" || status = "reported")',
batch: 200, batch: 200,
); );
for (final record in existing) { for (final record in existing) {
@@ -88,8 +105,7 @@ class FinanceService {
try { try {
match = existing.firstWhere( match = existing.firstWhere(
(record) => (record) =>
record.data['tenant_id'] == tenantId && record.data['tenant_id'] == tenantId && record.data['type'] == type,
record.data['type'] == type,
); );
} catch (_) { } catch (_) {
match = null; match = null;
+13 -3
View File
@@ -19,6 +19,7 @@ class JobHistoryEntry {
enum JobHistoryAction { enum JobHistoryAction {
accepted, accepted,
stepCompleted,
handedToClinic, handedToClinic,
approved, approved,
revisionRequested, revisionRequested,
@@ -29,6 +30,7 @@ enum JobHistoryAction {
extension JobHistoryActionExt on JobHistoryAction { extension JobHistoryActionExt on JobHistoryAction {
String get value => switch (this) { String get value => switch (this) {
JobHistoryAction.accepted => 'accepted', JobHistoryAction.accepted => 'accepted',
JobHistoryAction.stepCompleted => 'step_completed',
JobHistoryAction.handedToClinic => 'handed_to_clinic', JobHistoryAction.handedToClinic => 'handed_to_clinic',
JobHistoryAction.approved => 'approved', JobHistoryAction.approved => 'approved',
JobHistoryAction.revisionRequested => 'revision_requested', JobHistoryAction.revisionRequested => 'revision_requested',
@@ -43,8 +45,7 @@ class JobHistoryService {
PocketBase get _pb => PocketBaseClient.instance.pb; PocketBase get _pb => PocketBaseClient.instance.pb;
String get _currentUserId => String get _currentUserId => _pb.authStore.record?.id ?? '';
(_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? '';
Future<List<JobHistoryEntry>> listForJob(String jobId) async { Future<List<JobHistoryEntry>> listForJob(String jobId) async {
try { try {
@@ -58,6 +59,7 @@ class JobHistoryService {
final s = v as String?; final s = v as String?;
return (s == null || s.isEmpty) ? null : s; return (s == null || s.isEmpty) ? null : s;
} }
return JobHistoryEntry( return JobHistoryEntry(
id: j['id'] as String, id: j['id'] as String,
action: _parseAction(j['action_type'] as String? ?? ''), action: _parseAction(j['action_type'] as String? ?? ''),
@@ -65,7 +67,8 @@ class JobHistoryService {
note: str(j['note']), note: str(j['note']),
createdAt: DateTime.parse(j['created'] as String), 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 (_) { } catch (_) {
return []; return [];
} }
@@ -73,6 +76,7 @@ class JobHistoryService {
static JobHistoryAction _parseAction(String s) => switch (s) { static JobHistoryAction _parseAction(String s) => switch (s) {
'accepted' => JobHistoryAction.accepted, 'accepted' => JobHistoryAction.accepted,
'step_completed' => JobHistoryAction.stepCompleted,
'handed_to_clinic' => JobHistoryAction.handedToClinic, 'handed_to_clinic' => JobHistoryAction.handedToClinic,
'approved' => JobHistoryAction.approved, 'approved' => JobHistoryAction.approved,
'revision_requested' => JobHistoryAction.revisionRequested, 'revision_requested' => JobHistoryAction.revisionRequested,
@@ -81,12 +85,18 @@ class JobHistoryService {
}; };
static JobStep _parseStep(String s) => switch (s) { static JobStep _parseStep(String s) => switch (s) {
'olcu_kontrol' => JobStep.olcuKontrol,
'dijital_tasarim' => JobStep.dijitalTasarim,
'model_hazirlik' => JobStep.modelHazirlik,
'alt_yapi_prova' => JobStep.altYapiProva, 'alt_yapi_prova' => JobStep.altYapiProva,
'ust_yapi_prova' => JobStep.ustYapiProva, 'ust_yapi_prova' => JobStep.ustYapiProva,
'mum_prova' => JobStep.mumProva, 'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva, 'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva, 'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva, 'kron_prova' => JobStep.kronProva,
'fotograf_onay' => JobStep.fotografOnay,
'kalite_kontrol' => JobStep.kaliteKontrol,
'teslim_oncesi_kontrol' => JobStep.teslimOncesiKontrol,
'cila_bitim' => JobStep.cilaBitim, 'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu, _ => JobStep.olcu,
}; };
+17 -5
View File
@@ -16,21 +16,33 @@ class RealtimeService {
required void Function(RecordSubscriptionEvent) onEvent, required void Function(RecordSubscriptionEvent) onEvent,
}) { }) {
UnsubFn? cancel; 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; cancel = fn;
}); }).catchError((_) {});
return () async { return () async {
if (disposed) return;
disposeRequested = true;
try { try {
final fn = cancel; final fn = cancel;
if (fn != null) { if (fn != null) {
disposed = true;
await fn(); await fn();
} else {
await _pb.collection(collection).unsubscribe(topic);
} }
} catch (_) { } 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.
} }
}; };
} }
@@ -11,12 +11,22 @@ class OnboardingRepository {
Future<AuthResult> createTenantAndJoin({ Future<AuthResult> createTenantAndJoin({
required String kind, required String kind,
required String companyName, required String companyName,
String? companyAddress,
String? city,
String? district,
double? latitude,
double? longitude,
}) async { }) async {
final userId = _pb.authStore.record!.id; final userId = _pb.authStore.record!.id;
final tenant = await _pb.collection('tenants').create(body: { final tenant = await _pb.collection('tenants').create(body: {
'kind': kind, 'kind': kind,
'company_name': companyName, 'company_name': companyName,
'company_address': companyAddress,
'city': city,
'district': district,
'latitude': latitude,
'longitude': longitude,
'status': 'active', 'status': 'active',
'default_currency': 'TRY', 'default_currency': 'TRY',
}); });
+79 -6
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.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'; import 'onboarding_repository.dart';
class OnboardingScreen extends ConsumerStatefulWidget { class OnboardingScreen extends ConsumerStatefulWidget {
@@ -15,6 +18,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController(); final _nameCtrl = TextEditingController();
String _selectedKind = 'clinic'; String _selectedKind = 'clinic';
TenantLocationData? _location;
bool _loading = false; bool _loading = false;
String? _error; String? _error;
late AnimationController _animCtrl; late AnimationController _animCtrl;
@@ -53,6 +57,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
final result = await OnboardingRepository.instance.createTenantAndJoin( final result = await OnboardingRepository.instance.createTenantAndJoin(
kind: _selectedKind, kind: _selectedKind,
companyName: _nameCtrl.text.trim(), companyName: _nameCtrl.text.trim(),
companyAddress: _location?.address,
city: _location?.city,
district: _location?.district,
latitude: _location?.latitude,
longitude: _location?.longitude,
); );
if (!mounted) return; if (!mounted) return;
ref.read(authProvider.notifier).setActiveTenant(result.tenants.first); ref.read(authProvider.notifier).setActiveTenant(result.tenants.first);
@@ -249,6 +258,70 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
const SizedBox(height: 24), 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 // Company name
Text( Text(
'Kurum Adı', 'Kurum Adı',
@@ -287,8 +360,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide( borderSide:
color: cs.error, width: 1.5), BorderSide(color: cs.error, width: 1.5),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@@ -340,7 +413,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen>
const SizedBox(height: 28), const SizedBox(height: 28),
FilledButton( FilledButton(
onPressed: _loading ? null : _create, onPressed: _loading || _location == null
? null
: _create,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52), minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -431,9 +506,7 @@ class _KindCard extends StatelessWidget {
child: Icon( child: Icon(
icon, icon,
size: 26, size: 26,
color: selected color: selected ? const Color(0xFF4F46E5) : cs.onSurfaceVariant,
? const Color(0xFF4F46E5)
: cs.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
+47 -41
View File
@@ -40,9 +40,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
Future<void> _submit() async { Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
await ref await ref.read(authProvider.notifier).signIn(
.read(authProvider.notifier)
.signIn(
_emailCtrl.text.trim(), _emailCtrl.text.trim(),
_passCtrl.text, _passCtrl.text,
rememberSession: _rememberMe, rememberSession: _rememberMe,
@@ -98,10 +96,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
), ),
], ],
), ),
child: child: const Center(
const Center(child: ToothLogo(size: 34, color: Colors.white)), 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), const SizedBox(height: 24),
Center( Center(
@@ -114,7 +115,10 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
letterSpacing: -0.5, 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), const SizedBox(height: 6),
Center( Center(
child: Text( child: Text(
@@ -169,10 +173,18 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
), ),
), ),
), ),
const Positioned(top: -140, left: -140, child: _Ring(size: 520, opacity: 0.06)), const Positioned(
const Positioned(bottom: -100, right: -100, child: _Ring(size: 400, opacity: 0.05)), top: -140,
const Positioned(top: 160, right: 60, child: _Ring(size: 100, opacity: 0.09)), left: -140,
const Positioned(bottom: 220, left: 60, child: _Ring(size: 70, opacity: 0.07)), 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(
padding: padding:
const EdgeInsets.symmetric(horizontal: 64, vertical: 52), const EdgeInsets.symmetric(horizontal: 64, vertical: 52),
@@ -356,7 +368,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
(v == null || v.trim().isEmpty) ? s.emailRequired : null, (v == null || v.trim().isEmpty) ? s.emailRequired : null,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
_Field( _Field(
controller: _passCtrl, controller: _passCtrl,
label: s.password, label: s.password,
@@ -377,27 +388,17 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
validator: (v) => validator: (v) =>
(v == null || v.isEmpty) ? s.passwordRequired : null, (v == null || v.isEmpty) ? s.passwordRequired : null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
CheckboxListTile(
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: auth.isLoading
? null
: () => setState(() => _rememberMe = !_rememberMe),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Checkbox(
value: _rememberMe, value: _rememberMe,
onChanged: auth.isLoading onChanged: auth.isLoading
? null ? null
: (value) => setState(() => _rememberMe = value ?? true), : (value) => setState(() => _rememberMe = value ?? true),
activeColor: const Color(0xFF0D4C85), activeColor: const Color(0xFF0D4C85),
), controlAffinity: ListTileControlAffinity.leading,
const SizedBox(width: 6), contentPadding: EdgeInsets.zero,
Text( dense: true,
title: Text(
s.rememberMe, s.rememberMe,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
@@ -405,16 +406,11 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
],
), ),
),
),
if (auth.error != null) ...[ if (auth.error != null) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
Container( Container(
padding: padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEF2F2), color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@@ -437,9 +433,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
), ),
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: const LinearGradient(
@@ -488,7 +482,9 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
// ── Sign-up link ─────────────────────────────────────────────────────────── // ── Sign-up link ───────────────────────────────────────────────────────────
Widget _buildSignUpLink(BuildContext context, AppStrings s) { Widget _buildSignUpLink(BuildContext context, AppStrings s) {
return Row( return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
@@ -509,6 +505,16 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
), ),
), ),
], ],
),
TextButton.icon(
onPressed: () => context.go(routeWelcome),
icon: const Icon(Icons.workspace_premium_outlined, size: 18),
label: const Text('Paketleri İncele'),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
),
],
); );
} }
} }
@@ -726,7 +732,8 @@ class _DashboardPreviewCard extends StatelessWidget {
const SizedBox(height: 18), const SizedBox(height: 18),
const Row( const Row(
children: [ children: [
_StatChip(value: '24', label: 'Aktif', color: Color(0xFF60A5FA)), _StatChip(
value: '24', label: 'Aktif', color: Color(0xFF60A5FA)),
SizedBox(width: 8), SizedBox(width: 8),
_StatChip( _StatChip(
value: '8', label: 'Bekliyor', color: Color(0xFFFBBF24)), value: '8', label: 'Bekliyor', color: Color(0xFFFBBF24)),
@@ -915,8 +922,7 @@ class _Field extends StatelessWidget {
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5),
const BorderSide(color: AppColors.cancelled, width: 1.5),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -924,8 +930,8 @@ class _Field extends StatelessWidget {
), ),
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14), const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
labelStyle: const TextStyle( labelStyle:
color: AppColors.textSecondary, fontSize: 14), const TextStyle(color: AppColors.textSecondary, fontSize: 14),
), ),
); );
} }
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart'; import '../../../core/api/pocketbase_client.dart';
import '../../../models/connection.dart'; import '../../../models/connection.dart';
import '../../../models/tenant.dart';
class ClinicConnectionsRepository { class ClinicConnectionsRepository {
ClinicConnectionsRepository._(); ClinicConnectionsRepository._();
@@ -30,11 +31,26 @@ class ClinicConnectionsRepository {
return Connection.fromJson(record.toJson()); return Connection.fromJson(record.toJson());
} }
Future<List<Map<String, dynamic>>> searchLabs(String query) async { Future<List<Tenant>> searchLabs({
final result = await _pb.collection('tenants').getList( String query = '',
filter: 'kind = "lab" && company_name ~ "$query"', String? city,
perPage: 20, }) 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")',
); );
return result.items.map((r) => r.toJson()).toList(); } else if (normalizedCity.isNotEmpty) {
filterParts.add('city = "$normalizedCity"');
}
final result = await _pb.collection('tenants').getList(
filter: filterParts.join(' && '),
perPage: 100,
);
return result.items.map((r) => Tenant.fromJson(r.toJson())).toList();
} }
} }
@@ -1,9 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package: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/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../models/connection.dart'; import '../../../models/connection.dart';
import '../../../models/tenant.dart';
import 'clinic_connections_repository.dart'; import 'clinic_connections_repository.dart';
class ClinicConnectionsScreen extends ConsumerStatefulWidget { class ClinicConnectionsScreen extends ConsumerStatefulWidget {
@@ -27,19 +34,19 @@ class _ClinicConnectionsScreenState
void _load() { void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() { setState(() {
_future = ClinicConnectionsRepository.instance _future = ClinicConnectionsRepository.instance.listConnections(tenantId);
.listConnections(tenantId);
}); });
} }
void _showSearchDialog() { void _showSearchDialog() {
final clinicTenant = ref.read(authProvider).activeTenant!.tenant;
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => _LabSearchDialog( builder: (ctx) => _LabSearchDialog(
clinicTenant: clinicTenant,
onRequested: (labId, labName) async { onRequested: (labId, labName) async {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
final tenantId = final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
ref.read(authProvider).activeTenant!.tenant.id;
try { try {
await ClinicConnectionsRepository.instance.requestConnection( await ClinicConnectionsRepository.instance.requestConnection(
clinicTenantId: tenantId, clinicTenantId: tenantId,
@@ -49,8 +56,8 @@ class _ClinicConnectionsScreenState
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('$labName\'a bağlantı talebi gönderildi.'),
'$labName\'a bağlantı talebi gönderildi.')), ),
); );
} }
} catch (e) { } catch (e) {
@@ -86,7 +93,8 @@ class _ClinicConnectionsScreenState
builder: (ctx, snap) { builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return const Center( return const Center(
child: CircularProgressIndicator(color: AppColors.accent)); child: CircularProgressIndicator(color: AppColors.accent),
);
} }
if (snap.hasError) { if (snap.hasError) {
return Center( return Center(
@@ -98,14 +106,19 @@ class _ClinicConnectionsScreenState
height: 64, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cancelledBg, color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(16)), borderRadius: BorderRadius.circular(16),
child: const Icon(Icons.wifi_off_rounded, ),
color: AppColors.cancelled, size: 30), child: const Icon(
Icons.wifi_off_rounded,
color: AppColors.cancelled,
size: 30,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Hata: ${snap.error}', Text(
style: 'Hata: ${snap.error}',
const TextStyle(color: AppColors.textSecondary)), style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
onPressed: _load, onPressed: _load,
@@ -127,9 +140,13 @@ class _ClinicConnectionsScreenState
height: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.inProgressBg, color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)), borderRadius: BorderRadius.circular(20),
child: const Icon(Icons.link_off, ),
color: AppColors.inProgress, size: 32), child: const Icon(
Icons.link_off,
color: AppColors.inProgress,
size: 32,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
@@ -137,7 +154,8 @@ class _ClinicConnectionsScreenState
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary), color: AppColors.textPrimary,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton.icon( FilledButton.icon(
@@ -168,8 +186,10 @@ class _ClinicConnectionsScreenState
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.04), color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2)) offset: const Offset(0, 2),
]), ),
],
),
child: Row( child: Row(
children: [ children: [
Container( Container(
@@ -177,9 +197,13 @@ class _ClinicConnectionsScreenState
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusBg, color: statusBg,
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12),
child: Icon(Icons.science_outlined, ),
color: statusColor, size: 22), child: Icon(
Icons.science_outlined,
color: statusColor,
size: 22,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -191,7 +215,8 @@ class _ClinicConnectionsScreenState
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary), color: AppColors.textPrimary,
),
), ),
if (conn.dateCreated != null) ...[ if (conn.dateCreated != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -199,7 +224,8 @@ class _ClinicConnectionsScreenState
_formatDate(conn.dateCreated!), _formatDate(conn.dateCreated!),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textSecondary), color: AppColors.textSecondary,
),
), ),
], ],
], ],
@@ -259,6 +285,7 @@ class _ClinicConnectionsScreenState
class _StatusChip extends StatelessWidget { class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status}); const _StatusChip({required this.status});
final ConnectionStatus status; final ConnectionStatus status;
@override @override
@@ -306,7 +333,12 @@ class _StatusChip extends StatelessWidget {
} }
class _LabSearchDialog extends StatefulWidget { 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; final void Function(String labId, String labName) onRequested;
@override @override
@@ -314,128 +346,687 @@ class _LabSearchDialog extends StatefulWidget {
} }
class _LabSearchDialogState extends State<_LabSearchDialog> { 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(); final _searchController = TextEditingController();
List<Map<String, dynamic>> _results = []; Timer? _searchDebounce;
List<_LabSearchItem> _results = [];
bool _isLoading = false; bool _isLoading = false;
bool _searched = 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 @override
void dispose() { void dispose() {
_searchDebounce?.cancel();
_searchController.dispose(); _searchController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _search() async { Future<void> _search() async {
final query = _searchController.text.trim(); final query = _searchController.text.trim();
if (query.isEmpty) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_searched = true; _searched = true;
_error = null;
}); });
try { try {
final results = final results = await ClinicConnectionsRepository.instance.searchLabs(
await ClinicConnectionsRepository.instance.searchLabs(query); 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(() { setState(() {
_results = results; _results = mapped;
_selectedLabId = mapped.isNotEmpty ? mapped.first.tenant.id : null;
_isLoading = false; _isLoading = false;
}); });
await _refreshMarkers();
await _moveMapToResults();
} catch (e) { } catch (e) {
setState(() => _isLoading = false); setState(() {
if (mounted) { _isLoading = false;
ScaffoldMessenger.of(context).showSnackBar( _error = e.toString();
SnackBar(content: Text('Hata: $e')), });
}
}
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<void> _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<void> _refreshMarkers() async {
final controller = _mapController;
if (controller == null || !_styleReady) return;
await controller.clearCircles();
final circles = <CircleOptions>[
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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( final selectedLab = _selectedLab;
title: const Text('Laboratuvar Bul'),
content: SizedBox( return Dialog(
width: double.maxFinite, 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( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Row(
children: [ children: [
Expanded( const Expanded(
child: TextField( child: Text(
controller: _searchController, 'Laboratuvar Bul',
decoration: const InputDecoration( style: TextStyle(
hintText: 'Lab adı ile arayın...', fontSize: 20,
contentPadding: EdgeInsets.symmetric( fontWeight: FontWeight.w700,
horizontal: 12, vertical: 8), color: AppColors.textPrimary,
),
onSubmitted: (_) => _search(),
), ),
), ),
const SizedBox(width: 8), ),
FilledButton( IconButton(
onPressed: _search, onPressed: () => Navigator.of(context).pop(),
child: const Text('Ara'), icon: const Icon(Icons.close_rounded),
), ),
], ],
), ),
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, Padding(
style: const TextStyle( padding: const EdgeInsets.symmetric(horizontal: 20),
fontWeight: FontWeight.w600, child: Column(
color: AppColors.textPrimary)), children: [
subtitle: lab['member_number'] != null TextField(
? Text('No: ${lab['member_number']}', controller: _searchController,
style: const TextStyle( decoration: InputDecoration(
color: AppColors.textSecondary)) hintText: 'Lab adı, şehir veya ilçe ile arayın...',
: null, prefixIcon: const Icon(Icons.search_rounded),
onTap: () => suffixIcon: _isLoading
widget.onRequested(lab['id'] as String, name), ? 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'),
),
],
],
), ),
], ],
), ),
), ),
actions: [ const SizedBox(height: 14),
TextButton( Expanded(
onPressed: () => Navigator.of(context).pop(), child: Padding(
child: const Text('İptal'), 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(height: 14),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: _buildResultsList(),
),
),
],
),
),
);
}
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 = <Widget>[
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'),
),
],
),
],
),
),
);
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.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/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/tooth_logo.dart'; import '../../../core/widgets/tooth_logo.dart';
import '../../shared/location_completion_banner.dart';
import '../../../models/job.dart'; import '../../../models/job.dart';
import '../jobs/clinic_jobs_repository.dart'; import '../jobs/clinic_jobs_repository.dart';
import '../patients/clinic_patients_repository.dart'; import '../patients/clinic_patients_repository.dart';
@@ -23,28 +25,48 @@ class ClinicDashboardScreen extends ConsumerStatefulWidget {
class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> { class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
late Future<_DashboardData> _future; late Future<_DashboardData> _future;
late UnsubFn _unsub; UnsubFn? _unsub;
final Map<String, bool> _actingJobs = {}; final Map<String, bool> _actingJobs = {};
Timer? _reloadDebounce;
String? _subscribedTenantId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); _load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id; _ensureRealtimeSubscription();
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "clinic_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
} }
@override @override
void dispose() { void dispose() {
_unsub(); _reloadDebounce?.cancel();
_unsub?.call();
super.dispose(); 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() { void _load() {
_ensureRealtimeSubscription();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() { setState(() {
_future = _loadAll(tenantId); _future = _loadAll(tenantId);
@@ -58,7 +80,9 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
title: Text(job.patientCode), title: Text(job.patientCode),
content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'), content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal')),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.success), style: FilledButton.styleFrom(backgroundColor: AppColors.success),
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
@@ -73,7 +97,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
await ClinicJobsRepository.instance.approveAtClinic(job.id, job); await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
_load(); _load();
} catch (e) { } 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 { } finally {
if (mounted) setState(() => _actingJobs.remove(job.id)); if (mounted) setState(() => _actingJobs.remove(job.id));
} }
@@ -84,9 +111,12 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text(job.patientCode), 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal')),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
child: const Text('Teslim Aldım'), child: const Text('Teslim Aldım'),
@@ -100,7 +130,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
await ClinicJobsRepository.instance.markDelivered(job.id, job); await ClinicJobsRepository.instance.markDelivered(job.id, job);
_load(); _load();
} catch (e) { } 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 { } finally {
if (mounted) setState(() => _actingJobs.remove(job.id)); if (mounted) setState(() => _actingJobs.remove(job.id));
} }
@@ -112,18 +145,25 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
final lastMonthStart = DateTime(now.year, now.month - 1, 1); final lastMonthStart = DateTime(now.year, now.month - 1, 1);
final results = await Future.wait([ final results = await Future.wait([
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['pending'], limit: 200), ClinicJobsRepository.instance
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['in_progress'], limit: 200), .listOutbound(tenantId, statuses: ['pending'], limit: 200),
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['sent'], 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), ClinicJobsRepository.instance.listOutbound(tenantId, limit: 5),
ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200), ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200),
]); ]);
final thisMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart); final thisMonth = await ClinicJobsRepository.instance
final lastMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart); .countDelivered(tenantId, from: thisMonthStart);
final lastMonth = await ClinicJobsRepository.instance
.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart);
final inProgressJobs = results[1] as List<Job>; final inProgressJobs = results[1] as List<Job>;
final sentJobs = results[2] as List<Job>; final sentJobs = results[2] as List<Job>;
final provaAtClinic = inProgressJobs.where((j) => j.location == JobLocation.atClinic).toList(); final provaAtClinic = inProgressJobs
.where((j) => j.location == JobLocation.atClinic)
.toList();
final actionJobs = [...provaAtClinic, ...sentJobs]; final actionJobs = [...provaAtClinic, ...sentJobs];
return _DashboardData( return _DashboardData(
@@ -140,8 +180,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final companyName = _ensureRealtimeSubscription();
ref.watch(authProvider).activeTenant?.tenant.companyName ?? ''; final activeTenant = ref.watch(authProvider).activeTenant?.tenant;
final companyName = activeTenant?.companyName ?? '';
final showLocationWarning = activeTenant?.hasLocation != true;
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
@@ -159,16 +201,31 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return _DashboardSkeleton(companyName: companyName, hPad: hPad); return _DashboardSkeleton(
companyName: companyName, hPad: hPad);
} }
if (snap.hasError) { if (snap.hasError) {
return _ErrorBody(onRetry: _load); return _ErrorBody(onRetry: _load);
} }
final data = snap.data!; final data = snap.data!;
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
_DashboardHeader(companyName: companyName), _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) if (isDesktop)
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
@@ -186,14 +243,18 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: _MonthlyReportSection(data: data) 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( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: _GamificationRow(data: data) 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<ClinicDashboardScreen> {
minimumSize: const Size(double.infinity, 52), minimumSize: const Size(double.infinity, 52),
backgroundColor: AppColors.accent, 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) if (data.actionJobs.isNotEmpty)
@@ -220,7 +284,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
actingJobs: _actingJobs, actingJobs: _actingJobs,
onApprove: _approveAtClinic, onApprove: _approveAtClinic,
onDeliver: _markDelivered, 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( SliverPadding(
@@ -235,7 +302,8 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
onPressed: () => context.go(routeClinicJobs), onPressed: () => context.go(routeClinicJobs),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.accent, foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8), padding:
const EdgeInsets.symmetric(horizontal: 8),
), ),
child: const Text('Tümünü Gör'), child: const Text('Tümünü Gör'),
), ),
@@ -251,7 +319,8 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24), padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24),
sliver: SliverList.separated( sliver: SliverList.separated(
itemCount: data.recentJobs.length, itemCount: data.recentJobs.length,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) =>
const SizedBox(height: 10),
itemBuilder: (ctx, i) => itemBuilder: (ctx, i) =>
_JobCard(job: data.recentJobs[i]) _JobCard(job: data.recentJobs[i])
.animate(delay: (i * 60).ms) .animate(delay: (i * 60).ms)
@@ -319,17 +388,31 @@ class _ActionSection extends StatelessWidget {
Row( Row(
children: [ children: [
Container( Container(
width: 26, height: 26, width: 26,
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(7)), height: 26,
child: const Icon(Icons.priority_high_rounded, size: 15, color: Colors.white), 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), 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), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(
child: Text('${jobs.length}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: Colors.white)), color: AppColors.pending,
borderRadius: BorderRadius.circular(10)),
child: Text('${jobs.length}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: Colors.white)),
), ),
], ],
), ),
@@ -341,7 +424,10 @@ class _ActionSection extends StatelessWidget {
acting: actingJobs[entry.value.id] == true, acting: actingJobs[entry.value.id] == true,
onApprove: () => onApprove(entry.value), onApprove: () => onApprove(entry.value),
onDeliver: () => onDeliver(entry.value), onDeliver: () => onDeliver(entry.value),
).animate(delay: (entry.key * 50).ms).fadeIn(duration: 250.ms).slideY(begin: 0.08, end: 0), )
.animate(delay: (entry.key * 50).ms)
.fadeIn(duration: 250.ms)
.slideY(begin: 0.08, end: 0),
)), )),
], ],
); );
@@ -361,7 +447,9 @@ class _ActionJobCard extends StatelessWidget {
final VoidCallback onApprove; final VoidCallback onApprove;
final VoidCallback onDeliver; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -369,7 +457,8 @@ class _ActionJobCard extends StatelessWidget {
final borderColor = isProva ? AppColors.pending : AppColors.accent; final borderColor = isProva ? AppColors.pending : AppColors.accent;
final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg; final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg;
final iconColor = isProva ? AppColors.pending : AppColors.accent; final iconColor = isProva ? AppColors.pending : AppColors.accent;
final icon = isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined; final icon =
isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined;
final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor'; final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor';
return Semantics( return Semantics(
@@ -385,8 +474,14 @@ class _ActionJobCard extends StatelessWidget {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor.withValues(alpha: 0.45), width: 1.5), border: Border.all(
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 3))], 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -396,8 +491,11 @@ class _ActionJobCard extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 40, height: 40, width: 40,
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(11)), height: 40,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(11)),
child: Icon(icon, color: iconColor, size: 19), child: Icon(icon, color: iconColor, size: 19),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -405,21 +503,34 @@ class _ActionJobCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 2),
Text( Text(
'${job.prostheticType.label} · ${job.labName ?? 'Lab'}', '${job.prostheticType.label} · ${job.labName ?? 'Lab'}',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), style: const TextStyle(
maxLines: 1, overflow: TextOverflow.ellipsis, fontSize: 12, color: AppColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8)), horizontal: 8, vertical: 3),
child: Text(statusLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: iconColor)), 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( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor.withValues(alpha: 0.45), 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), padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: acting child: acting
? const Center( ? const Center(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(vertical: 4), 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 : isProva
@@ -442,27 +559,38 @@ class _ActionJobCard extends StatelessWidget {
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: onApprove, onPressed: onApprove,
icon: const Icon(Icons.check_circle_outline, size: 15), icon: const Icon(Icons.check_circle_outline,
label: const Text('Onayla', style: TextStyle(fontSize: 13)), size: 15),
label: const Text('Onayla',
style: TextStyle(fontSize: 13)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize:
padding: const EdgeInsets.symmetric(horizontal: 12), MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(
horizontal: 12),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => context.push('/clinic/jobs/${job.id}'), onPressed: () =>
icon: const Icon(Icons.open_in_new_rounded, size: 14), context.push('/clinic/jobs/${job.id}'),
label: const Text('Detay', style: TextStyle(fontSize: 13)), icon: const Icon(Icons.open_in_new_rounded,
size: 14),
label: const Text('Detay',
style: TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize:
padding: const EdgeInsets.symmetric(horizontal: 12), MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(
horizontal: 12),
foregroundColor: AppColors.pending, 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( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: onDeliver, onPressed: onDeliver,
icon: const Icon(Icons.inventory_2_outlined, size: 15), icon: const Icon(Icons.inventory_2_outlined,
label: const Text('Teslim Aldım', style: TextStyle(fontSize: 13)), size: 15),
label: const Text('Teslim Aldım',
style: TextStyle(fontSize: 13)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize:
padding: const EdgeInsets.symmetric(horizontal: 12), MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(
horizontal: 12),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => context.push('/clinic/jobs/${job.id}'), onPressed: () =>
icon: const Icon(Icons.open_in_new_rounded, size: 14), context.push('/clinic/jobs/${job.id}'),
label: const Text('Detay', style: TextStyle(fontSize: 13)), icon: const Icon(Icons.open_in_new_rounded,
size: 14),
label: const Text('Detay',
style: TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize:
padding: const EdgeInsets.symmetric(horizontal: 12), MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(
horizontal: 12),
foregroundColor: AppColors.accent, 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: [ children: [
Row( Row(
children: [ 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), 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), const SizedBox(height: 12),
Row( Row(
children: [ 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), 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), const SizedBox(width: 12),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isUp ? AppColors.successBg : AppColors.cancelledBg, color: isUp ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -548,7 +701,9 @@ class _MonthlyReportSection extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, isUp
? Icons.trending_up_rounded
: Icons.trending_down_rounded,
size: 16, size: 16,
color: isUp ? AppColors.success : AppColors.cancelled, color: isUp ? AppColors.success : AppColors.cancelled,
), ),
@@ -573,7 +728,8 @@ class _MonthlyReportSection extends StatelessWidget {
} }
class _MonthStat 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 String label;
final int value; final int value;
final bool highlighted; final bool highlighted;
@@ -583,14 +739,22 @@ class _MonthStat extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( 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), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 2),
Text( Text(
'$value', '$value',
@@ -617,7 +781,8 @@ class _GamificationRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0); 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( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -632,7 +797,11 @@ class _GamificationRow extends StatelessWidget {
children: [ children: [
const Text('🏆', style: TextStyle(fontSize: 16)), const Text('🏆', style: TextStyle(fontSize: 16)),
const SizedBox(width: 6), 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(), const Spacer(),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
@@ -642,7 +811,10 @@ class _GamificationRow extends StatelessWidget {
), ),
child: Text( child: Text(
'${data.points} puan', '${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: [ children: [
Text( Text(
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi', '${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary), style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary),
), ),
Text( Text(
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı', progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) { if (isDesktop) {
return SliverAppBar( return SliverAppBar(
@@ -712,15 +888,24 @@ class _DashboardHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)), Text('Genel Bakış',
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), 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: [ actions: [
IconButton( IconButton(
onPressed: () => context.go(routeClinicSettings), 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), const SizedBox(width: 8),
], ],
@@ -751,14 +936,26 @@ class _DashboardHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)), Text('DLS',
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis), 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: [ actions: [
IconButton( IconButton(
onPressed: () => context.go(routeClinicSettings), 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( flexibleSpace: FlexibleSpaceBar(
@@ -777,9 +974,19 @@ class _DashboardHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ 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 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 @override
Widget build(BuildContext context) { 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) final c1 = _StatCard(
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); label: 'Bekleyen',
final c2 = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg) value: '$pending',
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); icon: Icons.hourglass_top_rounded,
final c3 = _StatCard(label: 'Toplam Hasta', value: '$patients', icon: Icons.people_outline_rounded, color: AppColors.success, bgColor: AppColors.successBg) color: AppColors.pending,
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); 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. // Wide desktop (≥ 1100px): 4 cards side by side — full lifecycle view.
if (isWideDesktop) { if (isWideDesktop) {
final c4 = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg) final c4 = _StatCard(
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); 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( return Row(
children: [ children: [
Expanded(child: c1), Expanded(child: c1),
@@ -883,8 +1119,8 @@ class _StatCard extends StatelessWidget {
Container( Container(
width: 44, width: 44,
height: 44, height: 44,
decoration: decoration: BoxDecoration(
BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)), color: bgColor, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22), child: Icon(icon, color: color, size: 22),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -1096,8 +1332,7 @@ class _EmptyJobs extends StatelessWidget {
const Text( const Text(
'Yeni iş oluşturduğunuzda\nburada görünecek', 'Yeni iş oluşturduğunuzda\nburada görünecek',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
TextStyle(fontSize: 13, color: AppColors.textSecondary),
), ),
], ],
), ),
@@ -1222,8 +1457,8 @@ class _ShimmerBoxState extends State<_ShimmerBox>
height: widget.height, height: widget.height,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.radius), borderRadius: BorderRadius.circular(widget.radius),
color: Color.lerp(const Color(0xFFE2E8F0), color: Color.lerp(
const Color(0xFFF1F5F9), _anim.value)), const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
), ),
); );
} }
@@ -16,7 +16,11 @@ class ClinicFinanceRepository {
int limit = 30, int limit = 30,
}) async { }) async {
final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"']; 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( final result = await _pb.collection('finance_entries').getList(
page: page, page: page,
@@ -32,7 +36,7 @@ class ClinicFinanceRepository {
final all = await listEntries(tenantId, limit: 200); final all = await listEntries(tenantId, limit: 200);
double pending = 0, paid = 0; double pending = 0, paid = 0;
for (final e in all) { for (final e in all) {
if (e.status == FinanceStatus.pending) { if (e.status.isOpen) {
pending += e.amount; pending += e.amount;
} else { } else {
paid += e.amount; paid += e.amount;
@@ -41,15 +45,17 @@ class ClinicFinanceRepository {
return {'pending': pending, 'paid': paid}; return {'pending': pending, 'paid': paid};
} }
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async { Future<List<CounterpartyFinanceSummary>> byCounterparty(
String tenantId) async {
final entries = await listEntries(tenantId, limit: 300); final entries = await listEntries(tenantId, limit: 300);
final map = <String, CounterpartyFinanceSummary>{}; final map = <String, CounterpartyFinanceSummary>{};
for (final entry in entries) { for (final entry in entries) {
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown'; final key =
entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
final current = map[key]; final current = map[key];
final pending = (current?.pendingAmount ?? 0) + final pending = (current?.pendingAmount ?? 0) +
(entry.status == FinanceStatus.pending ? entry.amount : 0); (entry.status.isOpen ? entry.amount : 0);
final paid = (current?.paidAmount ?? 0) + final paid = (current?.paidAmount ?? 0) +
(entry.status == FinanceStatus.paid ? entry.amount : 0); (entry.status == FinanceStatus.paid ? entry.amount : 0);
map[key] = CounterpartyFinanceSummary( map[key] = CounterpartyFinanceSummary(
@@ -67,16 +73,16 @@ class ClinicFinanceRepository {
return list; return list;
} }
Future<void> markPaid(String entryId) async { Future<void> reportPayment(String entryId) async {
final record = await _pb.collection('finance_entries').getOne(entryId); final record = await _pb.collection('finance_entries').getOne(entryId);
final jobId = record.data['job_id']?.toString(); final jobId = record.data['job_id']?.toString();
if (jobId == null || jobId.isEmpty) { if (jobId == null || jobId.isEmpty) {
await _pb.collection('finance_entries').update(entryId, body: { await _pb.collection('finance_entries').update(entryId, body: {
'status': 'paid', 'status': 'reported',
'paid_at': DateTime.now().toIso8601String(), 'paid_at': null,
}); });
return; return;
} }
await FinanceService.instance.markJobPaid(jobId); await FinanceService.instance.reportJobPayment(jobId);
} }
} }
@@ -101,8 +101,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
future: _headerFuture, future: _headerFuture,
builder: (ctx, snap) { builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator( return const LinearProgressIndicator(color: AppColors.accent);
color: AppColors.accent);
} }
final data = snap.data ?? final data = snap.data ??
const _ClinicFinanceHeaderData( const _ClinicFinanceHeaderData(
@@ -117,7 +116,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
children: [ children: [
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
label: s.pendingReceivable, label: 'Açık Borç',
amount: data.summary['pending'] ?? 0.0, amount: data.summary['pending'] ?? 0.0,
currencyCode: currencyCode, currencyCode: currencyCode,
color: AppColors.pending, color: AppColors.pending,
@@ -128,7 +127,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
label: s.collected, label: 'Onaylanan Ödeme',
amount: data.summary['paid'] ?? 0.0, amount: data.summary['paid'] ?? 0.0,
currencyCode: currencyCode, currencyCode: currencyCode,
color: AppColors.success, color: AppColors.success,
@@ -230,8 +229,7 @@ class _SummaryCard extends StatelessWidget {
width: 44, width: 44,
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor, borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22), child: Icon(icon, color: color, size: 22),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -304,12 +302,10 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
switch (widget.sort) { switch (widget.sort) {
case _FinanceSort.newestFirst: case _FinanceSort.newestFirst:
list.sort((a, b) { list.sort((a, b) {
final da = a.dateCreated != null final da =
? DateTime.tryParse(a.dateCreated!) a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
: null; final db =
final db = b.dateCreated != null b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
? DateTime.tryParse(b.dateCreated!)
: null;
if (da == null && db == null) return 0; if (da == null && db == null) return 0;
if (da == null) return 1; if (da == null) return 1;
if (db == null) return -1; if (db == null) return -1;
@@ -323,15 +319,15 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
return list; return list;
} }
Future<void> _markPaid(FinanceEntry entry) async { Future<void> _reportPayment(FinanceEntry entry) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Ödeme Onayı'), title: const Text('Ödeme Bildir'),
content: Text( content: Text(
'${entry.counterpartyName ?? "Bu kayıt"} için ' '${entry.counterpartyName ?? "Bu kayıt"} için '
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu ' '${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
'ödendi olarak işaretlemek istiyor musunuz?', 'laboratuvara ödendi olarak bildirmek istiyor musunuz?',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -340,19 +336,21 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
child: const Text('Ödendi'), child: const Text('Bildir'),
), ),
], ],
), ),
); );
if (confirmed != true || !mounted) return; if (confirmed != true || !mounted) return;
try { try {
await ClinicFinanceRepository.instance.markPaid(entry.id); await ClinicFinanceRepository.instance.reportPayment(entry.id);
_load(); _load();
widget.onPaymentMade(); widget.onPaymentMade();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ödeme kaydedildi.')), const SnackBar(
content: Text('Ödeme bildirildi. Laboratuvar onayı bekleniyor.'),
),
); );
} }
} catch (e) { } catch (e) {
@@ -392,8 +390,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Hata: ${snap.error}', Text('Hata: ${snap.error}',
style: const TextStyle( style: const TextStyle(color: AppColors.textSecondary)),
color: AppColors.textSecondary)),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
onPressed: _load, onPressed: _load,
@@ -437,10 +434,17 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
final isPending = entry.status == FinanceStatus.pending; final isPending = entry.status == FinanceStatus.pending;
final statusColor = final isReported = entry.status == FinanceStatus.reported;
isPending ? AppColors.pending : AppColors.success; final statusColor = isPending
final statusBg = ? AppColors.pending
isPending ? AppColors.pendingBg : AppColors.successBg; : isReported
? AppColors.accent
: AppColors.success;
final statusBg = isPending
? AppColors.pendingBg
: isReported
? AppColors.inProgressBg
: AppColors.successBg;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
@@ -448,7 +452,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: InkWell( child: InkWell(
onTap: isPending ? () => _markPaid(entry) : null, onTap: isPending ? () => _reportPayment(entry) : null,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -472,6 +476,8 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
child: Icon( child: Icon(
isPending isPending
? Icons.hourglass_empty_rounded ? Icons.hourglass_empty_rounded
: isReported
? Icons.verified_outlined
: Icons.check_circle_outline, : Icons.check_circle_outline,
color: statusColor, color: statusColor,
size: 22, size: 22,
@@ -486,8 +492,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
entry.counterpartyName ?? entry.counterpartyName ?? 'Bilinmiyor',
'Bilinmiyor',
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -522,6 +527,25 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
color: AppColors.textMuted), 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,
),
),
],
], ],
), ),
), ),
@@ -20,39 +20,70 @@ class ClinicJobDetailScreen extends ConsumerStatefulWidget {
_ClinicJobDetailScreenState(); _ClinicJobDetailScreenState();
} }
class _ClinicJobDetailScreenState class _ClinicJobDetailScreenState extends ConsumerState<ClinicJobDetailScreen> {
extends ConsumerState<ClinicJobDetailScreen> {
Job? _job; Job? _job;
String? _loadError; String? _loadError;
late Future<List<JobFile>> _filesFuture; late Future<List<JobFile>> _filesFuture;
late Future<List<JobHistoryEntry>> _historyFuture;
bool _isActing = false; bool _isActing = false;
late UnsubFn _unsub; final List<UnsubFn> _unsubs = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); _load();
_loadFiles(); _loadFiles();
_unsub = RealtimeService.instance.watch( _loadHistory();
_unsubs.add(RealtimeService.instance.watch(
'jobs', 'jobs',
topic: widget.jobId, 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 @override
void dispose() { void dispose() {
_unsub(); for (final unsub in _unsubs) {
unsub();
}
super.dispose(); super.dispose();
} }
Future<void> _load() async { Future<void> _load() async {
if (mounted) setState(() { _loadError = null; }); if (mounted) {
setState(() {
_loadError = null;
});
}
try { try {
final job = await ClinicJobsRepository.instance.getJob(widget.jobId); final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
if (mounted) setState(() { _job = job; }); if (mounted) {
setState(() {
_job = job;
});
}
} catch (e) { } 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<void> _approve(Job job) async { Future<void> _approve(Job job) async {
setState(() => _isActing = true); setState(() => _isActing = true);
try { try {
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job); final updated =
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
if (mounted) { 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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş onaylandı.')), const SnackBar(content: Text('İş onaylandı.')),
); );
@@ -87,9 +129,12 @@ class _ClinicJobDetailScreenState
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('İşi İptal Et'), 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')), TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Vazgeç')),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
@@ -101,15 +146,21 @@ class _ClinicJobDetailScreenState
if (confirmed != true || !mounted) return; if (confirmed != true || !mounted) return;
setState(() => _isActing = true); setState(() => _isActing = true);
try { try {
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job); final updated =
await ClinicJobsRepository.instance.cancelJob(job.id, job);
if (mounted) { if (mounted) {
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; }); setState(() {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); _job = _job!.copyWith(status: updated.status);
_isActing = false;
});
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() => _isActing = false); 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(), note: noteController.text.trim(),
); );
if (mounted) { 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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Revizyon talebi gönderildi.')), const SnackBar(content: Text('Revizyon talebi gönderildi.')),
); );
@@ -212,10 +267,16 @@ class _ClinicJobDetailScreenState
setState(() => _isActing = true); setState(() => _isActing = true);
try { try {
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null; final note =
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note); noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
final updated = await ClinicJobsRepository.instance
.markDelivered(job.id, job, note: note);
if (mounted) { 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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')), const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
); );
@@ -241,7 +302,8 @@ class _ClinicJobDetailScreenState
Widget _buildBody() { Widget _buildBody() {
if (_job == null && _loadError == null) { 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) { if (_loadError != null && _job == null) {
return Center( return Center(
@@ -270,7 +332,10 @@ 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 job = _job!;
final membership = ref.read(authProvider).activeTenant; final membership = ref.read(authProvider).activeTenant;
final canDeliver = membership?.canDeliverJobs ?? true; final canDeliver = membership?.canDeliverJobs ?? true;
@@ -279,13 +344,16 @@ class _ClinicJobDetailScreenState
return _JobDetailBody( return _JobDetailBody(
job: job, job: job,
filesFuture: _filesFuture, filesFuture: _filesFuture,
historyFuture: _historyFuture,
isActing: _isActing, isActing: _isActing,
canDeliver: canDeliver, canDeliver: canDeliver,
canManage: canManage, canManage: canManage,
onApprove: canManage ? () => _approve(job) : () {}, onApprove: canManage ? () => _approve(job) : () {},
onRevision: canManage ? () => _requestRevision(job) : () {}, onRevision: canManage ? () => _requestRevision(job) : () {},
onDelivered: () => _markDelivered(job), onDelivered: () => _markDelivered(job),
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null, onCancel: (canCancel && job.status == JobStatus.pending)
? () => _cancelJob(job)
: null,
onFilesRefresh: _loadFiles, onFilesRefresh: _loadFiles,
); );
} }
@@ -295,6 +363,7 @@ class _JobDetailBody extends StatelessWidget {
const _JobDetailBody({ const _JobDetailBody({
required this.job, required this.job,
required this.filesFuture, required this.filesFuture,
required this.historyFuture,
required this.isActing, required this.isActing,
required this.canDeliver, required this.canDeliver,
required this.canManage, required this.canManage,
@@ -307,6 +376,7 @@ class _JobDetailBody extends StatelessWidget {
final Job job; final Job job;
final Future<List<JobFile>> filesFuture; final Future<List<JobFile>> filesFuture;
final Future<List<JobHistoryEntry>> historyFuture;
final bool isActing; final bool isActing;
final bool canDeliver; final bool canDeliver;
final bool canManage; final bool canManage;
@@ -355,7 +425,9 @@ class _JobDetailBody extends StatelessWidget {
job.patientName?.isNotEmpty == true job.patientName?.isNotEmpty == true
? job.patientName! ? job.patientName!
: job.patientCode, : job.patientCode,
style: Theme.of(context).textTheme.headlineSmall style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith( ?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.textPrimary), color: AppColors.textPrimary),
@@ -369,7 +441,7 @@ class _JobDetailBody extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
// Patient + Lab // Patient + Lab
_SectionLabel(title: 'Hasta & Laboratuvar'), const _SectionLabel(title: 'Hasta & Laboratuvar'),
if (job.patientName != null && job.patientName!.isNotEmpty) if (job.patientName != null && job.patientName!.isNotEmpty)
_InfoRow(label: 'Hasta', value: job.patientName!), _InfoRow(label: 'Hasta', value: job.patientName!),
_InfoRow(label: 'Protokol No', value: job.patientCode), _InfoRow(label: 'Protokol No', value: job.patientCode),
@@ -380,12 +452,13 @@ class _JobDetailBody extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
// Prosthetic // Prosthetic
_SectionLabel(title: 'Protez Bilgisi'), const _SectionLabel(title: 'Protez Bilgisi'),
_InfoRow(label: 'Tür', value: job.prostheticType.label), _InfoRow(label: 'Tür', value: job.prostheticType.label),
if (job.prostheticName != null && job.prostheticName!.isNotEmpty) if (job.prostheticName != null && job.prostheticName!.isNotEmpty)
_InfoRow(label: 'Ürün', value: job.prostheticName!), _InfoRow(label: 'Ürün', value: job.prostheticName!),
if (job.workflowType != null) if (job.workflowType != null)
_InfoRow(label: 'İş Tipi', value: job.workflowType!.label), _InfoRow(label: 'İş Tipi', value: job.workflowType!.label),
_InfoRow(label: 'Akış', value: job.workflowPreset.title),
_InfoRow( _InfoRow(
label: 'Prova', label: 'Prova',
value: job.provaRequired ? 'Provalı' : 'Provasız', value: job.provaRequired ? 'Provalı' : 'Provasız',
@@ -398,7 +471,9 @@ class _JobDetailBody extends StatelessWidget {
if (job.description != null && job.description!.isNotEmpty) if (job.description != null && job.description!.isNotEmpty)
_InfoRow(label: 'Açıklama', value: job.description!), _InfoRow(label: 'Açıklama', value: job.description!),
if (job.dueDate != null) 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) if (job.price != null)
_InfoRow( _InfoRow(
label: 'Fiyat', label: 'Fiyat',
@@ -438,7 +513,8 @@ class _JobDetailBody extends StatelessWidget {
_StepperWidget( _StepperWidget(
steps: steps, steps: steps,
currentStepIndex: currentStepIndex, 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}) { 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; if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
} }
@@ -526,11 +603,13 @@ class _StepperWidget extends StatelessWidget {
const _StepperWidget({ const _StepperWidget({
required this.steps, required this.steps,
required this.currentStepIndex, required this.currentStepIndex,
required this.isDelivered,
required this.historyFuture, required this.historyFuture,
}); });
final List<JobStep> steps; final List<JobStep> steps;
final int currentStepIndex; final int currentStepIndex;
final bool isDelivered;
final Future<List<JobHistoryEntry>> historyFuture; final Future<List<JobHistoryEntry>> historyFuture;
@override @override
@@ -542,7 +621,8 @@ class _StepperWidget extends StatelessWidget {
final Map<JobStep, int> revisionCounts = {}; final Map<JobStep, int> revisionCounts = {};
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {}; final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
for (final e in history) { 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; revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
} }
if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) { 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) { children: steps.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final step = entry.value; final step = entry.value;
final isCompleted = index < currentStepIndex; final isCompleted = isDelivered || index < currentStepIndex;
final isCurrent = index == currentStepIndex; final isCurrent = !isDelivered && index == currentStepIndex;
final revCount = revisionCounts[step] ?? 0; final revCount = revisionCounts[step] ?? 0;
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[]; final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
@@ -582,7 +662,7 @@ class _StepperWidget extends StatelessWidget {
Container( Container(
width: 2, width: 2,
height: 44, height: 44,
color: index < currentStepIndex color: isDelivered || index < currentStepIndex
? AppColors.success.withValues(alpha: 0.35) ? AppColors.success.withValues(alpha: 0.35)
: AppColors.border, : AppColors.border,
), ),
@@ -642,7 +722,8 @@ class _StepperWidget extends StatelessWidget {
), ),
if (stepNotes.isNotEmpty) ...[ if (stepNotes.isNotEmpty) ...[
const SizedBox(height: 8), 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) { String _label(JobHistoryAction action) {
return switch (action) { return switch (action) {
JobHistoryAction.revisionRequested => 'Revizyon Notu', JobHistoryAction.revisionRequested => 'Revizyon Notu',
JobHistoryAction.stepCompleted => 'İç Adım Notu',
JobHistoryAction.handedToClinic => 'Laboratuvar Notu', JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
JobHistoryAction.approved => 'Onay Notu', JobHistoryAction.approved => 'Onay Notu',
JobHistoryAction.delivered => 'Teslim Notu', JobHistoryAction.delivered => 'Teslim Notu',
@@ -745,8 +827,8 @@ class _InfoRow extends StatelessWidget {
width: 110, width: 110,
child: Text( child: Text(
label, label,
style: const TextStyle( style:
fontSize: 13, color: AppColors.textSecondary), const TextStyle(fontSize: 13, color: AppColors.textSecondary),
), ),
), ),
Expanded( Expanded(
@@ -45,7 +45,8 @@ class ClinicJobsRepository {
} }
Future<Job> getJob(String jobId) async { Future<Job> 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()); return Job.fromJson(record.toJson());
} }
@@ -66,13 +67,15 @@ class ClinicJobsRepository {
String? currency, String? currency,
JobWorkflowType? workflowType, JobWorkflowType? workflowType,
bool provaRequired = true, bool provaRequired = true,
List<String> workflowSteps = const [],
}) async { }) async {
final record = await _pb.collection('jobs').create(body: { final record = await _pb.collection('jobs').create(body: {
'clinic_tenant_id': clinicTenantId, 'clinic_tenant_id': clinicTenantId,
'lab_tenant_id': labTenantId, 'lab_tenant_id': labTenantId,
'patient_code': patientCode, 'patient_code': patientCode,
if (patientId != null) 'patient_id': patientId, if (patientId != null) 'patient_id': patientId,
if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId, if (prostheticId != null && prostheticId.isNotEmpty)
'prosthetic_id': prostheticId,
'prosthetic_type': prostheticType.value, 'prosthetic_type': prostheticType.value,
'member_count': teeth.length, 'member_count': teeth.length,
'teeth': teeth, 'teeth': teeth,
@@ -82,6 +85,7 @@ class ClinicJobsRepository {
if (price != null) 'price': price, if (price != null) 'price': price,
if (currency != null && currency.isNotEmpty) 'currency': currency, if (currency != null && currency.isNotEmpty) 'currency': currency,
if (workflowType != null) 'workflow_type': workflowType.value, if (workflowType != null) 'workflow_type': workflowType.value,
if (workflowSteps.isNotEmpty) 'workflow_steps': workflowSteps,
'status': 'pending', 'status': 'pending',
'location': 'at_clinic', 'location': 'at_clinic',
'prova_required': provaRequired, 'prova_required': provaRequired,
@@ -126,7 +130,8 @@ class ClinicJobsRepository {
return updated; return updated;
} }
Future<Job> requestRevision(String jobId, Job job, {required String note}) async { Future<Job> requestRevision(String jobId, Job job,
{required String note}) async {
final record = await _pb.collection('jobs').update(jobId, body: { final record = await _pb.collection('jobs').update(jobId, body: {
'location': 'at_lab', 'location': 'at_lab',
}); });
@@ -170,7 +175,8 @@ class ClinicJobsRepository {
return Job.fromJson(record.toJson()); return Job.fromJson(record.toJson());
} }
Future<List<Map<String, dynamic>>> listApprovedLabs(String clinicTenantId) async { Future<List<Map<String, dynamic>>> listApprovedLabs(
String clinicTenantId) async {
final result = await _pb.collection('connections').getList( final result = await _pb.collection('connections').getList(
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"', filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
expand: 'lab_tenant_id', expand: 'lab_tenant_id',
@@ -178,11 +184,13 @@ class ClinicJobsRepository {
); );
return result.items.map((r) { return result.items.map((r) {
final expand = r.toJson()['expand'] as Map<String, dynamic>?; final expand = r.toJson()['expand'] as Map<String, dynamic>?;
return expand?['lab_tenant_id'] as Map<String, dynamic>? ?? {'id': r.data['lab_tenant_id']}; return expand?['lab_tenant_id'] as Map<String, dynamic>? ??
{'id': r.data['lab_tenant_id']};
}).toList(); }).toList();
} }
Future<List<Job>> listJobsByPatient(String patientId, {int limit = 50}) async { Future<List<Job>> listJobsByPatient(String patientId,
{int limit = 50}) async {
final result = await _pb.collection('jobs').getList( final result = await _pb.collection('jobs').getList(
filter: 'patient_id = "$patientId"', filter: 'patient_id = "$patientId"',
perPage: limit, perPage: limit,
@@ -192,11 +200,17 @@ class ClinicJobsRepository {
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
} }
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async { Future<int> countDelivered(String clinicTenantId,
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"']; {DateTime? from, DateTime? to}) async {
final parts = [
'clinic_tenant_id = "$clinicTenantId"',
'status = "delivered"'
];
if (from != null) parts.add('updated >= "${_date(from)}"'); if (from != null) parts.add('updated >= "${_date(from)}"');
if (to != null) parts.add('updated < "${_date(to)}"'); 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; return r.totalItems;
} }
+134 -60
View File
@@ -13,6 +13,7 @@ import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart'; import '../../../models/job.dart';
import '../../../models/patient.dart'; import '../../../models/patient.dart';
import '../../../models/prosthetic_product.dart'; import '../../../models/prosthetic_product.dart';
import '../../../models/tenant.dart';
import '../../lab/discounts/discount_repository.dart'; import '../../lab/discounts/discount_repository.dart';
import '../../lab/products/lab_products_repository.dart'; import '../../lab/products/lab_products_repository.dart';
import 'clinic_jobs_repository.dart'; import 'clinic_jobs_repository.dart';
@@ -111,8 +112,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_labsError = null; _labsError = null;
}); });
try { try {
final tenantId = final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
ref.read(authProvider).activeTenant!.tenant.id;
final labs = final labs =
await ClinicJobsRepository.instance.listApprovedLabs(tenantId); await ClinicJobsRepository.instance.listApprovedLabs(tenantId);
setState(() { setState(() {
@@ -149,9 +149,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
labId, labId,
isActive: true, isActive: true,
); );
final matchingProducts = products final matchingProducts =
.where((p) => p.prostheticType == ptValue) products.where((p) => p.prostheticType == ptValue).toList();
.toList();
ProstheticProduct? product; ProstheticProduct? product;
if (_selectedProduct != null) { if (_selectedProduct != null) {
@@ -230,8 +229,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_availableProducts.isEmpty; _availableProducts.isEmpty;
bool get _hasSelectedProductWithoutPrice => bool get _hasSelectedProductWithoutPrice =>
_selectedProduct != null && _selectedProduct != null && _selectedProduct!.unitPrice == null;
_selectedProduct!.unitPrice == null;
bool get _canSubmitJob => bool get _canSubmitJob =>
!_isSubmitting && !_isSubmitting &&
@@ -251,8 +249,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
} }
setState(() => _patientSearchLoading = true); setState(() => _patientSearchLoading = true);
try { try {
final tenantId = final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
ref.read(authProvider).activeTenant!.tenant.id;
final results = await ClinicPatientsRepository.instance final results = await ClinicPatientsRepository.instance
.listPatients(tenantId, search: normalizedQuery, limit: 10); .listPatients(tenantId, search: normalizedQuery, limit: 10);
if (!mounted || _patientSearchController.text.trim() != normalizedQuery) { if (!mounted || _patientSearchController.text.trim() != normalizedQuery) {
@@ -315,8 +312,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_dueDate = DateTime( _dueDate = DateTime(
pickedDate.year, pickedDate.month, pickedDate.day, pickedDate.year,
pickedTime?.hour ?? 17, pickedTime?.minute ?? 0, pickedDate.month,
pickedDate.day,
pickedTime?.hour ?? 17,
pickedTime?.minute ?? 0,
); );
}); });
} }
@@ -326,7 +326,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
final date = final date =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ'; 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'; return 'PR-$date-$rand';
} }
@@ -397,6 +398,13 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
lastName: rawLastName.isNotEmpty ? rawLastName : null, 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( final job = await ClinicJobsRepository.instance.createJob(
clinicTenantId: tenantId, clinicTenantId: tenantId,
labTenantId: _selectedLab!['id'] as String, labTenantId: _selectedLab!['id'] as String,
@@ -418,24 +426,29 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
currency: _labProduct?.currency, currency: _labProduct?.currency,
workflowType: _selectedWorkflowType, workflowType: _selectedWorkflowType,
provaRequired: _provaRequired, provaRequired: _provaRequired,
workflowSteps: workflowSteps.map((step) => step.value).toList(),
); );
// Upload pending files // Upload pending files
if (_pendingFiles.isNotEmpty) { if (_pendingFiles.isNotEmpty) {
final pb = PocketBaseClient.instance.pb; final pb = PocketBaseClient.instance.pb;
final token = pb.authStore.token; 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) { for (final file in _pendingFiles) {
final bytes = file.bytes; final bytes = file.bytes;
if (bytes == null) continue; if (bytes == null) continue;
final ext = (file.extension ?? '').toLowerCase(); final ext = (file.extension ?? '').toLowerCase();
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply') final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
? 'scan' ? 'scan'
: (ext == 'pdf') ? 'document' : 'image'; : (ext == 'pdf')
? 'document'
: 'image';
final mimeType = _mimeFromExt(ext); final mimeType = _mimeFromExt(ext);
final req = http.MultipartRequest( final req = http.MultipartRequest(
'POST', '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' ..headers['Authorization'] = 'Bearer $token'
..fields['job_id'] = job.id ..fields['job_id'] = job.id
@@ -483,7 +496,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// Lab selection // Lab selection
_SectionLabel(label: 'Laboratuvar *'), const _SectionLabel(label: 'Laboratuvar *'),
if (_labsLoading) if (_labsLoading)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (_labsError != null) else if (_labsError != null)
@@ -523,7 +536,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionLabel(label: 'Hasta / Protokol'), const _SectionLabel(label: 'Hasta / Protokol'),
const SizedBox(height: 8), const SizedBox(height: 8),
SegmentedButton<_PatientEntryMode>( SegmentedButton<_PatientEntryMode>(
segments: const [ segments: const [
@@ -566,7 +579,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
dense: true, dense: true,
leading: Icon(Icons.info_outline), leading: Icon(Icons.info_outline),
title: Text('Hasta bulunamadı'), title: Text('Hasta bulunamadı'),
subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'), subtitle: Text(
'İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
), ),
..._patientResults.map( ..._patientResults.map(
(p) => ListTile( (p) => ListTile(
@@ -668,7 +682,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Prosthetic type // Prosthetic type
_SectionLabel(label: 'Protez Türü *'), const _SectionLabel(label: 'Protez Türü *'),
DropdownButtonFormField<ProstheticType>( DropdownButtonFormField<ProstheticType>(
initialValue: _selectedProstheticType, initialValue: _selectedProstheticType,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -689,12 +703,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
}); });
_refreshProductsAndPrice(); _refreshProductsAndPrice();
}, },
validator: (val) => validator: (val) => val == null ? 'Protez türü zorunludur' : null,
val == null ? 'Protez türü zorunludur' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionLabel(label: 'Ürün'), const _SectionLabel(label: 'Ürün'),
DropdownButtonFormField<ProstheticProduct>( DropdownButtonFormField<ProstheticProduct>(
initialValue: _selectedProduct, initialValue: _selectedProduct,
decoration: InputDecoration( decoration: InputDecoration(
@@ -716,7 +729,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
), ),
) )
.toList(), .toList(),
onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty) onChanged: (_selectedProstheticType == null ||
_availableProducts.isEmpty)
? null ? null
: (val) { : (val) {
setState(() => _selectedProduct = val); setState(() => _selectedProduct = val);
@@ -733,14 +747,15 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
_InlineInfoBanner( _InlineInfoBanner(
message: _productAvailabilityMessage!, message: _productAvailabilityMessage!,
tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice tone:
_hasMissingProductForType || _hasSelectedProductWithoutPrice
? _InfoBannerTone.warning ? _InfoBannerTone.warning
: _InfoBannerTone.info, : _InfoBannerTone.info,
), ),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionLabel(label: 'İş Tipi'), const _SectionLabel(label: 'İş Tipi'),
DropdownButtonFormField<JobWorkflowType>( DropdownButtonFormField<JobWorkflowType>(
initialValue: _selectedWorkflowType, initialValue: _selectedWorkflowType,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -754,19 +769,22 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
), ),
) )
.toList(), .toList(),
onChanged: (val) => onChanged: (val) => setState(() => _selectedWorkflowType = val),
setState(() => _selectedWorkflowType = val), validator: (val) => val == null ? 'Lütfen iş tipi seçin' : null,
validator: (val) =>
val == null ? 'Lütfen iş tipi seçin' : null,
), ),
// Price preview // Price preview
if (_priceLoading) if (_priceLoading)
const Padding( const Padding(
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: Row(children: [ 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), 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) ...[ else if (_labProduct != null && _effectivePrice != null) ...[
@@ -784,6 +802,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_ProvaToggle( _ProvaToggle(
value: _provaRequired, value: _provaRequired,
prostheticType: _selectedProstheticType, prostheticType: _selectedProstheticType,
workflowType: _selectedWorkflowType,
optionalSteps: _selectedLab != null
? Tenant.fromJson(_selectedLab!).workflowOverrideSteps
: const [],
onChanged: (v) => setState(() => _provaRequired = v), onChanged: (v) => setState(() => _provaRequired = v),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -809,7 +831,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
}, },
onSelectUpper: () { onSelectUpper: () {
setState(() { setState(() {
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]}; final upper = {
...[for (int i = 11; i <= 18; i++) i],
...[for (int i = 21; i <= 28; i++) i]
};
if (upper.every(_selectedTeeth.contains)) { if (upper.every(_selectedTeeth.contains)) {
_selectedTeeth.removeAll(upper); _selectedTeeth.removeAll(upper);
} else { } else {
@@ -820,7 +845,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
}, },
onSelectLower: () { onSelectLower: () {
setState(() { setState(() {
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]}; final lower = {
...[for (int i = 31; i <= 38; i++) i],
...[for (int i = 41; i <= 48; i++) i]
};
if (lower.every(_selectedTeeth.contains)) { if (lower.every(_selectedTeeth.contains)) {
_selectedTeeth.removeAll(lower); _selectedTeeth.removeAll(lower);
} else { } else {
@@ -851,7 +879,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Color (optional) // Color (optional)
_SectionLabel(label: 'Renk (İsteğe Bağlı)'), const _SectionLabel(label: 'Renk (İsteğe Bağlı)'),
TextFormField( TextFormField(
controller: _colorController, controller: _colorController,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -861,7 +889,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Description (optional) // Description (optional)
_SectionLabel(label: 'Açıklama (İsteğe Bağlı)'), const _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
TextFormField( TextFormField(
controller: _descriptionController, controller: _descriptionController,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -873,7 +901,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Due date (optional) // Due date (optional)
_SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'), const _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
InkWell( InkWell(
onTap: _pickDueDate, onTap: _pickDueDate,
child: InputDecorator( child: InputDecorator(
@@ -895,7 +923,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// File attachments (optional) // File attachments (optional)
_SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'), const _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
_FilePicker( _FilePicker(
files: _pendingFiles, files: _pendingFiles,
onAdd: () async { onAdd: () async {
@@ -958,7 +986,9 @@ class _InlineInfoBanner extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded, isWarning
? Icons.warning_amber_rounded
: Icons.info_outline_rounded,
size: 18, size: 18,
color: isWarning ? AppColors.pending : AppColors.textSecondary, color: isWarning ? AppColors.pending : AppColors.textSecondary,
), ),
@@ -995,12 +1025,18 @@ class _TeethBulkBar extends StatelessWidget {
final VoidCallback onClear; final VoidCallback onClear;
bool _allUpperSelected() { 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); return upper.every(selectedTeeth.contains);
} }
bool _allLowerSelected() { 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); return lower.every(selectedTeeth.contains);
} }
@@ -1094,9 +1130,7 @@ class _BulkChip extends StatelessWidget {
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12, fontWeight: FontWeight.w600, color: color),
fontWeight: FontWeight.w600,
color: color),
), ),
], ],
), ),
@@ -1247,7 +1281,8 @@ class _FilePicker extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -1265,7 +1300,8 @@ class _FilePicker extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
GestureDetector( GestureDetector(
onTap: () => onRemove(i), 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: [ children: [
Text( Text(
'${product.name}${effectivePrice.toStringAsFixed(2)} $currency', '${product.name}${effectivePrice.toStringAsFixed(2)} $currency',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success), style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.success),
), ),
Text( Text(
'${unitPrice.toStringAsFixed(2)} $currency x $units $unitLabel', '${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) if (hasDiscount)
Text( Text(
'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı', 'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı',
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), style: TextStyle(
fontSize: 11,
color: AppColors.success.withValues(alpha: 0.75)),
) )
else else
Text( Text(
'Liste fiyatı · İndirim yok', '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({ const _ProvaToggle({
required this.value, required this.value,
required this.onChanged, required this.onChanged,
required this.optionalSteps,
this.prostheticType, this.prostheticType,
this.workflowType,
}); });
final bool value; final bool value;
final ValueChanged<bool> onChanged; final ValueChanged<bool> onChanged;
final ProstheticType? prostheticType; final ProstheticType? prostheticType;
final JobWorkflowType? workflowType;
final List<JobStep> optionalSteps;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final steps = prostheticType != null final preset = prostheticType != null
? jobStepTemplate(prostheticType!, value) ? buildJobWorkflowPreset(
: <JobStep>[]; prostheticType: prostheticType!,
workflowType: workflowType,
provaRequired: value,
optionalSteps: optionalSteps,
)
: null;
final steps = preset?.steps ?? <JobStep>[];
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@@ -1400,7 +1455,9 @@ class _ProvaToggle extends StatelessWidget {
color: value ? AppColors.inProgressBg : AppColors.surfaceVariant, color: value ? AppColors.inProgressBg : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( 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( child: Column(
@@ -1422,14 +1479,17 @@ class _ProvaToggle extends StatelessWidget {
value ? 'Provalı İş' : 'Provasız İş', value ? 'Provalı İş' : 'Provasız İş',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: value ? AppColors.inProgress : AppColors.textPrimary, color: value
? AppColors.inProgress
: AppColors.textPrimary,
fontSize: 14, fontSize: 14,
), ),
), ),
Text( Text(
value preset?.title ??
(value
? 'Lab her adımda klinik onayı bekler' ? 'Lab her adımda klinik onayı bekler'
: 'Lab doğrudan üretip teslime gönderir', : 'Lab doğrudan üretip teslime gönderir'),
style: const TextStyle( style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary), fontSize: 12, color: AppColors.textSecondary),
), ),
@@ -1439,16 +1499,29 @@ class _ProvaToggle extends StatelessWidget {
Switch( Switch(
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
activeColor: AppColors.inProgress, activeThumbColor: AppColors.inProgress,
), ),
], ],
), ),
if (steps.isNotEmpty) ...[ if (steps.isNotEmpty) ...[
const SizedBox(height: 8), 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( Wrap(
spacing: 6, spacing: 6,
children: steps.map((s) => Container( children: steps
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), .map((s) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
@@ -1459,7 +1532,8 @@ class _ProvaToggle extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary), fontSize: 11, color: AppColors.textSecondary),
), ),
)).toList(), ))
.toList(),
), ),
], ],
], ],
@@ -9,7 +9,10 @@ import '../../../core/providers/locale_provider.dart';
import '../../../core/router/app_router.dart'; import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../models/tenant.dart'; import '../../../models/tenant.dart';
import '../../shared/location_completion_banner.dart';
import '../../shared/tenant_team_screen.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'; import '../connections/clinic_connections_screen.dart';
class ClinicSettingsScreen extends ConsumerWidget { class ClinicSettingsScreen extends ConsumerWidget {
@@ -29,6 +32,17 @@ class ClinicSettingsScreen extends ConsumerWidget {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ 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 // User card
_SectionHeader(title: s.userInfo), _SectionHeader(title: s.userInfo),
_UserCard(profile: profile), _UserCard(profile: profile),
@@ -62,6 +76,13 @@ class ClinicSettingsScreen extends ConsumerWidget {
label: s.role, label: s.role,
value: _roleLabel(membership?.role, s), value: _roleLabel(membership?.role, s),
), ),
_InfoTile(
icon: Icons.place_outlined,
label: 'Konum',
value: tenant?.locationLabel.isNotEmpty == true
? tenant!.locationLabel
: '-',
),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -100,7 +121,9 @@ class ClinicSettingsScreen extends ConsumerWidget {
onTap: () { onTap: () {
ref.read(authProvider.notifier).setActiveTenant(m); ref.read(authProvider.notifier).setActiveTenant(m);
ScaffoldMessenger.of(context).showSnackBar( 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, subtitle: s.teamSub,
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (_) => const TenantTeamScreen()),
builder: (_) => const TenantTeamScreen()),
), ),
), ),
_NavTile( _NavTile(
@@ -140,6 +162,14 @@ class ClinicSettingsScreen extends ConsumerWidget {
subtitle: s.aiAssistantSub, subtitle: s.aiAssistantSub,
onTap: () => context.push(routeClinicAi), 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), const SizedBox(height: 20),
], ],
@@ -152,7 +182,8 @@ class ClinicSettingsScreen extends ConsumerWidget {
iconColor: AppColors.accent, iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg, iconBg: AppColors.inProgressBg,
title: s.appLanguage, title: s.appLanguage,
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s), subtitle: _currentLanguageLabel(
ref.watch(localeProvider).languageCode, s),
onTap: () => _showLanguagePicker(context, ref, 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; if (tenant == null) return;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -185,10 +217,12 @@ class ClinicSettingsScreen extends ConsumerWidget {
builder: (_) => _EditTenantSheet( builder: (_) => _EditTenantSheet(
tenant: tenant, tenant: tenant,
s: s, s: s,
onSave: (name) async { onSave: (name, location) async {
await ref await ref.read(authProvider.notifier).updateTenantInfo(
.read(authProvider.notifier) tenantId: tenant.id,
.updateTenantInfo(tenantId: tenant.id, companyName: name); 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, 'en' => s.languageEnglish,
'ru' => s.languageRussian, 'ru' => s.languageRussian,
'ar' => s.languageArabic, 'ar' => s.languageArabic,
@@ -316,7 +351,10 @@ class _EditTenantSheet extends StatefulWidget {
}); });
final Tenant tenant; final Tenant tenant;
final AppStrings s; final AppStrings s;
final Future<void> Function(String companyName) onSave; final Future<void> Function(
String companyName,
TenantLocationData location,
) onSave;
@override @override
State<_EditTenantSheet> createState() => _EditTenantSheetState(); State<_EditTenantSheet> createState() => _EditTenantSheetState();
@@ -324,32 +362,49 @@ class _EditTenantSheet extends StatefulWidget {
class _EditTenantSheetState extends State<_EditTenantSheet> { class _EditTenantSheetState extends State<_EditTenantSheet> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final TextEditingController _cityController;
late final TextEditingController _districtController;
late TenantLocationData _location;
bool _saving = false; bool _saving = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.tenant.companyName); _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 @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_addressController.dispose();
_cityController.dispose();
_districtController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _submit() async { Future<void> _submit() async {
final name = _nameController.text.trim(); final name = _nameController.text.trim();
if (name.isEmpty) return; 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); setState(() => _saving = true);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
await widget.onSave(name); await widget.onSave(name, location);
navigator.pop(); navigator.pop();
} catch (e) { } catch (e) {
messenger.showSnackBar( messenger
SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); .showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
} }
@@ -395,13 +450,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
), ),
textCapitalization: TextCapitalization.words, 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), const SizedBox(height: 20),
if (_saving) if (_saving)
const Center( const Center(
child: CircularProgressIndicator(color: AppColors.accent)) child: CircularProgressIndicator(color: AppColors.accent))
else else
FilledButton( FilledButton(
onPressed: _submit, onPressed: _saving ? null : _submit,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48)), minimumSize: const Size(double.infinity, 48)),
child: Text(s.save), child: Text(s.save),
@@ -534,7 +667,10 @@ class _InfoCard extends StatelessWidget {
children[i], children[i],
if (i < children.length - 1) if (i < children.length - 1)
const Divider( 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container( leading: Container(
width: 36, width: 36,
height: 36, height: 36,
@@ -615,8 +750,7 @@ class _NavTile extends StatelessWidget {
? Text(subtitle!, ? Text(subtitle!,
style: const TextStyle(color: AppColors.textSecondary)) style: const TextStyle(color: AppColors.textSecondary))
: null, : null,
trailing: trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: onTap, onTap: onTap,
); );
} }
@@ -642,16 +776,14 @@ class _SignOutCard extends StatelessWidget {
], ],
), ),
child: ListTile( child: ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container( leading: Container(
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cancelledBg, color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(9)), borderRadius: BorderRadius.circular(9)),
child: const Icon(Icons.logout, child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18),
color: AppColors.cancelled, size: 18),
), ),
title: Text(s.signOut, title: Text(s.signOut,
style: const TextStyle( style: const TextStyle(
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.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/widgets/tooth_logo.dart';
import '../../../core/services/realtime_service.dart'; import '../../../core/services/realtime_service.dart';
import '../../../models/job.dart'; import '../../../models/job.dart';
import '../../shared/location_completion_banner.dart';
import '../jobs/lab_jobs_repository.dart'; import '../jobs/lab_jobs_repository.dart';
class LabDashboardScreen extends ConsumerStatefulWidget { class LabDashboardScreen extends ConsumerStatefulWidget {
@@ -20,27 +22,47 @@ class LabDashboardScreen extends ConsumerStatefulWidget {
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> { class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
late Future<_DashboardData> _future; late Future<_DashboardData> _future;
bool _acceptingAll = false; bool _acceptingAll = false;
late UnsubFn _unsub; UnsubFn? _unsub;
Timer? _reloadDebounce;
String? _subscribedTenantId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); _load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id; _ensureRealtimeSubscription();
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "lab_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
} }
@override @override
void dispose() { void dispose() {
_unsub(); _reloadDebounce?.cancel();
_unsub?.call();
super.dispose(); 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() { void _load() {
_ensureRealtimeSubscription();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id; final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
final now = DateTime.now(); final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1); final thisMonthStart = DateTime(now.year, now.month, 1);
@@ -50,13 +72,19 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
Future.wait<List<Job>>([ Future.wait<List<Job>>([
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'), LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
LabJobsRepository.instance.listInProgress(tenantId), LabJobsRepository.instance.listInProgress(tenantId),
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'), LabJobsRepository.instance
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'), .listInProgress(tenantId, location: 'at_lab'),
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200), LabJobsRepository.instance
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200), .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
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart), .countDelivered(tenantId, from: thisMonthStart),
LabJobsRepository.instance
.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
]).then((r) { ]).then((r) {
final jobs = r[0] as List<List<Job>>; final jobs = r[0] as List<List<Job>>;
return _DashboardData( return _DashboardData(
@@ -82,7 +110,8 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating), SnackBar(
content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
); );
} }
} finally { } finally {
@@ -92,7 +121,10 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: LayoutBuilder( body: LayoutBuilder(
@@ -109,14 +141,29 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return _DashboardSkeleton(companyName: companyName, hPad: hPad); return _DashboardSkeleton(
companyName: companyName, hPad: hPad);
} }
if (snap.hasError) return _ErrorBody(onRetry: _load); if (snap.hasError) return _ErrorBody(onRetry: _load);
final data = snap.data!; final data = snap.data!;
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
_DashboardHeader(companyName: companyName), _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) if (isDesktop)
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
@@ -137,7 +184,10 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
count: data.pendingJobs.length, count: data.pendingJobs.length,
loading: _acceptingAll, loading: _acceptingAll,
onTap: _bulkAccept, 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) ...[ if (isDesktop) ...[
@@ -145,14 +195,18 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: _MonthlyReportSection(data: data) 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( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: _GamificationRow(data: data) 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<LabDashboardScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium), Text('Yapılacaklar',
style: Theme.of(context).textTheme.titleMedium),
TextButton( TextButton(
onPressed: () => context.go(routeLabJobsAll), 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'), child: const Text('Tümünü Gör'),
), ),
], ],
@@ -185,8 +243,10 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated( sliver: SliverList.separated(
itemCount: data.atLabJobs.take(5).length, itemCount: data.atLabJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) =>
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i]) const SizedBox(height: 10),
itemBuilder: (ctx, i) =>
_JobCard(job: data.atLabJobs[i])
.animate(delay: (i * 60).ms) .animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms) .fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0), .slideY(begin: 0.12, end: 0),
@@ -197,15 +257,18 @@ class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
sliver: SliverToBoxAdapter( 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( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
sliver: SliverList.separated( sliver: SliverList.separated(
itemCount: data.atClinicJobs.take(5).length, itemCount: data.atClinicJobs.take(5).length,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) =>
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i]) const SizedBox(height: 10),
itemBuilder: (ctx, i) =>
_JobCard(job: data.atClinicJobs[i])
.animate(delay: (i * 60).ms) .animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms) .fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0), .slideY(begin: 0.12, end: 0),
@@ -233,7 +296,8 @@ class _DashboardHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) { if (isDesktop) {
return SliverAppBar( return SliverAppBar(
@@ -252,15 +316,24 @@ class _DashboardHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)), Text('Genel Bakış',
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), 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: [ actions: [
IconButton( IconButton(
onPressed: () => context.go(routeLabSettings), 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), const SizedBox(width: 8),
], ],
@@ -291,14 +364,26 @@ class _DashboardHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)), Text('DLS',
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis), 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: [ actions: [
IconButton( IconButton(
onPressed: () => context.go(routeLabSettings), 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( flexibleSpace: FlexibleSpaceBar(
@@ -317,9 +402,19 @@ class _DashboardHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ 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 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 @override
Widget build(BuildContext context) { 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) final pendingCard = _StatCard(
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); label: 'Bekleyen',
final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg) value: '$pending',
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); 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) { if (isWideDesktop) {
final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg) final sentCard = _StatCard(
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); label: 'Klinik\'te',
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg) value: '$sent',
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); 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( return Row(
children: [ children: [
@@ -380,7 +504,12 @@ class _StatsRow extends StatelessWidget {
} }
class _StatCard 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 label;
final String value; final String value;
final IconData icon; final IconData icon;
@@ -394,22 +523,38 @@ class _StatCard extends StatelessWidget {
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border), 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( child: Row(
children: [ children: [
Container( Container(
width: 44, height: 44, width: 44,
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)), height: 44,
decoration: BoxDecoration(
color: bgColor, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22), child: Icon(icon, color: color, size: 22),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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 { 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 int count;
final bool loading; final bool loading;
final VoidCallback onTap; final VoidCallback onTap;
@@ -433,30 +579,54 @@ class _AcceptAllBanner extends StatelessWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), 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( child: Row(
children: [ children: [
Container( Container(
width: 38, height: 38, width: 38,
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)), height: 38,
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18), 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), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), Text('$count yeni iş bekliyor',
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), 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 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( : Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)), horizontal: 14, vertical: 8),
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), 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,7 +643,9 @@ class _JobCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final due = job.dueDate; final due = job.dueDate;
final isOverdue = due != null && due.isBefore(DateTime.now()); 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( return Semantics(
label: job.patientCode, label: job.patientCode,
button: true, button: true,
@@ -486,28 +658,51 @@ class _JobCard extends StatelessWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border)),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 46, height: 46, width: 46,
decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)), height: 46,
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))), 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), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), Text(job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), Text(job.clinicName ?? 'Klinik',
style: const TextStyle(
fontSize: 13, color: AppColors.textSecondary)),
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap( Wrap(
spacing: 6, spacing: 6,
children: [ children: [
_Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg), _Tag(
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg), 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),
], ],
), ),
], ],
@@ -518,9 +713,19 @@ class _JobCard extends StatelessWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted), Icon(Icons.calendar_today_outlined,
size: 13,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)), 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) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), decoration:
child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label,
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
); );
} }
} }
class _EmptySection extends StatelessWidget { class _EmptySection extends StatelessWidget {
const _EmptySection({required this.message}); const _EmptySection({required this.message});
final String message; final String message;
@@ -563,9 +770,12 @@ class _EmptySection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ 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), 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 64, height: 64, width: 64,
decoration: BoxDecoration(color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(16)), height: 64,
child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30), 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 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), 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), padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
sliver: SliverList.builder( sliver: SliverList.builder(
itemCount: 4, 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(); State<_ShimmerBox> createState() => _ShimmerBoxState();
} }
class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin { class _ShimmerBoxState extends State<_ShimmerBox>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl; late AnimationController _ctrl;
late Animation<double> _anim; late Animation<double> _anim;
@override @override
void initState() { void initState() {
super.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); _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
} }
@override @override
void dispose() { _ctrl.dispose(); super.dispose(); } void dispose() {
_ctrl.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: _anim, animation: _anim,
builder: (_, __) => Container( builder: (_, __) => Container(
height: widget.height, 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: [ children: [
Row( Row(
children: [ 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), 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), const SizedBox(height: 12),
@@ -710,7 +949,8 @@ class _MonthlyReportSection extends StatelessWidget {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isUp ? AppColors.successBg : AppColors.cancelledBg, color: isUp ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -719,7 +959,9 @@ class _MonthlyReportSection extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, isUp
? Icons.trending_up_rounded
: Icons.trending_down_rounded,
size: 16, size: 16,
color: isUp ? AppColors.success : AppColors.cancelled, color: isUp ? AppColors.success : AppColors.cancelled,
), ),
@@ -744,7 +986,8 @@ class _MonthlyReportSection extends StatelessWidget {
} }
class _MonthStat 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 String label;
final int value; final int value;
final bool highlighted; final bool highlighted;
@@ -754,14 +997,22 @@ class _MonthStat extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( 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), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 2),
Text( Text(
'$value', '$value',
@@ -788,7 +1039,8 @@ class _GamificationRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0); 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( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -803,7 +1055,11 @@ class _GamificationRow extends StatelessWidget {
children: [ children: [
const Text('🏆', style: TextStyle(fontSize: 16)), const Text('🏆', style: TextStyle(fontSize: 16)),
const SizedBox(width: 6), 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(), const Spacer(),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
@@ -813,7 +1069,10 @@ class _GamificationRow extends StatelessWidget {
), ),
child: Text( child: Text(
'${data.points} puan', '${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: [ children: [
Text( Text(
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi', '${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary), style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary),
), ),
Text( Text(
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı', progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary, color: progress >= 1.0
? AppColors.success
: AppColors.textSecondary,
), ),
), ),
], ],
@@ -1,5 +1,6 @@
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import '../../../core/api/pocketbase_client.dart'; import '../../../core/api/pocketbase_client.dart';
import '../../../core/services/finance_service.dart';
import '../../../models/finance_entry.dart'; import '../../../models/finance_entry.dart';
class LabFinanceRepository { class LabFinanceRepository {
@@ -15,7 +16,11 @@ class LabFinanceRepository {
int limit = 30, int limit = 30,
}) async { }) async {
final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"']; 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( final result = await _pb.collection('finance_entries').getList(
page: page, page: page,
@@ -31,7 +36,7 @@ class LabFinanceRepository {
final all = await listEntries(tenantId, limit: 200); final all = await listEntries(tenantId, limit: 200);
double pending = 0, paid = 0; double pending = 0, paid = 0;
for (final e in all) { for (final e in all) {
if (e.status == FinanceStatus.pending) { if (e.status.isOpen) {
pending += e.amount; pending += e.amount;
} else { } else {
paid += e.amount; paid += e.amount;
@@ -40,15 +45,17 @@ class LabFinanceRepository {
return {'pending': pending, 'paid': paid}; return {'pending': pending, 'paid': paid};
} }
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async { Future<List<CounterpartyFinanceSummary>> byCounterparty(
String tenantId) async {
final entries = await listEntries(tenantId, limit: 300); final entries = await listEntries(tenantId, limit: 300);
final map = <String, CounterpartyFinanceSummary>{}; final map = <String, CounterpartyFinanceSummary>{};
for (final entry in entries) { for (final entry in entries) {
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown'; final key =
entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
final current = map[key]; final current = map[key];
final pending = (current?.pendingAmount ?? 0) + final pending = (current?.pendingAmount ?? 0) +
(entry.status == FinanceStatus.pending ? entry.amount : 0); (entry.status.isOpen ? entry.amount : 0);
final paid = (current?.paidAmount ?? 0) + final paid = (current?.paidAmount ?? 0) +
(entry.status == FinanceStatus.paid ? entry.amount : 0); (entry.status == FinanceStatus.paid ? entry.amount : 0);
map[key] = CounterpartyFinanceSummary( map[key] = CounterpartyFinanceSummary(
@@ -65,4 +72,17 @@ class LabFinanceRepository {
list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount)); list.sort((a, b) => b.pendingAmount.compareTo(a.pendingAmount));
return list; return list;
} }
Future<void> 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);
}
} }
@@ -76,8 +76,10 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
switch (_sort) { switch (_sort) {
case _FinanceSort.newestFirst: case _FinanceSort.newestFirst:
list.sort((a, b) { list.sort((a, b) {
final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null; final da =
final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null; 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 && db == null) return 0;
if (da == null) return 1; if (da == null) return 1;
if (db == null) return -1; if (db == null) return -1;
@@ -101,6 +103,49 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
} }
} }
Future<void> _confirmPayment(
FinanceEntry entry,
String Function(double) formatAmount,
) async {
final confirmed = await showDialog<bool>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst; final isSortActive = _sort != _FinanceSort.newestFirst;
@@ -154,8 +199,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Hata: ${snap.error}', Text('Hata: ${snap.error}',
style: const TextStyle( style: const TextStyle(color: AppColors.textSecondary)),
color: AppColors.textSecondary)),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
onPressed: _load, onPressed: _load,
@@ -181,7 +225,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
children: [ children: [
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
label: s.pendingReceivable, label: 'Açık Alacak',
amount: formatAmount(pendingTotal), amount: formatAmount(pendingTotal),
color: AppColors.pending, color: AppColors.pending,
bgColor: AppColors.pendingBg, bgColor: AppColors.pendingBg,
@@ -191,7 +235,7 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
label: s.collected, label: 'Onaylanan Tahsilat',
amount: formatAmount(paidTotal), amount: formatAmount(paidTotal),
color: AppColors.success, color: AppColors.success,
bgColor: AppColors.successBg, bgColor: AppColors.successBg,
@@ -226,6 +270,8 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
emptyIcon: Icons.hourglass_empty_rounded, emptyIcon: Icons.hourglass_empty_rounded,
formatDate: _formatDate, formatDate: _formatDate,
formatAmount: formatAmount, formatAmount: formatAmount,
onConfirmPayment: (entry) =>
_confirmPayment(entry, formatAmount),
), ),
_EntriesList( _EntriesList(
entries: paid, entries: paid,
@@ -334,6 +380,7 @@ class _EntriesList extends StatelessWidget {
required this.emptyIcon, required this.emptyIcon,
required this.formatDate, required this.formatDate,
required this.formatAmount, required this.formatAmount,
this.onConfirmPayment,
}); });
final List<FinanceEntry> entries; final List<FinanceEntry> entries;
@@ -341,6 +388,7 @@ class _EntriesList extends StatelessWidget {
final IconData emptyIcon; final IconData emptyIcon;
final String Function(String?) formatDate; final String Function(String?) formatDate;
final String Function(double) formatAmount; final String Function(double) formatAmount;
final Future<void> Function(FinanceEntry entry)? onConfirmPayment;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -374,11 +422,28 @@ class _EntriesList extends StatelessWidget {
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final entry = entries[i]; final entry = entries[i];
final isPending = entry.status == FinanceStatus.pending; final isPending = entry.status == FinanceStatus.pending;
final statusColor = isPending ? AppColors.pending : AppColors.success; final isReported = entry.status == FinanceStatus.reported;
final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg; final statusColor = isPending
? AppColors.pending
: isReported
? AppColors.accent
: AppColors.success;
final statusBg = isPending
? AppColors.pendingBg
: isReported
? AppColors.inProgressBg
: AppColors.successBg;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: isReported && onConfirmPayment != null
? () => onConfirmPayment!(entry)
: null,
borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -403,6 +468,8 @@ class _EntriesList extends StatelessWidget {
child: Icon( child: Icon(
isPending isPending
? Icons.hourglass_empty_rounded ? Icons.hourglass_empty_rounded
: isReported
? Icons.verified_outlined
: Icons.check_circle_outline, : Icons.check_circle_outline,
color: statusColor, color: statusColor,
size: 22, size: 22,
@@ -436,6 +503,21 @@ class _EntriesList extends StatelessWidget {
fontSize: 12, color: AppColors.textMuted), 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),
),
],
], ],
), ),
), ),
@@ -473,6 +555,8 @@ class _EntriesList extends StatelessWidget {
], ],
), ),
), ),
),
),
); );
}, },
); );
+125 -55
View File
@@ -14,13 +14,13 @@ import 'lab_jobs_repository.dart';
// ── Adaptive sheet helper ──────────────────────────────────────────────────── // ── Adaptive sheet helper ────────────────────────────────────────────────────
void _showAdaptive(BuildContext context, Widget content) { void _showAdaptive(BuildContext context, Widget content) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) { if (isDesktop) {
showDialog( showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
shape: shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: content, child: content,
@@ -51,33 +51,66 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
String? _loadError; String? _loadError;
bool _isActing = false; bool _isActing = false;
late Future<List<JobFile>> _filesFuture; late Future<List<JobFile>> _filesFuture;
late UnsubFn _unsub; late Future<List<JobHistoryEntry>> _historyFuture;
final List<UnsubFn> _unsubs = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); _load();
_loadFiles(); _loadFiles();
_unsub = RealtimeService.instance.watch( _loadHistory();
_unsubs.add(RealtimeService.instance.watch(
'jobs', 'jobs',
topic: widget.jobId, 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 @override
void dispose() { void dispose() {
_unsub(); for (final unsub in _unsubs) {
unsub();
}
super.dispose(); super.dispose();
} }
Future<void> _load() async { Future<void> _load() async {
setState(() { _loadingJob = true; _loadError = null; }); setState(() {
_loadingJob = true;
_loadError = null;
});
try { try {
final job = await LabJobsRepository.instance.getJob(widget.jobId); final job = await LabJobsRepository.instance.getJob(widget.jobId);
if (mounted) setState(() { _job = job; _loadingJob = false; }); if (mounted) {
setState(() {
_job = job;
_loadingJob = false;
});
}
} catch (e) { } 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<LabJobDetailScreen> {
}); });
} }
void _loadHistory() {
setState(() {
_historyFuture = JobHistoryService.instance.listForJob(widget.jobId);
});
}
Future<void> _cancelJob(Job job) async { Future<void> _cancelJob(Job job) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('İşi İptal Et'), 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')), TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Vazgeç')),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
@@ -108,13 +150,18 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
try { try {
final updated = await LabJobsRepository.instance.cancelJob(job.id, job); final updated = await LabJobsRepository.instance.cancelJob(job.id, job);
if (mounted) { if (mounted) {
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; }); setState(() {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); _job = _job!.copyWith(status: updated.status);
_isActing = false;
});
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() => _isActing = false); 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<LabJobDetailScreen> {
try { try {
final updated = await LabJobsRepository.instance.acceptJob(job); final updated = await LabJobsRepository.instance.acceptJob(job);
if (mounted) { 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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('İş kabul edildi')), const SnackBar(content: Text('İş kabul edildi')),
); );
@@ -132,7 +183,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() => _isActing = false); 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<LabJobDetailScreen> {
_HandToClinicSheet( _HandToClinicSheet(
job: job, job: job,
onDone: (Job updated) { 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<LabJobDetailScreen> {
} }
String _formatDate(DateTime dt, {bool withTime = false}) { 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; if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d;
return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
} }
@@ -264,7 +320,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.headlineSmall .headlineSmall
?.copyWith(fontWeight: FontWeight.bold, ?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.textPrimary), color: AppColors.textPrimary),
), ),
), ),
@@ -291,8 +348,7 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
icon: Icons.business, icon: Icons.business,
label: 'Klinik', label: 'Klinik',
value: job.clinicName ?? '-'), value: job.clinicName ?? '-'),
if (job.patientName != null && if (job.patientName != null && job.patientName!.isNotEmpty)
job.patientName!.isNotEmpty)
_InfoRow( _InfoRow(
icon: Icons.person_outline, icon: Icons.person_outline,
label: 'Hasta', label: 'Hasta',
@@ -320,6 +376,11 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
label: 'İş Tipi', label: 'İş Tipi',
value: job.workflowType!.label, value: job.workflowType!.label,
), ),
_InfoRow(
icon: Icons.route_outlined,
label: 'Akış',
value: job.workflowPreset.title,
),
_InfoRow( _InfoRow(
icon: Icons.fact_check_outlined, icon: Icons.fact_check_outlined,
label: 'Prova', label: 'Prova',
@@ -352,8 +413,7 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
label: 'Fiyat', label: 'Fiyat',
value: value:
'${job.price!.toStringAsFixed(2)} ${job.currency}'), '${job.price!.toStringAsFixed(2)} ${job.currency}'),
if (job.description != null && if (job.description != null && job.description!.isNotEmpty)
job.description!.isNotEmpty)
_InfoRow( _InfoRow(
icon: Icons.notes, icon: Icons.notes,
label: 'Açıklama', label: 'Açıklama',
@@ -385,10 +445,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
children: [ children: [
Text( Text(
'İş Adımları', 'İş Adımları',
style: Theme.of(context) style: Theme.of(context).textTheme.titleMedium?.copyWith(
.textTheme fontWeight: FontWeight.w600,
.titleMedium
?.copyWith(fontWeight: FontWeight.w600,
color: AppColors.textPrimary), color: AppColors.textPrimary),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -418,8 +476,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
_JobStepper( _JobStepper(
steps: job.stepTemplate, steps: job.stepTemplate,
currentStep: job.currentStep, currentStep: job.currentStep,
historyFuture: JobHistoryService.instance isDelivered: job.status == JobStatus.delivered,
.listForJob(job.id), historyFuture: _historyFuture,
), ),
], ],
), ),
@@ -431,7 +489,8 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
if (_isActing) if (_isActing)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
child: Center(child: CircularProgressIndicator(color: AppColors.accent)), child: Center(
child: CircularProgressIndicator(color: AppColors.accent)),
) )
else ...[ else ...[
if (canAccept) if (canAccept)
@@ -444,7 +503,6 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
), ),
), ),
if (canSendToClinic) if (canSendToClinic)
FilledButton.icon( FilledButton.icon(
onPressed: () => _showHandToClinicSheet(job), onPressed: () => _showHandToClinicSheet(job),
@@ -461,7 +519,6 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
: AppColors.inProgress, : AppColors.inProgress,
), ),
), ),
if (canCancelJobs && job.status == JobStatus.pending) ...[ if (canCancelJobs && job.status == JobStatus.pending) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon( OutlinedButton.icon(
@@ -515,12 +572,19 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
@override @override
Widget build(BuildContext context) { 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 isLast = widget.job.isLastStep;
final stepLabel = widget.job.currentStep?.label ?? ''; final stepLabel = currentStep?.label ?? '';
final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true;
final buttonLabel = isLast final buttonLabel = isLast
? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder') ? (widget.job.provaRequired
: '$stepLabel için Kliniğe Gönder'; ? '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; final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
return Container( return Container(
@@ -534,9 +598,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
left: 20, left: 20,
right: 20, right: 20,
top: 24, top: 24,
bottom: isDesktop bottom: isDesktop ? 24 : MediaQuery.of(context).viewInsets.bottom + 24,
? 24
: MediaQuery.of(context).viewInsets.bottom + 24,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -544,17 +606,16 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
children: [ children: [
Text( Text(
buttonLabel, buttonLabel,
style: Theme.of(context) style: Theme.of(context).textTheme.titleMedium?.copyWith(
.textTheme fontWeight: FontWeight.bold, color: AppColors.textPrimary),
.titleMedium
?.copyWith(fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
isLast isLast
? 'İş teslim edilecek olarak işaretlenecek.' ? 'İş 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), style: const TextStyle(color: AppColors.textSecondary),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -575,7 +636,8 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
final updated = await LabJobsRepository.instance.handToClinic( final updated =
await LabJobsRepository.instance.handToClinic(
widget.job.id, widget.job.id,
widget.job, widget.job,
note: _noteController.text.trim().isEmpty note: _noteController.text.trim().isEmpty
@@ -587,7 +649,9 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
SnackBar( SnackBar(
content: Text(isLast content: Text(isLast
? 'İş teslim için gönderildi' ? 'İş 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); if (context.mounted) widget.onDone(updated);
} catch (e) { } catch (e) {
@@ -645,7 +709,8 @@ class _InfoRow extends StatelessWidget {
width: 110, width: 110,
child: Text( child: Text(
label, label,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), style:
const TextStyle(color: AppColors.textSecondary, fontSize: 13),
), ),
), ),
Expanded( Expanded(
@@ -670,10 +735,12 @@ class _JobStepper extends StatelessWidget {
const _JobStepper({ const _JobStepper({
required this.steps, required this.steps,
required this.currentStep, required this.currentStep,
required this.isDelivered,
required this.historyFuture, required this.historyFuture,
}); });
final List<JobStep> steps; final List<JobStep> steps;
final JobStep? currentStep; final JobStep? currentStep;
final bool isDelivered;
final Future<List<JobHistoryEntry>> historyFuture; final Future<List<JobHistoryEntry>> historyFuture;
@override @override
@@ -686,7 +753,8 @@ class _JobStepper extends StatelessWidget {
final Map<JobStep, int> revisionCounts = {}; final Map<JobStep, int> revisionCounts = {};
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {}; final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
for (final e in history) { 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; revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
} }
if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) { if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) {
@@ -699,8 +767,8 @@ class _JobStepper extends StatelessWidget {
return Column( return Column(
children: List.generate(steps.length, (i) { children: List.generate(steps.length, (i) {
final step = steps[i]; final step = steps[i];
final isCompleted = i < currentIndex; final isCompleted = isDelivered || i < currentIndex;
final isCurrent = i == currentIndex; final isCurrent = !isDelivered && i == currentIndex;
final isLastItem = i == steps.length - 1; final isLastItem = i == steps.length - 1;
final revCount = revisionCounts[step] ?? 0; final revCount = revisionCounts[step] ?? 0;
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[]; final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
@@ -728,7 +796,7 @@ class _JobStepper extends StatelessWidget {
Container( Container(
width: 2, width: 2,
height: 44, height: 44,
color: i < currentIndex color: isDelivered || i < currentIndex
? AppColors.success.withValues(alpha: 0.35) ? AppColors.success.withValues(alpha: 0.35)
: AppColors.border, : AppColors.border,
), ),
@@ -788,7 +856,8 @@ class _JobStepper extends StatelessWidget {
), ),
if (stepNotes.isNotEmpty) ...[ if (stepNotes.isNotEmpty) ...[
const SizedBox(height: 8), 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) { String _label(JobHistoryAction action) {
return switch (action) { return switch (action) {
JobHistoryAction.revisionRequested => 'Revizyon Notu', JobHistoryAction.revisionRequested => 'Revizyon Notu',
JobHistoryAction.stepCompleted => 'İç Adım Notu',
JobHistoryAction.handedToClinic => 'Laboratuvar Notu', JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
JobHistoryAction.approved => 'Onay Notu', JobHistoryAction.approved => 'Onay Notu',
JobHistoryAction.delivered => 'Teslim Notu', JobHistoryAction.delivered => 'Teslim Notu',
+35 -11
View File
@@ -33,8 +33,12 @@ class LabJobsRepository {
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
} }
Future<List<Job>> listInProgress(String labTenantId, {int limit = 50, String? location}) async { Future<List<Job>> listInProgress(String labTenantId,
final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"']; {int limit = 50, String? location}) async {
final filterParts = [
'lab_tenant_id = "$labTenantId"',
'status = "in_progress"'
];
if (location != null) filterParts.add('location = "$location"'); if (location != null) filterParts.add('location = "$location"');
final result = await _pb.collection('jobs').getList( final result = await _pb.collection('jobs').getList(
perPage: limit, perPage: limit,
@@ -43,7 +47,9 @@ class LabJobsRepository {
); );
return (result.items.map((r) => Job.fromJson(r.toJson())).toList() return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) { ..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 (a.dueDate == null) return 1;
if (b.dueDate == null) return -1; if (b.dueDate == null) return -1;
final cmp = a.dueDate!.compareTo(b.dueDate!); final cmp = a.dueDate!.compareTo(b.dueDate!);
@@ -52,7 +58,8 @@ class LabJobsRepository {
} }
Future<Job> getJob(String jobId) async { Future<Job> 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()); return Job.fromJson(record.toJson());
} }
@@ -75,10 +82,21 @@ class LabJobsRepository {
} }
Future<Job> handToClinic(String jobId, Job job, {String? note}) async { Future<Job> 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 final patch = isFinal
? {'status': 'sent', 'location': 'at_clinic'} ? {'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 record = await _pb.collection('jobs').update(jobId, body: patch);
final updated = Job.fromJson(record.toJson()); final updated = Job.fromJson(record.toJson());
@@ -86,8 +104,10 @@ class LabJobsRepository {
jobId: jobId, jobId: jobId,
clinicTenantId: job.clinicTenantId, clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId, labTenantId: job.labTenantId,
action: JobHistoryAction.handedToClinic, action: currentStep.requiresClinicApproval || isFinal
step: job.currentStep, ? JobHistoryAction.handedToClinic
: JobHistoryAction.stepCompleted,
step: currentStep,
note: note, note: note,
)); ));
return updated; return updated;
@@ -109,7 +129,8 @@ class LabJobsRepository {
} }
Future<void> bulkAcceptPending(String labTenantId) async { Future<void> 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))); await Future.wait(pending.map((j) => acceptJob(j)));
} }
@@ -121,11 +142,14 @@ class LabJobsRepository {
return r.totalItems; return r.totalItems;
} }
Future<int> countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async { Future<int> countDelivered(String labTenantId,
{DateTime? from, DateTime? to}) async {
final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"']; final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"'];
if (from != null) parts.add('updated >= "${_date(from)}"'); if (from != null) parts.add('updated >= "${_date(from)}"');
if (to != null) parts.add('updated < "${_date(to)}"'); 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; return r.totalItems;
} }
@@ -8,8 +8,12 @@ import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart'; import '../../../core/providers/locale_provider.dart';
import '../../../core/router/app_router.dart'; import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart';
import '../../../models/tenant.dart'; import '../../../models/tenant.dart';
import '../../shared/location_completion_banner.dart';
import '../../shared/tenant_team_screen.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'; import '../connections/lab_connections_screen.dart';
class LabSettingsScreen extends ConsumerWidget { class LabSettingsScreen extends ConsumerWidget {
@@ -29,6 +33,17 @@ class LabSettingsScreen extends ConsumerWidget {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ 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 // User card
_SectionHeader(title: s.userInfo), _SectionHeader(title: s.userInfo),
_UserCard(profile: profile), _UserCard(profile: profile),
@@ -60,7 +75,9 @@ class LabSettingsScreen extends ConsumerWidget {
_InfoTileBadge( _InfoTileBadge(
icon: Icons.circle_outlined, icon: Icons.circle_outlined,
label: s.status, label: s.status,
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'), value: tenant?.status == 'active'
? s.active
: (tenant?.status ?? '-'),
badgeColor: AppColors.success, badgeColor: AppColors.success,
badgeBg: AppColors.successBg, badgeBg: AppColors.successBg,
), ),
@@ -69,9 +86,42 @@ class LabSettingsScreen extends ConsumerWidget {
label: s.role, label: s.role,
value: _roleLabel(membership?.role, s), value: _roleLabel(membership?.role, s),
), ),
_InfoTile(
icon: Icons.place_outlined,
label: 'Konum',
value: tenant?.locationLabel.isNotEmpty == true
? tenant!.locationLabel
: '-',
),
]), ]),
const SizedBox(height: 20), 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 // Connections
if (membership?.showConnections ?? false) ...[ if (membership?.showConnections ?? false) ...[
_SectionHeader(title: s.connections), _SectionHeader(title: s.connections),
@@ -107,7 +157,9 @@ class LabSettingsScreen extends ConsumerWidget {
onTap: () { onTap: () {
ref.read(authProvider.notifier).setActiveTenant(m); ref.read(authProvider.notifier).setActiveTenant(m);
ScaffoldMessenger.of(context).showSnackBar( 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, subtitle: s.teamSub,
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (_) => const TenantTeamScreen()),
builder: (_) => const TenantTeamScreen()),
), ),
), ),
_NavTile( _NavTile(
@@ -155,6 +206,14 @@ class LabSettingsScreen extends ConsumerWidget {
subtitle: s.aiAssistantSub, subtitle: s.aiAssistantSub,
onTap: () => context.push(routeLabAi), 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), const SizedBox(height: 20),
], ],
@@ -167,7 +226,8 @@ class LabSettingsScreen extends ConsumerWidget {
iconColor: AppColors.accent, iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg, iconBg: AppColors.inProgressBg,
title: s.appLanguage, title: s.appLanguage,
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s), subtitle: _currentLanguageLabel(
ref.watch(localeProvider).languageCode, s),
onTap: () => _showLanguagePicker(context, ref, 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; if (tenant == null) return;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -200,11 +261,12 @@ class LabSettingsScreen extends ConsumerWidget {
builder: (_) => _EditTenantSheet( builder: (_) => _EditTenantSheet(
tenant: tenant, tenant: tenant,
s: s, s: s,
onSave: (name, currency) async { onSave: (name, currency, location) async {
await ref.read(authProvider.notifier).updateTenantInfo( await ref.read(authProvider.notifier).updateTenantInfo(
tenantId: tenant.id, tenantId: tenant.id,
companyName: name, companyName: name,
defaultCurrency: currency, 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) => static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
switch (kind) { switch (kind) {
TenantKind.clinic => s.tenantKindClinic, TenantKind.clinic => s.tenantKindClinic,
@@ -226,7 +311,8 @@ class LabSettingsScreen extends ConsumerWidget {
null => '-', null => '-',
}; };
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) { static String _currentLanguageLabel(String code, AppStrings s) =>
switch (code) {
'en' => s.languageEnglish, 'en' => s.languageEnglish,
'ru' => s.languageRussian, 'ru' => s.languageRussian,
'ar' => s.languageArabic, 'ar' => s.languageArabic,
@@ -334,7 +420,11 @@ class _EditTenantSheet extends StatefulWidget {
}); });
final Tenant tenant; final Tenant tenant;
final AppStrings s; final AppStrings s;
final Future<void> Function(String companyName, String currency) onSave; final Future<void> Function(
String companyName,
String currency,
TenantLocationData location,
) onSave;
@override @override
State<_EditTenantSheet> createState() => _EditTenantSheetState(); State<_EditTenantSheet> createState() => _EditTenantSheetState();
@@ -342,7 +432,11 @@ class _EditTenantSheet extends StatefulWidget {
class _EditTenantSheetState extends State<_EditTenantSheet> { class _EditTenantSheetState extends State<_EditTenantSheet> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final TextEditingController _cityController;
late final TextEditingController _districtController;
late String _selectedCurrency; late String _selectedCurrency;
late TenantLocationData _location;
bool _saving = false; bool _saving = false;
static const _currencies = [ static const _currencies = [
@@ -358,26 +452,39 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.tenant.companyName); _nameController = TextEditingController(text: widget.tenant.companyName);
_selectedCurrency = widget.tenant.defaultCurrency; _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 @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_addressController.dispose();
_cityController.dispose();
_districtController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _submit() async { Future<void> _submit() async {
final name = _nameController.text.trim(); final name = _nameController.text.trim();
if (name.isEmpty) return; 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); setState(() => _saving = true);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
await widget.onSave(name, _selectedCurrency); await widget.onSave(name, _selectedCurrency, location);
navigator.pop(); navigator.pop();
} catch (e) { } catch (e) {
messenger.showSnackBar( messenger
SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); .showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
} }
@@ -431,7 +538,7 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
color: AppColors.textSecondary)), color: AppColors.textSecondary)),
const SizedBox(height: 8), const SizedBox(height: 8),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedCurrency, initialValue: _selectedCurrency,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14), const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@@ -454,13 +561,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
if (v != null) setState(() => _selectedCurrency = v); 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), const SizedBox(height: 20),
if (_saving) if (_saving)
const Center( const Center(
child: CircularProgressIndicator(color: AppColors.accent)) child: CircularProgressIndicator(color: AppColors.accent))
else else
FilledButton( FilledButton(
onPressed: _submit, onPressed: _saving ? null : _submit,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48)), minimumSize: const Size(double.infinity, 48)),
child: Text(s.save), child: Text(s.save),
@@ -593,7 +778,10 @@ class _InfoCard extends StatelessWidget {
children[i], children[i],
if (i < children.length - 1) if (i < children.length - 1)
const Divider( 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), const SizedBox(width: 12),
Expanded( Expanded(
child: Text(label, child: Text(label,
style: const TextStyle( style:
fontSize: 11, color: AppColors.textMuted)), const TextStyle(fontSize: 11, color: AppColors.textMuted)),
), ),
Container( Container(
padding: padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: badgeBg, color: badgeBg,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -704,8 +891,7 @@ class _NavTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container( leading: Container(
width: 36, width: 36,
height: 36, height: 36,
@@ -720,13 +906,170 @@ class _NavTile extends StatelessWidget {
? Text(subtitle!, ? Text(subtitle!,
style: const TextStyle(color: AppColors.textSecondary)) style: const TextStyle(color: AppColors.textSecondary))
: null, : null,
trailing: trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: onTap, onTap: onTap,
); );
} }
} }
class _WorkflowPreviewTile extends StatelessWidget {
const _WorkflowPreviewTile({
required this.enabledSteps,
required this.canEdit,
this.onTap,
});
final List<JobStep> 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<void> Function(List<JobStep> steps) onSave;
@override
State<_WorkflowSettingsSheet> createState() => _WorkflowSettingsSheetState();
}
class _WorkflowSettingsSheetState extends State<_WorkflowSettingsSheet> {
late final Set<JobStep> _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 { class _SignOutCard extends StatelessWidget {
const _SignOutCard({required this.ref, required this.s}); const _SignOutCard({required this.ref, required this.s});
final WidgetRef ref; final WidgetRef ref;
@@ -747,16 +1090,14 @@ class _SignOutCard extends StatelessWidget {
], ],
), ),
child: ListTile( child: ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container( leading: Container(
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cancelledBg, color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(9)), borderRadius: BorderRadius.circular(9)),
child: const Icon(Icons.logout, child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18),
color: AppColors.cancelled, size: 18),
), ),
title: Text(s.signOut, title: Text(s.signOut,
style: const TextStyle( style: const TextStyle(
@@ -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),
),
],
),
),
],
),
);
}
}
@@ -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<TenantLocationData?> showLocationPickerSheet(
BuildContext context, {
TenantLocationData? initialLocation,
String title = 'Konum Seç',
}) {
return showModalBottomSheet<TenantLocationData>(
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<void> _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<void> _moveCamera(LatLng point, double zoom) async {
final controller = _mapController;
if (controller == null) return;
await controller.animateCamera(
CameraUpdate.newLatLngZoom(point, zoom),
);
}
Future<void> _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<void> _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<void> _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<String>().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'),
),
),
],
),
);
}
}
@@ -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<String, dynamic> 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,
);
}
@@ -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<List<PlatformMembership>> 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<List<TenantSubscription>> 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<List<AiCreditLedgerEntry>> 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<List<AiUsageLog>> 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<List<AdminAuditLog>> 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<void> 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<void> 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 = <String, dynamic>{
'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<UserProfile> platformUsers;
final List<Tenant> tenants;
final List<TenantSubscription> activeSubscriptions;
final List<AiUsageLog> aiUsageLogs;
}
+22 -6
View File
@@ -5,11 +5,23 @@ extension FinanceTypeX on FinanceType {
String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç'; String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç';
} }
enum FinanceStatus { pending, paid } enum FinanceStatus { pending, reported, paid }
extension FinanceStatusX on FinanceStatus { extension FinanceStatusX on FinanceStatus {
String get value => name; 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 { class FinanceEntry {
@@ -44,7 +56,11 @@ class FinanceEntry {
factory FinanceEntry.fromJson(Map<String, dynamic> j) { factory FinanceEntry.fromJson(Map<String, dynamic> j) {
final expand = j['expand'] as Map<String, dynamic>?; final expand = j['expand'] as Map<String, dynamic>?;
final jobExp = expand?['job_id'] as Map<String, dynamic>?; final jobExp = expand?['job_id'] as Map<String, dynamic>?;
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( return FinanceEntry(
id: j['id'] as String, id: j['id'] as String,
tenantId: j['tenant_id'] as String, tenantId: j['tenant_id'] as String,
@@ -55,9 +71,9 @@ class FinanceEntry {
currency: j['currency'] as String? ?? 'TRY', currency: j['currency'] as String? ?? 'TRY',
status: FinanceStatus.values.firstWhere((e) => e.value == j['status'], status: FinanceStatus.values.firstWhere((e) => e.value == j['status'],
orElse: () => FinanceStatus.pending), orElse: () => FinanceStatus.pending),
counterpartyTenantId: _str(j['counterparty_tenant_id']), counterpartyTenantId: parseOptionalString(j['counterparty_tenant_id']),
paidAt: _str(j['paid_at']), paidAt: parseOptionalString(j['paid_at']),
counterpartyName: _str(j['counterparty_name']), counterpartyName: parseOptionalString(j['counterparty_name']),
patientCode: jobExp?['patient_code'] as String?, patientCode: jobExp?['patient_code'] as String?,
dateCreated: j['created'] as String?, dateCreated: j['created'] as String?,
); );
+335 -25
View File
@@ -2,12 +2,18 @@ enum JobStatus { pending, inProgress, sent, delivered, cancelled }
enum JobStep { enum JobStep {
olcu, // legacy fallback 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) altYapiProva, // sabit seramik/metal — alt yapı (coping)
ustYapiProva, // sabit seramik — bisküvi prova ustYapiProva, // sabit seramik — bisküvi prova
mumProva, // hareketli protez — mum prova mumProva, // hareketli protez — mum prova
dislerProva, // hareketli protez — dişler prova dislerProva, // hareketli protez — dişler prova
dayanakProva, // implant — dayanak prova dayanakProva, // implant — dayanak prova
kronProva, // implant — kron 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) cilaBitim, // son cila / bitim (her şablonda son adım)
} }
@@ -15,6 +21,8 @@ enum JobLocation { atClinic, atLab }
enum JobWorkflowType { arjinat, geleneksel, dijital } enum JobWorkflowType { arjinat, geleneksel, dijital }
enum ProstheticFamily { sabit, implant, hareketli, gecici, ozel }
enum ProstheticType { enum ProstheticType {
metalPorselen, metalPorselen,
zirkonyum, zirkonyum,
@@ -50,37 +58,74 @@ extension JobStatusExt on JobStatus {
extension JobStepExt on JobStep { extension JobStepExt on JobStep {
String get label => switch (this) { 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.altYapiProva => 'Alt Yapı Prova',
JobStep.ustYapiProva => 'Üst Yapı Prova', JobStep.ustYapiProva => 'Üst Yapı Prova',
JobStep.mumProva => 'Mum Prova', JobStep.mumProva => 'Mum Prova',
JobStep.dislerProva => 'Dişler Prova', JobStep.dislerProva => 'Dişler Prova',
JobStep.dayanakProva => 'Dayanak Prova', JobStep.dayanakProva => 'Dayanak Prova',
JobStep.kronProva => 'Kron Prova', JobStep.kronProva => 'Kron Prova',
JobStep.fotografOnay => 'Fotoğraf / Mockup Onayı',
JobStep.kaliteKontrol => 'Kalite Kontrol',
JobStep.teslimOncesiKontrol => 'Teslim Öncesi Kontrol',
JobStep.cilaBitim => 'Cila / Bitim', JobStep.cilaBitim => 'Cila / Bitim',
}; };
/// One-liner shown under the step on the stepper /// One-liner shown under the step on the stepper
String get description => switch (this) { 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.altYapiProva => 'Metal/zirkonyum coping klinik onayı',
JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı', JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı',
JobStep.mumProva => 'Mum prova klinik onayı', JobStep.mumProva => 'Mum prova klinik onayı',
JobStep.dislerProva => 'Diş dizimi klinik onayı', JobStep.dislerProva => 'Diş dizimi klinik onayı',
JobStep.dayanakProva => 'Dayanak klinik onayı', JobStep.dayanakProva => 'Dayanak klinik onayı',
JobStep.kronProva => 'Kron klinik onayı', 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ığı', JobStep.cilaBitim => 'Son cila ve teslim hazırlığı',
}; };
String get value => switch (this) { 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.altYapiProva => 'alt_yapi_prova',
JobStep.ustYapiProva => 'ust_yapi_prova', JobStep.ustYapiProva => 'ust_yapi_prova',
JobStep.mumProva => 'mum_prova', JobStep.mumProva => 'mum_prova',
JobStep.dislerProva => 'disler_prova', JobStep.dislerProva => 'disler_prova',
JobStep.dayanakProva => 'dayanak_prova', JobStep.dayanakProva => 'dayanak_prova',
JobStep.kronProva => 'kron_prova', JobStep.kronProva => 'kron_prova',
JobStep.fotografOnay => 'fotograf_onay',
JobStep.kaliteKontrol => 'kalite_kontrol',
JobStep.teslimOncesiKontrol => 'teslim_oncesi_kontrol',
JobStep.cilaBitim => 'cila_bitim', 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,
};
} }
extension JobWorkflowTypeExt on JobWorkflowType { extension JobWorkflowTypeExt on JobWorkflowType {
@@ -120,33 +165,258 @@ extension ProstheticTypeExt on ProstheticType {
ProstheticType.parsiyel => 'parsiyel', ProstheticType.parsiyel => 'parsiyel',
ProstheticType.diger => 'diger', 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 ───────────────────────────────────────────────────────────── // ── Step template ─────────────────────────────────────────────────────────────
/// Returns the ordered step list for a given prosthetic type + prova flag. class JobWorkflowPreset {
List<JobStep> jobStepTemplate(ProstheticType type, bool provaRequired) { const JobWorkflowPreset({
if (!provaRequired) return const [JobStep.cilaBitim]; required this.title,
return switch (type) { required this.summary,
// Sabit seramik: alt yapı coping + bisküvi prova + cila required this.steps,
ProstheticType.metalPorselen || });
ProstheticType.zirkonyum ||
ProstheticType.eMax => final String title;
const [JobStep.altYapiProva, JobStep.ustYapiProva, JobStep.cilaBitim], final String summary;
// İmplant: dayanak + kron prova + cila final List<JobStep> steps;
ProstheticType.implantUstuZirkonyum => }
const [JobStep.dayanakProva, JobStep.kronProva, JobStep.cilaBitim],
// Hareketli protez: mum + dişler prova + cila const optionalLabStepCatalog = <JobStep>[
ProstheticType.tamProtez || JobStep.modelHazirlik,
ProstheticType.parsiyel => JobStep.fotografOnay,
const [JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim], JobStep.kaliteKontrol,
// Geçici: sadece cila (prova gereksiz) JobStep.teslimOncesiKontrol,
ProstheticType.gecici => ];
const [JobStep.cilaBitim],
// Diğer: tek ara prova + cila bool isOptionalStepApplicable(
_ => JobStep step, {
const [JobStep.altYapiProva, JobStep.cilaBitim], 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<JobStep> mergeOptionalLabSteps({
required List<JobStep> baseSteps,
required List<JobStep> optionalSteps,
required JobWorkflowType workflowType,
required ProstheticFamily family,
required bool provaRequired,
}) {
final merged = List<JobStep>.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<JobStep> optionalSteps = const [],
}) {
final normalizedWorkflow = workflowType ?? JobWorkflowType.geleneksel;
final family = prostheticType.family;
List<JobStep> 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<JobStep> jobStepTemplate(
ProstheticType type,
bool provaRequired, {
JobWorkflowType? workflowType,
List<JobStep> optionalSteps = const [],
}) {
return buildJobWorkflowPreset(
prostheticType: type,
workflowType: workflowType,
provaRequired: provaRequired,
optionalSteps: optionalSteps,
).steps;
} }
// ── Job ─────────────────────────────────────────────────────────────────────── // ── Job ───────────────────────────────────────────────────────────────────────
@@ -178,6 +448,7 @@ class Job {
this.labName, this.labName,
this.attachments = const [], this.attachments = const [],
this.provaRequired = true, this.provaRequired = true,
this.workflowSteps = const [],
}); });
final String id; final String id;
@@ -203,6 +474,7 @@ class Job {
final DateTime dateCreated; final DateTime dateCreated;
final List<String> attachments; final List<String> attachments;
final bool provaRequired; final bool provaRequired;
final List<JobStep> workflowSteps;
// Denormalized from relation joins — list views only // Denormalized from relation joins — list views only
final String? clinicName; final String? clinicName;
@@ -236,20 +508,45 @@ class Job {
price: price, price: price,
currency: currency, currency: currency,
status: status ?? this.status, status: status ?? this.status,
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep), currentStep:
clearCurrentStep ? null : (currentStep ?? this.currentStep),
location: location ?? this.location, location: location ?? this.location,
workflowType: workflowType ?? this.workflowType, workflowType: workflowType ?? this.workflowType,
dueDate: dueDate, dueDate: dueDate,
dateCreated: dateCreated, dateCreated: dateCreated,
attachments: attachments, attachments: attachments,
provaRequired: provaRequired, provaRequired: provaRequired,
workflowSteps: workflowSteps,
clinicName: clinicName ?? this.clinicName, clinicName: clinicName ?? this.clinicName,
labName: labName ?? this.labName, labName: labName ?? this.labName,
); );
// ── Step helpers ────────────────────────────────────────────────────────── // ── Step helpers ──────────────────────────────────────────────────────────
List<JobStep> get stepTemplate => jobStepTemplate(prostheticType, provaRequired); List<JobStep> 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 => bool get isLastStep =>
currentStep != null && currentStep == stepTemplate.last; currentStep != null && currentStep == stepTemplate.last;
@@ -310,6 +607,11 @@ class Job {
? (j['attachments'] as List).map((e) => e.toString()).toList() ? (j['attachments'] as List).map((e) => e.toString()).toList()
: [], : [],
provaRequired: (j['prova_required'] as bool?) ?? true, 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 [],
); );
} }
@@ -322,16 +624,24 @@ class Job {
}; };
static JobStep _parseStep(String s) => switch (s) { static JobStep _parseStep(String s) => switch (s) {
'olcu_kontrol' => JobStep.olcuKontrol,
'dijital_tasarim' => JobStep.dijitalTasarim,
'model_hazirlik' => JobStep.modelHazirlik,
'alt_yapi_prova' => JobStep.altYapiProva, 'alt_yapi_prova' => JobStep.altYapiProva,
'ust_yapi_prova' => JobStep.ustYapiProva, 'ust_yapi_prova' => JobStep.ustYapiProva,
'mum_prova' => JobStep.mumProva, 'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva, 'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva, 'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva, 'kron_prova' => JobStep.kronProva,
'fotograf_onay' => JobStep.fotografOnay,
'kalite_kontrol' => JobStep.kaliteKontrol,
'teslim_oncesi_kontrol' => JobStep.teslimOncesiKontrol,
'cila_bitim' => JobStep.cilaBitim, 'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu, _ => JobStep.olcu,
}; };
static JobStep parseStepValue(String s) => _parseStep(s);
static JobWorkflowType _parseWorkflowType(String s) => switch (s) { static JobWorkflowType _parseWorkflowType(String s) => switch (s) {
'arjinat' => JobWorkflowType.arjinat, 'arjinat' => JobWorkflowType.arjinat,
'dijital' => JobWorkflowType.dijital, 'dijital' => JobWorkflowType.dijital,
@@ -353,7 +663,7 @@ class Job {
static ProstheticType _parseProstheticType(String s) => switch (s) { static ProstheticType _parseProstheticType(String s) => switch (s) {
'zirkonyum' => ProstheticType.zirkonyum, 'zirkonyum' => ProstheticType.zirkonyum,
'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum, 'implant_ustu_zirkonyum' => ProstheticType.implantUstuZirkonyum,
'gecici' => ProstheticType.gecici, 'gecici' => ProstheticType.gecici,
'e_max' => ProstheticType.eMax, 'e_max' => ProstheticType.eMax,
'tam_protez' => ProstheticType.tamProtez, 'tam_protez' => ProstheticType.tamProtez,
+298
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? metadata;
final DateTime? createdAt;
factory AdminAuditLog.fromJson(Map<String, dynamic> 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<String, dynamic> ? 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);
}
+68 -7
View File
@@ -1,3 +1,5 @@
import 'job.dart';
enum TenantKind { lab, clinic } enum TenantKind { lab, clinic }
enum TenantRole { enum TenantRole {
@@ -31,36 +33,71 @@ class Tenant {
required this.kind, required this.kind,
required this.memberNumber, required this.memberNumber,
required this.companyName, required this.companyName,
this.companyAddress,
this.city,
this.district,
this.latitude,
this.longitude,
this.logo, this.logo,
this.defaultCurrency = 'TRY', this.defaultCurrency = 'TRY',
this.status = 'active', this.status = 'active',
this.plan, this.plan,
this.maxMembers, this.maxMembers,
this.workflowOverrideStepKeys = const [],
}); });
final String id; final String id;
final TenantKind kind; final TenantKind kind;
final String memberNumber; final String memberNumber;
final String companyName; final String companyName;
final String? companyAddress;
final String? city;
final String? district;
final double? latitude;
final double? longitude;
final String? logo; final String? logo;
final String defaultCurrency; final String defaultCurrency;
final String status; final String status;
final TenantPlan? plan; final TenantPlan? plan;
final int? maxMembers; final int? maxMembers;
final List<String> workflowOverrideStepKeys;
bool get isLab => kind == TenantKind.lab; bool get isLab => kind == TenantKind.lab;
bool get isClinic => kind == TenantKind.clinic; bool get isClinic => kind == TenantKind.clinic;
bool get hasLocation => latitude != null && longitude != null;
List<JobStep> 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<String, dynamic> j) => Tenant( factory Tenant.fromJson(Map<String, dynamic> j) => Tenant(
id: j['id'] as String, id: j['id'] as String,
kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic, kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic,
memberNumber: (j['member_number'] as String?) ?? '', memberNumber: (j['member_number'] as String?) ?? '',
companyName: j['company_name'] 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?, logo: j['logo'] as String?,
defaultCurrency: (j['default_currency'] as String?) ?? 'TRY', defaultCurrency: (j['default_currency'] as String?) ?? 'TRY',
status: (j['status'] as String?) ?? 'active', status: (j['status'] as String?) ?? 'active',
plan: _parsePlan(j['plan'] as String?), plan: _parsePlan(j['plan'] as String?),
maxMembers: (j['max_members'] as num?)?.toInt(), 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) { static TenantPlan? _parsePlan(String? p) => switch (p) {
@@ -69,6 +106,9 @@ class Tenant {
'enterprise' => TenantPlan.enterprise, 'enterprise' => TenantPlan.enterprise,
_ => null, _ => null,
}; };
static TenantPlan parsePlanValue(String? value) =>
_parsePlan(value) ?? TenantPlan.starter;
} }
class TenantMembership { class TenantMembership {
@@ -85,22 +125,43 @@ class TenantMembership {
// ── Access helpers ──────────────────────────────────────────────────────── // ── Access helpers ────────────────────────────────────────────────────────
bool get isOwner => role == TenantRole.owner; bool get isOwner => role == TenantRole.owner;
bool get isAdmin => role == TenantRole.admin || 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 canManageJobs => role != TenantRole.finance;
bool get canManageFinance => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.finance || role == TenantRole.member; bool get canManageFinance =>
bool get canManageProducts => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.technician || role == TenantRole.member; role == TenantRole.owner ||
bool get canViewPatients => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.doctor || role == TenantRole.member; role == TenantRole.admin ||
bool get canManageConnections => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member; 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 ────────────────────────────────────────────── // ── Fine-grained job actions ──────────────────────────────────────────────
/// Can create new jobs (clinic side: owner/admin/doctor/member; not delivery/finance) /// 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) /// Can confirm physical delivery (delivery role + supervisors)
bool get canDeliverJobs => role != TenantRole.finance; bool get canDeliverJobs => role != TenantRole.finance;
/// Can cancel or fully manage job lifecycle (not delivery-only or 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 /// Primary focus is delivery — restrict to delivery-relevant UI
bool get isDeliveryOnly => role == TenantRole.delivery; bool get isDeliveryOnly => role == TenantRole.delivery;
@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation import Foundation
import file_picker import file_picker
import geolocator_apple
import path_provider_foundation import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -13,6 +14,7 @@ import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+136
View File
@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.4" version: "0.13.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -445,6 +453,86 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" 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: glob:
dependency: transitive dependency: transitive
description: description:
@@ -501,6 +589,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -557,6 +653,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.9.5" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -597,6 +701,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -765,6 +893,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
+4
View File
@@ -38,6 +38,10 @@ dependencies:
intl: ^0.20.2 intl: ^0.20.2
google_fonts: ^6.2.1 google_fonts: ^6.2.1
flutter_animate: ^4.5.0 flutter_animate: ^4.5.0
maplibre_gl: ^0.26.1
latlong2: ^0.9.1
geolocator: ^13.0.2
geocoding: ^3.0.0
# Utilities # Utilities
freezed_annotation: ^2.4.1 freezed_annotation: ^2.4.1
@@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <geolocator_windows/geolocator_windows.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
SharePlusWindowsPluginCApiRegisterWithRegistrar( SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
+1
View File
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
geolocator_windows
share_plus share_plus
url_launcher_windows url_launcher_windows
) )