Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -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}"
|
||||||
|
|||||||
@@ -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ü
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 iş',
|
'$value iş',
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 iş',
|
'$value iş',
|
||||||
@@ -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 {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user