Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
|
||||
class ClinicConnectionsRepository {
|
||||
ClinicConnectionsRepository._();
|
||||
@@ -10,10 +11,10 @@ class ClinicConnectionsRepository {
|
||||
|
||||
Future<List<Connection>> listConnections(String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId"',
|
||||
expand: 'lab_tenant_id,clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId"',
|
||||
expand: 'lab_tenant_id,clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
@@ -30,11 +31,26 @@ class ClinicConnectionsRepository {
|
||||
return Connection.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> searchLabs(String query) async {
|
||||
Future<List<Tenant>> searchLabs({
|
||||
String query = '',
|
||||
String? city,
|
||||
}) async {
|
||||
final normalizedQuery = query.trim().replaceAll('"', '\\"');
|
||||
final normalizedCity = (city ?? '').trim().replaceAll('"', '\\"');
|
||||
|
||||
final filterParts = ['kind = "lab"'];
|
||||
if (normalizedQuery.isNotEmpty) {
|
||||
filterParts.add(
|
||||
'(company_name ~ "$normalizedQuery" || city ~ "$normalizedQuery" || district ~ "$normalizedQuery")',
|
||||
);
|
||||
} else if (normalizedCity.isNotEmpty) {
|
||||
filterParts.add('city = "$normalizedCity"');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('tenants').getList(
|
||||
filter: 'kind = "lab" && company_name ~ "$query"',
|
||||
perPage: 20,
|
||||
);
|
||||
return result.items.map((r) => r.toJson()).toList();
|
||||
filter: filterParts.join(' && '),
|
||||
perPage: 100,
|
||||
);
|
||||
return result.items.map((r) => Tenant.fromJson(r.toJson())).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
import '../../../core/location/location_access_service.dart';
|
||||
import '../../../core/maps/open_free_map.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import 'clinic_connections_repository.dart';
|
||||
|
||||
class ClinicConnectionsScreen extends ConsumerStatefulWidget {
|
||||
@@ -27,19 +34,19 @@ class _ClinicConnectionsScreenState
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicConnectionsRepository.instance
|
||||
.listConnections(tenantId);
|
||||
_future = ClinicConnectionsRepository.instance.listConnections(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
void _showSearchDialog() {
|
||||
final clinicTenant = ref.read(authProvider).activeTenant!.tenant;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _LabSearchDialog(
|
||||
clinicTenant: clinicTenant,
|
||||
onRequested: (labId, labName) async {
|
||||
Navigator.of(ctx).pop();
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
try {
|
||||
await ClinicConnectionsRepository.instance.requestConnection(
|
||||
clinicTenantId: tenantId,
|
||||
@@ -49,8 +56,8 @@ class _ClinicConnectionsScreenState
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$labName\'a bağlantı talebi gönderildi.')),
|
||||
content: Text('$labName\'a bağlantı talebi gönderildi.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -86,7 +93,8 @@ class _ClinicConnectionsScreenState
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
@@ -97,15 +105,20 @@ class _ClinicConnectionsScreenState
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
Text(
|
||||
'Hata: ${snap.error}',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
@@ -126,18 +139,23 @@ class _ClinicConnectionsScreenState
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.link_off,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.link_off,
|
||||
color: AppColors.inProgress,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Henüz bağlantı yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
@@ -161,25 +179,31 @@ class _ClinicConnectionsScreenState
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.science_outlined,
|
||||
color: statusColor, size: 22),
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.science_outlined,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -189,17 +213,19 @@ class _ClinicConnectionsScreenState
|
||||
Text(
|
||||
conn.labName ?? 'Laboratuvar',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (conn.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(conn.dateCreated!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -259,6 +285,7 @@ class _ClinicConnectionsScreenState
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
@@ -306,7 +333,12 @@ class _StatusChip extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _LabSearchDialog extends StatefulWidget {
|
||||
const _LabSearchDialog({required this.onRequested});
|
||||
const _LabSearchDialog({
|
||||
required this.clinicTenant,
|
||||
required this.onRequested,
|
||||
});
|
||||
|
||||
final Tenant clinicTenant;
|
||||
final void Function(String labId, String labName) onRequested;
|
||||
|
||||
@override
|
||||
@@ -314,128 +346,687 @@ class _LabSearchDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LabSearchDialogState extends State<_LabSearchDialog> {
|
||||
static const _fallbackCenter = LatLng(41.0082, 28.9784);
|
||||
static const _defaultZoom = 10.5;
|
||||
|
||||
final _distance = const ll.Distance();
|
||||
final _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _results = [];
|
||||
Timer? _searchDebounce;
|
||||
List<_LabSearchItem> _results = [];
|
||||
bool _isLoading = false;
|
||||
bool _searched = false;
|
||||
String? _error;
|
||||
String? _selectedLabId;
|
||||
LatLng? _devicePoint;
|
||||
bool _resolvingDeviceLocation = false;
|
||||
MapLibreMapController? _mapController;
|
||||
bool _styleReady = false;
|
||||
|
||||
LatLng get _clinicPoint => LatLng(
|
||||
widget.clinicTenant.latitude ?? _fallbackCenter.latitude,
|
||||
widget.clinicTenant.longitude ?? _fallbackCenter.longitude,
|
||||
);
|
||||
|
||||
bool get _hasClinicLocation => widget.clinicTenant.hasLocation;
|
||||
LatLng? get _searchAnchorPoint =>
|
||||
_devicePoint ?? (_hasClinicLocation ? _clinicPoint : null);
|
||||
|
||||
_LabSearchItem? get _selectedLab {
|
||||
for (final item in _results) {
|
||||
if (item.tenant.id == _selectedLabId) return item;
|
||||
}
|
||||
return _results.isNotEmpty ? _results.first : null;
|
||||
}
|
||||
|
||||
List<_LabSearchItem> get _mappedLabs =>
|
||||
_results.where((item) => item.tenant.hasLocation).toList();
|
||||
|
||||
List<_LabSearchItem> get _legacyLabs =>
|
||||
_results.where((item) => !item.tenant.hasLocation).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _search());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_searched = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final results =
|
||||
await ClinicConnectionsRepository.instance.searchLabs(query);
|
||||
final results = await ClinicConnectionsRepository.instance.searchLabs(
|
||||
query: query,
|
||||
city: widget.clinicTenant.city,
|
||||
);
|
||||
final mapped = results
|
||||
.map(
|
||||
(lab) => _LabSearchItem(
|
||||
tenant: lab,
|
||||
distanceKm: _distanceFor(lab),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final aDistance = a.distanceKm ?? double.infinity;
|
||||
final bDistance = b.distanceKm ?? double.infinity;
|
||||
final compareDistance = aDistance.compareTo(bDistance);
|
||||
if (compareDistance != 0) return compareDistance;
|
||||
return a.tenant.companyName.compareTo(b.tenant.companyName);
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_results = results;
|
||||
_results = mapped;
|
||||
_selectedLabId = mapped.isNotEmpty ? mapped.first.tenant.id : null;
|
||||
_isLoading = false;
|
||||
});
|
||||
await _refreshMarkers();
|
||||
await _moveMapToResults();
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
double? _distanceFor(Tenant lab) {
|
||||
final anchor = _searchAnchorPoint;
|
||||
if (anchor == null || !lab.hasLocation) return null;
|
||||
final meters = _distance(
|
||||
ll.LatLng(anchor.latitude, anchor.longitude),
|
||||
ll.LatLng(lab.latitude!, lab.longitude!),
|
||||
);
|
||||
return meters / 1000;
|
||||
}
|
||||
|
||||
Future<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
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Laboratuvar Bul'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
final selectedLab = _selectedLab;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
height: MediaQuery.sizeOf(context).height * 0.88,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Lab adı ile arayın...',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Laboratuvar Bul',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Lab adı, şehir veya ilçe ile arayın...',
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
)
|
||||
: (_searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_queueSearch('');
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
)
|
||||
: null),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
_queueSearch(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_searchAnchorPoint != null
|
||||
? Icons.near_me_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_devicePoint != null
|
||||
? 'Sonuçlar aktif konumunuza göre yakın olandan sıralanır.'
|
||||
: (_hasClinicLocation
|
||||
? 'Sonuçlar kliniğin konumuna göre yakın olandan sıralanır.'
|
||||
: 'Aktif konumunuzu kullanarak yakın arama yapabilir veya isimle arayabilirsiniz.'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _resolvingDeviceLocation
|
||||
? null
|
||||
: _useDeviceLocationForSearch,
|
||||
icon: _resolvingDeviceLocation
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.my_location_rounded, size: 18),
|
||||
label: Text(
|
||||
_devicePoint == null
|
||||
? 'Aktif Konumumla Ara'
|
||||
: 'Aktif Konumu Yenile',
|
||||
),
|
||||
),
|
||||
if (_devicePoint != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _devicePoint = null);
|
||||
unawaited(_search());
|
||||
},
|
||||
child: const Text('Klinik Konumuna Dön'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Stack(
|
||||
children: [
|
||||
MapLibreMap(
|
||||
styleString: OpenFreeMap.libertyStyle,
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _clinicPoint,
|
||||
zoom: _defaultZoom,
|
||||
),
|
||||
onMapCreated: (controller) {
|
||||
_mapController = controller;
|
||||
},
|
||||
onStyleLoadedCallback: () async {
|
||||
_styleReady = true;
|
||||
await _refreshMarkers();
|
||||
},
|
||||
compassEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
myLocationTrackingMode: MyLocationTrackingMode.none,
|
||||
),
|
||||
if (selectedLab != null)
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: _SelectedLabCard(
|
||||
item: selectedLab,
|
||||
onRequest: () => widget.onRequested(
|
||||
selectedLab.tenant.id,
|
||||
selectedLab.tenant.companyName,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _search,
|
||||
child: const Text('Ara'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
)
|
||||
else if (_searched && _results.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Sonuç bulunamadı',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
)
|
||||
else if (_results.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lab = _results[index];
|
||||
final name =
|
||||
lab['company_name'] as String? ?? 'Lab';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: const Icon(Icons.science_outlined,
|
||||
color: AppColors.inProgress, size: 18),
|
||||
),
|
||||
title: Text(name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
subtitle: lab['member_number'] != null
|
||||
? Text('No: ${lab['member_number']}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary))
|
||||
: null,
|
||||
onTap: () =>
|
||||
widget.onRequested(lab['id'] as String, name),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||
child: _buildResultsList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('İptal'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsList() {
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Hata: $_error',
|
||||
style: const TextStyle(color: AppColors.cancelled),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && !_searched) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
|
||||
if (_searched && _results.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Bu kriterlerde laboratuvar bulunamadı.',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final children = <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/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
@@ -9,6 +10,7 @@ import '../../../core/router/app_router.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/tooth_logo.dart';
|
||||
import '../../shared/location_completion_banner.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../jobs/clinic_jobs_repository.dart';
|
||||
import '../patients/clinic_patients_repository.dart';
|
||||
@@ -23,28 +25,48 @@ class ClinicDashboardScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
late Future<_DashboardData> _future;
|
||||
late UnsubFn _unsub;
|
||||
UnsubFn? _unsub;
|
||||
final Map<String, bool> _actingJobs = {};
|
||||
Timer? _reloadDebounce;
|
||||
String? _subscribedTenantId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "clinic_tenant_id='$tenantId'",
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
_ensureRealtimeSubscription();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
_reloadDebounce?.cancel();
|
||||
_unsub?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureRealtimeSubscription() {
|
||||
final tenantId = ref.read(authProvider).activeTenant?.tenant.id;
|
||||
if (tenantId == null || tenantId == _subscribedTenantId) return;
|
||||
_unsub?.call();
|
||||
_subscribedTenantId = tenantId;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "clinic_tenant_id='$tenantId'",
|
||||
onEvent: (_) {
|
||||
_scheduleReload();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleReload() {
|
||||
_reloadDebounce?.cancel();
|
||||
_reloadDebounce = Timer(const Duration(milliseconds: 250), () {
|
||||
if (mounted) _load();
|
||||
});
|
||||
}
|
||||
|
||||
void _load() {
|
||||
_ensureRealtimeSubscription();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = _loadAll(tenantId);
|
||||
@@ -58,7 +80,9 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
title: Text(job.patientCode),
|
||||
content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.success),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
@@ -73,7 +97,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _actingJobs.remove(job.id));
|
||||
}
|
||||
@@ -84,9 +111,12 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(job.patientCode),
|
||||
content: Text('${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'),
|
||||
content: Text(
|
||||
'${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('İptal')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Teslim Aldım'),
|
||||
@@ -100,7 +130,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
await ClinicJobsRepository.instance.markDelivered(job.id, job);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _actingJobs.remove(job.id));
|
||||
}
|
||||
@@ -112,18 +145,25 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||||
|
||||
final results = await Future.wait([
|
||||
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['pending'], limit: 200),
|
||||
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['in_progress'], limit: 200),
|
||||
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['sent'], limit: 200),
|
||||
ClinicJobsRepository.instance
|
||||
.listOutbound(tenantId, statuses: ['pending'], limit: 200),
|
||||
ClinicJobsRepository.instance
|
||||
.listOutbound(tenantId, statuses: ['in_progress'], limit: 200),
|
||||
ClinicJobsRepository.instance
|
||||
.listOutbound(tenantId, statuses: ['sent'], limit: 200),
|
||||
ClinicJobsRepository.instance.listOutbound(tenantId, limit: 5),
|
||||
ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200),
|
||||
]);
|
||||
final thisMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart);
|
||||
final lastMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart);
|
||||
final thisMonth = await ClinicJobsRepository.instance
|
||||
.countDelivered(tenantId, from: thisMonthStart);
|
||||
final lastMonth = await ClinicJobsRepository.instance
|
||||
.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart);
|
||||
|
||||
final inProgressJobs = results[1] as List<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];
|
||||
|
||||
return _DashboardData(
|
||||
@@ -140,8 +180,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final companyName =
|
||||
ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
|
||||
_ensureRealtimeSubscription();
|
||||
final activeTenant = ref.watch(authProvider).activeTenant?.tenant;
|
||||
final companyName = activeTenant?.companyName ?? '';
|
||||
final showLocationWarning = activeTenant?.hasLocation != true;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
@@ -159,16 +201,31 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
|
||||
return _DashboardSkeleton(
|
||||
companyName: companyName, hPad: hPad);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorBody(onRetry: _load);
|
||||
}
|
||||
final data = snap.data!;
|
||||
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
_DashboardHeader(companyName: companyName),
|
||||
if (showLocationWarning)
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: LocationCompletionBanner(
|
||||
title: 'Konum kaydı eksik',
|
||||
description:
|
||||
'Haritada görünmek ve yakın laboratuvar sıralamasında doğru yer almak için işletme konumunu tamamlayın.',
|
||||
buttonLabel: 'Konumu Tamamla',
|
||||
onTap: () => context.go(routeClinicSettings),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isDesktop)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
@@ -186,14 +243,18 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _MonthlyReportSection(data: data)
|
||||
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _GamificationRow(data: data)
|
||||
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms, delay: 60.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -208,7 +269,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
backgroundColor: AppColors.accent,
|
||||
),
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.1, end: 0),
|
||||
),
|
||||
),
|
||||
if (data.actionJobs.isNotEmpty)
|
||||
@@ -220,7 +284,10 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
actingJobs: _actingJobs,
|
||||
onApprove: _approveAtClinic,
|
||||
onDeliver: _markDelivered,
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.06, end: 0),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.06, end: 0),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
@@ -235,7 +302,8 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
onPressed: () => context.go(routeClinicJobs),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: const Text('Tümünü Gör'),
|
||||
),
|
||||
@@ -251,7 +319,8 @@ class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
|
||||
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.recentJobs.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) =>
|
||||
_JobCard(job: data.recentJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
@@ -319,30 +388,47 @@ class _ActionSection extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26, height: 26,
|
||||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(7)),
|
||||
child: const Icon(Icons.priority_high_rounded, size: 15, color: Colors.white),
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.circular(7)),
|
||||
child: const Icon(Icons.priority_high_rounded,
|
||||
size: 15, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Text('Yapılacaklar',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(10)),
|
||||
child: Text('${jobs.length}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: Colors.white)),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pending,
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: Text('${jobs.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...jobs.asMap().entries.map((entry) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ActionJobCard(
|
||||
job: entry.value,
|
||||
acting: actingJobs[entry.value.id] == true,
|
||||
onApprove: () => onApprove(entry.value),
|
||||
onDeliver: () => onDeliver(entry.value),
|
||||
).animate(delay: (entry.key * 50).ms).fadeIn(duration: 250.ms).slideY(begin: 0.08, end: 0),
|
||||
)),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ActionJobCard(
|
||||
job: entry.value,
|
||||
acting: actingJobs[entry.value.id] == true,
|
||||
onApprove: () => onApprove(entry.value),
|
||||
onDeliver: () => onDeliver(entry.value),
|
||||
)
|
||||
.animate(delay: (entry.key * 50).ms)
|
||||
.fadeIn(duration: 250.ms)
|
||||
.slideY(begin: 0.08, end: 0),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -361,15 +447,18 @@ class _ActionJobCard extends StatelessWidget {
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onDeliver;
|
||||
|
||||
bool get _isProva => job.status == JobStatus.inProgress && job.location == JobLocation.atClinic;
|
||||
bool get _isProva =>
|
||||
job.status == JobStatus.inProgress &&
|
||||
job.location == JobLocation.atClinic;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isProva = _isProva;
|
||||
final borderColor = isProva ? AppColors.pending : AppColors.accent;
|
||||
final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg;
|
||||
final iconColor = isProva ? AppColors.pending : AppColors.accent;
|
||||
final icon = isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined;
|
||||
final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg;
|
||||
final iconColor = isProva ? AppColors.pending : AppColors.accent;
|
||||
final icon =
|
||||
isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined;
|
||||
final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor';
|
||||
|
||||
return Semantics(
|
||||
@@ -385,8 +474,14 @@ class _ActionJobCard extends StatelessWidget {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: borderColor.withValues(alpha: 0.45), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 3))],
|
||||
border: Border.all(
|
||||
color: borderColor.withValues(alpha: 0.45), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -396,8 +491,11 @@ class _ActionJobCard extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(11)),
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(11)),
|
||||
child: Icon(icon, color: iconColor, size: 19),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@@ -405,21 +503,34 @@ class _ActionJobCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
Text(job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${job.prostheticType.label} · ${job.labName ?? 'Lab'}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(statusLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: iconColor)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(statusLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: iconColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -427,14 +538,20 @@ class _ActionJobCard extends StatelessWidget {
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor.withValues(alpha: 0.45),
|
||||
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(13), bottomRight: Radius.circular(13)),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(13),
|
||||
bottomRight: Radius.circular(13)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||
child: acting
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent)),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5, color: AppColors.accent)),
|
||||
),
|
||||
)
|
||||
: isProva
|
||||
@@ -442,27 +559,38 @@ class _ActionJobCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onApprove,
|
||||
icon: const Icon(Icons.check_circle_outline, size: 15),
|
||||
label: const Text('Onayla', style: TextStyle(fontSize: 13)),
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
size: 15),
|
||||
label: const Text('Onayla',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push('/clinic/jobs/${job.id}'),
|
||||
icon: const Icon(Icons.open_in_new_rounded, size: 14),
|
||||
label: const Text('Detay', style: TextStyle(fontSize: 13)),
|
||||
onPressed: () =>
|
||||
context.push('/clinic/jobs/${job.id}'),
|
||||
icon: const Icon(Icons.open_in_new_rounded,
|
||||
size: 14),
|
||||
label: const Text('Detay',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
foregroundColor: AppColors.pending,
|
||||
side: BorderSide(color: AppColors.pending.withValues(alpha: 0.6)),
|
||||
side: BorderSide(
|
||||
color: AppColors.pending
|
||||
.withValues(alpha: 0.6)),
|
||||
),
|
||||
),
|
||||
])
|
||||
@@ -470,26 +598,37 @@ class _ActionJobCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onDeliver,
|
||||
icon: const Icon(Icons.inventory_2_outlined, size: 15),
|
||||
label: const Text('Teslim Aldım', style: TextStyle(fontSize: 13)),
|
||||
icon: const Icon(Icons.inventory_2_outlined,
|
||||
size: 15),
|
||||
label: const Text('Teslim Aldım',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push('/clinic/jobs/${job.id}'),
|
||||
icon: const Icon(Icons.open_in_new_rounded, size: 14),
|
||||
label: const Text('Detay', style: TextStyle(fontSize: 13)),
|
||||
onPressed: () =>
|
||||
context.push('/clinic/jobs/${job.id}'),
|
||||
icon: const Icon(Icons.open_in_new_rounded,
|
||||
size: 14),
|
||||
label: const Text('Detay',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
foregroundColor: AppColors.accent,
|
||||
side: BorderSide(color: AppColors.accent.withValues(alpha: 0.6)),
|
||||
side: BorderSide(
|
||||
color: AppColors.accent
|
||||
.withValues(alpha: 0.6)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
@@ -526,20 +665,34 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
|
||||
const Icon(Icons.bar_chart_rounded,
|
||||
size: 18, color: AppColors.accent),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text('Aylık Rapor',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _MonthStat(label: 'Bu Ay', value: data.thisMonthDelivered, highlighted: true)),
|
||||
Expanded(
|
||||
child: _MonthStat(
|
||||
label: 'Bu Ay',
|
||||
value: data.thisMonthDelivered,
|
||||
highlighted: true)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _MonthStat(label: 'Geçen Ay', value: data.lastMonthDelivered, highlighted: false)),
|
||||
Expanded(
|
||||
child: _MonthStat(
|
||||
label: 'Geçen Ay',
|
||||
value: data.lastMonthDelivered,
|
||||
highlighted: false)),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -548,7 +701,9 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
|
||||
isUp
|
||||
? Icons.trending_up_rounded
|
||||
: Icons.trending_down_rounded,
|
||||
size: 16,
|
||||
color: isUp ? AppColors.success : AppColors.cancelled,
|
||||
),
|
||||
@@ -573,7 +728,8 @@ class _MonthlyReportSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _MonthStat extends StatelessWidget {
|
||||
const _MonthStat({required this.label, required this.value, required this.highlighted});
|
||||
const _MonthStat(
|
||||
{required this.label, required this.value, required this.highlighted});
|
||||
final String label;
|
||||
final int value;
|
||||
final bool highlighted;
|
||||
@@ -583,14 +739,22 @@ class _MonthStat extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
|
||||
color: highlighted
|
||||
? AppColors.accent.withValues(alpha: 0.06)
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
|
||||
border: highlighted
|
||||
? Border.all(color: AppColors.accent.withValues(alpha: 0.2))
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$value iş',
|
||||
@@ -617,7 +781,8 @@ class _GamificationRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
|
||||
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
|
||||
final remaining =
|
||||
(_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -632,7 +797,11 @@ class _GamificationRow extends StatelessWidget {
|
||||
children: [
|
||||
const Text('🏆', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text('Aylık Hedef',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
@@ -642,7 +811,10 @@ class _GamificationRow extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
'${data.points} puan',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -665,14 +837,17 @@ class _GamificationRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
Text(
|
||||
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
|
||||
color: progress >= 1.0
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -693,7 +868,8 @@ class _DashboardHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
final isDesktop =
|
||||
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
|
||||
|
||||
if (isDesktop) {
|
||||
return SliverAppBar(
|
||||
@@ -712,15 +888,24 @@ class _DashboardHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
|
||||
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
Text('Genel Bakış',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
letterSpacing: 0.3)),
|
||||
const Text('Bugünkü Durum',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeClinicSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
|
||||
icon: const Icon(Icons.settings_outlined,
|
||||
color: AppColors.textSecondary, size: 22),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -751,14 +936,26 @@ class _DashboardHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
|
||||
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text('DLS',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.5)),
|
||||
Text(companyName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go(routeClinicSettings),
|
||||
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
|
||||
icon: const Icon(Icons.settings_outlined,
|
||||
color: Colors.white, size: 22),
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
@@ -777,9 +974,19 @@ class _DashboardHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
|
||||
Text('Genel Bakış',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
|
||||
const Text('Bugünkü Durum',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -805,19 +1012,48 @@ class _StatsRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||||
final isWideDesktop =
|
||||
MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||||
|
||||
final c1 = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
|
||||
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final c2 = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final c3 = _StatCard(label: 'Toplam Hasta', value: '$patients', icon: Icons.people_outline_rounded, color: AppColors.success, bgColor: AppColors.successBg)
|
||||
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final c1 = _StatCard(
|
||||
label: 'Bekleyen',
|
||||
value: '$pending',
|
||||
icon: Icons.hourglass_top_rounded,
|
||||
color: AppColors.pending,
|
||||
bgColor: AppColors.pendingBg)
|
||||
.animate()
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
final c2 = _StatCard(
|
||||
label: 'Devam Eden',
|
||||
value: '$inProgress',
|
||||
icon: Icons.autorenew_rounded,
|
||||
color: AppColors.inProgress,
|
||||
bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 80.ms)
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
final c3 = _StatCard(
|
||||
label: 'Toplam Hasta',
|
||||
value: '$patients',
|
||||
icon: Icons.people_outline_rounded,
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successBg)
|
||||
.animate(delay: 160.ms)
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
|
||||
// Wide desktop (≥ 1100px): 4 cards side by side — full lifecycle view.
|
||||
if (isWideDesktop) {
|
||||
final c4 = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
final c4 = _StatCard(
|
||||
label: 'Klinik\'te',
|
||||
value: '$sent',
|
||||
icon: Icons.local_hospital_outlined,
|
||||
color: AppColors.accent,
|
||||
bgColor: AppColors.inProgressBg)
|
||||
.animate(delay: 120.ms)
|
||||
.fadeIn(duration: 350.ms)
|
||||
.slideY(begin: 0.2, end: 0);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: c1),
|
||||
@@ -883,8 +1119,8 @@ class _StatCard extends StatelessWidget {
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration:
|
||||
BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -933,85 +1169,85 @@ class _JobCard extends StatelessWidget {
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/clinic/jobs/${job.id}'),
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border)),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.work_outline_rounded,
|
||||
color: statusColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(job.labName ?? 'Laboratuvar',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
_Tag(
|
||||
label: job.prostheticType.label,
|
||||
color: AppColors.inProgress,
|
||||
bg: AppColors.inProgressBg),
|
||||
_Tag(
|
||||
label: job.status.label,
|
||||
color: statusColor,
|
||||
bg: statusBg),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/clinic/jobs/${job.id}'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border)),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.work_outline_rounded,
|
||||
color: statusColor, size: 22),
|
||||
),
|
||||
),
|
||||
if (dueText != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 13,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textMuted),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
dueText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(job.patientCode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(job.labName ?? 'Laboratuvar',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
_Tag(
|
||||
label: job.prostheticType.label,
|
||||
color: AppColors.inProgress,
|
||||
bg: AppColors.inProgressBg),
|
||||
_Tag(
|
||||
label: job.status.label,
|
||||
color: statusColor,
|
||||
bg: statusBg),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dueText != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 13,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
: AppColors.textMuted),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
dueText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isOverdue
|
||||
? AppColors.cancelled
|
||||
: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1096,8 +1332,7 @@ class _EmptyJobs extends StatelessWidget {
|
||||
const Text(
|
||||
'Yeni iş oluşturduğunuzda\nburada görünecek',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1222,8 +1457,8 @@ class _ShimmerBoxState extends State<_ShimmerBox>
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
color: Color.lerp(const Color(0xFFE2E8F0),
|
||||
const Color(0xFFF1F5F9), _anim.value)),
|
||||
color: Color.lerp(
|
||||
const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,18 @@ class ClinicFinanceRepository {
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"'];
|
||||
if (status != null) filterParts.add('status = "$status"');
|
||||
if (status == FinanceStatus.pending.value) {
|
||||
filterParts.add('(status = "pending" || status = "reported")');
|
||||
} else if (status != null) {
|
||||
filterParts.add('status = "$status"');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: 'job_id',
|
||||
);
|
||||
return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
@@ -32,7 +36,7 @@ class ClinicFinanceRepository {
|
||||
final all = await listEntries(tenantId, limit: 200);
|
||||
double pending = 0, paid = 0;
|
||||
for (final e in all) {
|
||||
if (e.status == FinanceStatus.pending) {
|
||||
if (e.status.isOpen) {
|
||||
pending += e.amount;
|
||||
} else {
|
||||
paid += e.amount;
|
||||
@@ -41,15 +45,17 @@ class ClinicFinanceRepository {
|
||||
return {'pending': pending, 'paid': paid};
|
||||
}
|
||||
|
||||
Future<List<CounterpartyFinanceSummary>> byCounterparty(String tenantId) async {
|
||||
Future<List<CounterpartyFinanceSummary>> byCounterparty(
|
||||
String tenantId) async {
|
||||
final entries = await listEntries(tenantId, limit: 300);
|
||||
final map = <String, CounterpartyFinanceSummary>{};
|
||||
|
||||
for (final entry in entries) {
|
||||
final key = entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||
final key =
|
||||
entry.counterpartyTenantId ?? entry.counterpartyName ?? 'unknown';
|
||||
final current = map[key];
|
||||
final pending = (current?.pendingAmount ?? 0) +
|
||||
(entry.status == FinanceStatus.pending ? entry.amount : 0);
|
||||
(entry.status.isOpen ? entry.amount : 0);
|
||||
final paid = (current?.paidAmount ?? 0) +
|
||||
(entry.status == FinanceStatus.paid ? entry.amount : 0);
|
||||
map[key] = CounterpartyFinanceSummary(
|
||||
@@ -67,16 +73,16 @@ class ClinicFinanceRepository {
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> markPaid(String entryId) async {
|
||||
Future<void> reportPayment(String entryId) async {
|
||||
final record = await _pb.collection('finance_entries').getOne(entryId);
|
||||
final jobId = record.data['job_id']?.toString();
|
||||
if (jobId == null || jobId.isEmpty) {
|
||||
await _pb.collection('finance_entries').update(entryId, body: {
|
||||
'status': 'paid',
|
||||
'paid_at': DateTime.now().toIso8601String(),
|
||||
'status': 'reported',
|
||||
'paid_at': null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await FinanceService.instance.markJobPaid(jobId);
|
||||
await FinanceService.instance.reportJobPayment(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||
future: _headerFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const LinearProgressIndicator(
|
||||
color: AppColors.accent);
|
||||
return const LinearProgressIndicator(color: AppColors.accent);
|
||||
}
|
||||
final data = snap.data ??
|
||||
const _ClinicFinanceHeaderData(
|
||||
@@ -117,7 +116,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.pendingReceivable,
|
||||
label: 'Açık Borç',
|
||||
amount: data.summary['pending'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.pending,
|
||||
@@ -128,7 +127,7 @@ class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
label: s.collected,
|
||||
label: 'Onaylanan Ödeme',
|
||||
amount: data.summary['paid'] ?? 0.0,
|
||||
currencyCode: currencyCode,
|
||||
color: AppColors.success,
|
||||
@@ -230,8 +229,7 @@ class _SummaryCard extends StatelessWidget {
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
color: bgColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -304,12 +302,10 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
switch (widget.sort) {
|
||||
case _FinanceSort.newestFirst:
|
||||
list.sort((a, b) {
|
||||
final da = a.dateCreated != null
|
||||
? DateTime.tryParse(a.dateCreated!)
|
||||
: null;
|
||||
final db = b.dateCreated != null
|
||||
? DateTime.tryParse(b.dateCreated!)
|
||||
: null;
|
||||
final da =
|
||||
a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null;
|
||||
final db =
|
||||
b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null;
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
@@ -323,15 +319,15 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> _markPaid(FinanceEntry entry) async {
|
||||
Future<void> _reportPayment(FinanceEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Ödeme Onayı'),
|
||||
title: const Text('Ödeme Bildir'),
|
||||
content: Text(
|
||||
'${entry.counterpartyName ?? "Bu kayıt"} için '
|
||||
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
|
||||
'ödendi olarak işaretlemek istiyor musunuz?',
|
||||
'laboratuvara ödendi olarak bildirmek istiyor musunuz?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -340,19 +336,21 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Ödendi'),
|
||||
child: const Text('Bildir'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await ClinicFinanceRepository.instance.markPaid(entry.id);
|
||||
await ClinicFinanceRepository.instance.reportPayment(entry.id);
|
||||
_load();
|
||||
widget.onPaymentMade();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ödeme kaydedildi.')),
|
||||
const SnackBar(
|
||||
content: Text('Ödeme bildirildi. Laboratuvar onayı bekleniyor.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -392,8 +390,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary)),
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
@@ -437,10 +434,17 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final isPending = entry.status == FinanceStatus.pending;
|
||||
final statusColor =
|
||||
isPending ? AppColors.pending : AppColors.success;
|
||||
final statusBg =
|
||||
isPending ? AppColors.pendingBg : AppColors.successBg;
|
||||
final isReported = entry.status == FinanceStatus.reported;
|
||||
final statusColor = isPending
|
||||
? AppColors.pending
|
||||
: isReported
|
||||
? AppColors.accent
|
||||
: AppColors.success;
|
||||
final statusBg = isPending
|
||||
? AppColors.pendingBg
|
||||
: isReported
|
||||
? AppColors.inProgressBg
|
||||
: AppColors.successBg;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
@@ -448,7 +452,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: isPending ? () => _markPaid(entry) : null,
|
||||
onTap: isPending ? () => _reportPayment(entry) : null,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -472,7 +476,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
child: Icon(
|
||||
isPending
|
||||
? Icons.hourglass_empty_rounded
|
||||
: Icons.check_circle_outline,
|
||||
: isReported
|
||||
? Icons.verified_outlined
|
||||
: Icons.check_circle_outline,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
@@ -486,8 +492,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.counterpartyName ??
|
||||
'Bilinmiyor',
|
||||
entry.counterpartyName ?? 'Bilinmiyor',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -522,6 +527,25 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
if (isReported) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Ödeme bildirildi, laboratuvar onayı bekleniyor.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
] else if (isPending) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Dokunarak ödeme bildirimi yapabilirsiniz.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,39 +20,70 @@ class ClinicJobDetailScreen extends ConsumerStatefulWidget {
|
||||
_ClinicJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobDetailScreenState
|
||||
extends ConsumerState<ClinicJobDetailScreen> {
|
||||
class _ClinicJobDetailScreenState extends ConsumerState<ClinicJobDetailScreen> {
|
||||
Job? _job;
|
||||
String? _loadError;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
late Future<List<JobHistoryEntry>> _historyFuture;
|
||||
bool _isActing = false;
|
||||
late UnsubFn _unsub;
|
||||
final List<UnsubFn> _unsubs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
_loadHistory();
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
onEvent: (_) {
|
||||
if (mounted && !_isActing) _load();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_files',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadFiles();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_status_history',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadHistory();
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
for (final unsub in _unsubs) {
|
||||
unsub();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (mounted) setState(() { _loadError = null; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_job = job;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +93,23 @@ class _ClinicJobDetailScreenState
|
||||
});
|
||||
}
|
||||
|
||||
void _loadHistory() {
|
||||
setState(() {
|
||||
_historyFuture = JobHistoryService.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _approve(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
final updated =
|
||||
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş onaylandı.')),
|
||||
);
|
||||
@@ -87,9 +129,12 @@ class _ClinicJobDetailScreenState
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İşi İptal Et'),
|
||||
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
content: const Text(
|
||||
'Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
@@ -101,15 +146,21 @@ class _ClinicJobDetailScreenState
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
final updated =
|
||||
await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
setState(() {
|
||||
_job = _job!.copyWith(status: updated.status);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +208,11 @@ class _ClinicJobDetailScreenState
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
|
||||
);
|
||||
@@ -212,10 +267,16 @@ class _ClinicJobDetailScreenState
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
|
||||
final note =
|
||||
noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance
|
||||
.markDelivered(job.id, job, note: note);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
|
||||
);
|
||||
@@ -241,7 +302,8 @@ class _ClinicJobDetailScreenState
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_job == null && _loadError == null) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
return Center(
|
||||
@@ -270,22 +332,28 @@ class _ClinicJobDetailScreenState
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
if (_job == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final canDeliver = membership?.canDeliverJobs ?? true;
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
return _JobDetailBody(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
historyFuture: _historyFuture,
|
||||
isActing: _isActing,
|
||||
canDeliver: canDeliver,
|
||||
canManage: canManage,
|
||||
onApprove: canManage ? () => _approve(job) : () {},
|
||||
onRevision: canManage ? () => _requestRevision(job) : () {},
|
||||
onDelivered: () => _markDelivered(job),
|
||||
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
|
||||
onCancel: (canCancel && job.status == JobStatus.pending)
|
||||
? () => _cancelJob(job)
|
||||
: null,
|
||||
onFilesRefresh: _loadFiles,
|
||||
);
|
||||
}
|
||||
@@ -295,6 +363,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const _JobDetailBody({
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.historyFuture,
|
||||
required this.isActing,
|
||||
required this.canDeliver,
|
||||
required this.canManage,
|
||||
@@ -307,6 +376,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
final bool isActing;
|
||||
final bool canDeliver;
|
||||
final bool canManage;
|
||||
@@ -355,7 +425,9 @@ class _JobDetailBody extends StatelessWidget {
|
||||
job.patientName?.isNotEmpty == true
|
||||
? job.patientName!
|
||||
: job.patientCode,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
@@ -369,7 +441,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Patient + Lab
|
||||
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
const _SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
if (job.patientName != null && job.patientName!.isNotEmpty)
|
||||
_InfoRow(label: 'Hasta', value: job.patientName!),
|
||||
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
||||
@@ -380,12 +452,13 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic
|
||||
_SectionLabel(title: 'Protez Bilgisi'),
|
||||
const _SectionLabel(title: 'Protez Bilgisi'),
|
||||
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
||||
if (job.prostheticName != null && job.prostheticName!.isNotEmpty)
|
||||
_InfoRow(label: 'Ürün', value: job.prostheticName!),
|
||||
if (job.workflowType != null)
|
||||
_InfoRow(label: 'İş Tipi', value: job.workflowType!.label),
|
||||
_InfoRow(label: 'Akış', value: job.workflowPreset.title),
|
||||
_InfoRow(
|
||||
label: 'Prova',
|
||||
value: job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
@@ -398,7 +471,9 @@ class _JobDetailBody extends StatelessWidget {
|
||||
if (job.description != null && job.description!.isNotEmpty)
|
||||
_InfoRow(label: 'Açıklama', value: job.description!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
|
||||
_InfoRow(
|
||||
label: 'Son Tarih',
|
||||
value: _formatDate(job.dueDate!, withTime: true)),
|
||||
if (job.price != null)
|
||||
_InfoRow(
|
||||
label: 'Fiyat',
|
||||
@@ -438,7 +513,8 @@ class _JobDetailBody extends StatelessWidget {
|
||||
_StepperWidget(
|
||||
steps: steps,
|
||||
currentStepIndex: currentStepIndex,
|
||||
historyFuture: JobHistoryService.instance.listForJob(job.id),
|
||||
isDelivered: job.status == JobStatus.delivered,
|
||||
historyFuture: historyFuture,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -516,7 +592,8 @@ class _JobDetailBody extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d, {bool withTime = false}) {
|
||||
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
final s =
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
|
||||
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -526,11 +603,13 @@ class _StepperWidget extends StatelessWidget {
|
||||
const _StepperWidget({
|
||||
required this.steps,
|
||||
required this.currentStepIndex,
|
||||
required this.isDelivered,
|
||||
required this.historyFuture,
|
||||
});
|
||||
|
||||
final List<JobStep> steps;
|
||||
final int currentStepIndex;
|
||||
final bool isDelivered;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
@@ -542,7 +621,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
if (e.action == JobHistoryAction.revisionRequested &&
|
||||
e.step != null) {
|
||||
revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1;
|
||||
}
|
||||
if (e.step != null && e.note != null && e.note!.trim().isNotEmpty) {
|
||||
@@ -554,8 +634,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
children: steps.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final step = entry.value;
|
||||
final isCompleted = index < currentStepIndex;
|
||||
final isCurrent = index == currentStepIndex;
|
||||
final isCompleted = isDelivered || index < currentStepIndex;
|
||||
final isCurrent = !isDelivered && index == currentStepIndex;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
|
||||
|
||||
@@ -582,7 +662,7 @@ class _StepperWidget extends StatelessWidget {
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: index < currentStepIndex
|
||||
color: isDelivered || index < currentStepIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
@@ -642,7 +722,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
),
|
||||
if (stepNotes.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...stepNotes.map((entry) => _StepNoteCard(entry: entry)),
|
||||
...stepNotes
|
||||
.map((entry) => _StepNoteCard(entry: entry)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -700,6 +781,7 @@ class _StepNoteCard extends StatelessWidget {
|
||||
String _label(JobHistoryAction action) {
|
||||
return switch (action) {
|
||||
JobHistoryAction.revisionRequested => 'Revizyon Notu',
|
||||
JobHistoryAction.stepCompleted => 'İç Adım Notu',
|
||||
JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
|
||||
JobHistoryAction.approved => 'Onay Notu',
|
||||
JobHistoryAction.delivered => 'Teslim Notu',
|
||||
@@ -745,8 +827,8 @@ class _InfoRow extends StatelessWidget {
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@@ -35,17 +35,18 @@ class ClinicJobsRepository {
|
||||
}
|
||||
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
page: page,
|
||||
perPage: limit,
|
||||
filter: filterParts.join(' && '),
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<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());
|
||||
}
|
||||
|
||||
@@ -66,13 +67,15 @@ class ClinicJobsRepository {
|
||||
String? currency,
|
||||
JobWorkflowType? workflowType,
|
||||
bool provaRequired = true,
|
||||
List<String> workflowSteps = const [],
|
||||
}) async {
|
||||
final record = await _pb.collection('jobs').create(body: {
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'patient_code': patientCode,
|
||||
if (patientId != null) 'patient_id': patientId,
|
||||
if (prostheticId != null && prostheticId.isNotEmpty) 'prosthetic_id': prostheticId,
|
||||
if (prostheticId != null && prostheticId.isNotEmpty)
|
||||
'prosthetic_id': prostheticId,
|
||||
'prosthetic_type': prostheticType.value,
|
||||
'member_count': teeth.length,
|
||||
'teeth': teeth,
|
||||
@@ -82,6 +85,7 @@ class ClinicJobsRepository {
|
||||
if (price != null) 'price': price,
|
||||
if (currency != null && currency.isNotEmpty) 'currency': currency,
|
||||
if (workflowType != null) 'workflow_type': workflowType.value,
|
||||
if (workflowSteps.isNotEmpty) 'workflow_steps': workflowSteps,
|
||||
'status': 'pending',
|
||||
'location': 'at_clinic',
|
||||
'prova_required': provaRequired,
|
||||
@@ -126,7 +130,8 @@ class ClinicJobsRepository {
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<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: {
|
||||
'location': 'at_lab',
|
||||
});
|
||||
@@ -170,33 +175,42 @@ class ClinicJobsRepository {
|
||||
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(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
|
||||
expand: 'lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"',
|
||||
expand: 'lab_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return result.items.map((r) {
|
||||
final expand = r.toJson()['expand'] as Map<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();
|
||||
}
|
||||
|
||||
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(
|
||||
filter: 'patient_id = "$patientId"',
|
||||
perPage: limit,
|
||||
expand: _listExpand,
|
||||
);
|
||||
filter: 'patient_id = "$patientId"',
|
||||
perPage: limit,
|
||||
expand: _listExpand,
|
||||
);
|
||||
return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
|
||||
}
|
||||
|
||||
Future<int> countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async {
|
||||
final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"'];
|
||||
Future<int> countDelivered(String clinicTenantId,
|
||||
{DateTime? from, DateTime? to}) async {
|
||||
final parts = [
|
||||
'clinic_tenant_id = "$clinicTenantId"',
|
||||
'status = "delivered"'
|
||||
];
|
||||
if (from != null) parts.add('updated >= "${_date(from)}"');
|
||||
if (to != null) parts.add('updated < "${_date(to)}"');
|
||||
final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && '));
|
||||
final r = await _pb
|
||||
.collection('jobs')
|
||||
.getList(perPage: 1, filter: parts.join(' && '));
|
||||
return r.totalItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../../../models/patient.dart';
|
||||
import '../../../models/prosthetic_product.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../lab/discounts/discount_repository.dart';
|
||||
import '../../lab/products/lab_products_repository.dart';
|
||||
import 'clinic_jobs_repository.dart';
|
||||
@@ -111,8 +112,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_labsError = null;
|
||||
});
|
||||
try {
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final labs =
|
||||
await ClinicJobsRepository.instance.listApprovedLabs(tenantId);
|
||||
setState(() {
|
||||
@@ -149,9 +149,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
labId,
|
||||
isActive: true,
|
||||
);
|
||||
final matchingProducts = products
|
||||
.where((p) => p.prostheticType == ptValue)
|
||||
.toList();
|
||||
final matchingProducts =
|
||||
products.where((p) => p.prostheticType == ptValue).toList();
|
||||
|
||||
ProstheticProduct? product;
|
||||
if (_selectedProduct != null) {
|
||||
@@ -230,8 +229,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_availableProducts.isEmpty;
|
||||
|
||||
bool get _hasSelectedProductWithoutPrice =>
|
||||
_selectedProduct != null &&
|
||||
_selectedProduct!.unitPrice == null;
|
||||
_selectedProduct != null && _selectedProduct!.unitPrice == null;
|
||||
|
||||
bool get _canSubmitJob =>
|
||||
!_isSubmitting &&
|
||||
@@ -251,8 +249,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
}
|
||||
setState(() => _patientSearchLoading = true);
|
||||
try {
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final results = await ClinicPatientsRepository.instance
|
||||
.listPatients(tenantId, search: normalizedQuery, limit: 10);
|
||||
if (!mounted || _patientSearchController.text.trim() != normalizedQuery) {
|
||||
@@ -315,8 +312,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dueDate = DateTime(
|
||||
pickedDate.year, pickedDate.month, pickedDate.day,
|
||||
pickedTime?.hour ?? 17, pickedTime?.minute ?? 0,
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
pickedTime?.hour ?? 17,
|
||||
pickedTime?.minute ?? 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -326,7 +326,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
final date =
|
||||
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
final rand = List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join();
|
||||
final rand =
|
||||
List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join();
|
||||
return 'PR-$date-$rand';
|
||||
}
|
||||
|
||||
@@ -397,6 +398,13 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
lastName: rawLastName.isNotEmpty ? rawLastName : null,
|
||||
);
|
||||
}
|
||||
final selectedLabTenant = Tenant.fromJson(_selectedLab!);
|
||||
final workflowSteps = buildJobWorkflowPreset(
|
||||
prostheticType: _selectedProstheticType!,
|
||||
workflowType: _selectedWorkflowType,
|
||||
provaRequired: _provaRequired,
|
||||
optionalSteps: selectedLabTenant.workflowOverrideSteps,
|
||||
).steps;
|
||||
final job = await ClinicJobsRepository.instance.createJob(
|
||||
clinicTenantId: tenantId,
|
||||
labTenantId: _selectedLab!['id'] as String,
|
||||
@@ -418,24 +426,29 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
currency: _labProduct?.currency,
|
||||
workflowType: _selectedWorkflowType,
|
||||
provaRequired: _provaRequired,
|
||||
workflowSteps: workflowSteps.map((step) => step.value).toList(),
|
||||
);
|
||||
|
||||
// Upload pending files
|
||||
if (_pendingFiles.isNotEmpty) {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final token = pb.authStore.token;
|
||||
final uploaderId = (pb.authStore.record?.id) ?? (auth.profile?.id ?? '');
|
||||
final uploaderId =
|
||||
(pb.authStore.record?.id) ?? (auth.profile?.id ?? '');
|
||||
for (final file in _pendingFiles) {
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) continue;
|
||||
final ext = (file.extension ?? '').toLowerCase();
|
||||
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
|
||||
? 'scan'
|
||||
: (ext == 'pdf') ? 'document' : 'image';
|
||||
: (ext == 'pdf')
|
||||
? 'document'
|
||||
: 'image';
|
||||
final mimeType = _mimeFromExt(ext);
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'),
|
||||
Uri.parse(
|
||||
'https://pocket.kovaksoft.com/api/collections/job_files/records'),
|
||||
)
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..fields['job_id'] = job.id
|
||||
@@ -483,7 +496,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Lab selection
|
||||
_SectionLabel(label: 'Laboratuvar *'),
|
||||
const _SectionLabel(label: 'Laboratuvar *'),
|
||||
if (_labsLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_labsError != null)
|
||||
@@ -523,7 +536,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'Hasta / Protokol'),
|
||||
const _SectionLabel(label: 'Hasta / Protokol'),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<_PatientEntryMode>(
|
||||
segments: const [
|
||||
@@ -566,7 +579,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
dense: true,
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Hasta bulunamadı'),
|
||||
subtitle: Text('İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
|
||||
subtitle: Text(
|
||||
'İsterseniz "Yeni Hasta" modundan manuel ekleyebilirsiniz.'),
|
||||
),
|
||||
..._patientResults.map(
|
||||
(p) => ListTile(
|
||||
@@ -668,7 +682,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Prosthetic type
|
||||
_SectionLabel(label: 'Protez Türü *'),
|
||||
const _SectionLabel(label: 'Protez Türü *'),
|
||||
DropdownButtonFormField<ProstheticType>(
|
||||
initialValue: _selectedProstheticType,
|
||||
decoration: const InputDecoration(
|
||||
@@ -689,12 +703,11 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
});
|
||||
_refreshProductsAndPrice();
|
||||
},
|
||||
validator: (val) =>
|
||||
val == null ? 'Protez türü zorunludur' : null,
|
||||
validator: (val) => val == null ? 'Protez türü zorunludur' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'Ürün'),
|
||||
const _SectionLabel(label: 'Ürün'),
|
||||
DropdownButtonFormField<ProstheticProduct>(
|
||||
initialValue: _selectedProduct,
|
||||
decoration: InputDecoration(
|
||||
@@ -716,7 +729,8 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (_selectedProstheticType == null || _availableProducts.isEmpty)
|
||||
onChanged: (_selectedProstheticType == null ||
|
||||
_availableProducts.isEmpty)
|
||||
? null
|
||||
: (val) {
|
||||
setState(() => _selectedProduct = val);
|
||||
@@ -733,14 +747,15 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 8),
|
||||
_InlineInfoBanner(
|
||||
message: _productAvailabilityMessage!,
|
||||
tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice
|
||||
? _InfoBannerTone.warning
|
||||
: _InfoBannerTone.info,
|
||||
tone:
|
||||
_hasMissingProductForType || _hasSelectedProductWithoutPrice
|
||||
? _InfoBannerTone.warning
|
||||
: _InfoBannerTone.info,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'İş Tipi'),
|
||||
const _SectionLabel(label: 'İş Tipi'),
|
||||
DropdownButtonFormField<JobWorkflowType>(
|
||||
initialValue: _selectedWorkflowType,
|
||||
decoration: const InputDecoration(
|
||||
@@ -754,19 +769,22 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) =>
|
||||
setState(() => _selectedWorkflowType = val),
|
||||
validator: (val) =>
|
||||
val == null ? 'Lütfen iş tipi seçin' : null,
|
||||
onChanged: (val) => setState(() => _selectedWorkflowType = val),
|
||||
validator: (val) => val == null ? 'Lütfen iş tipi seçin' : null,
|
||||
),
|
||||
// Price preview
|
||||
if (_priceLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Row(children: [
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5)),
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5)),
|
||||
SizedBox(width: 8),
|
||||
Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
Text('Fiyat yükleniyor...',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
]),
|
||||
)
|
||||
else if (_labProduct != null && _effectivePrice != null) ...[
|
||||
@@ -784,6 +802,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
_ProvaToggle(
|
||||
value: _provaRequired,
|
||||
prostheticType: _selectedProstheticType,
|
||||
workflowType: _selectedWorkflowType,
|
||||
optionalSteps: _selectedLab != null
|
||||
? Tenant.fromJson(_selectedLab!).workflowOverrideSteps
|
||||
: const [],
|
||||
onChanged: (v) => setState(() => _provaRequired = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -809,7 +831,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
},
|
||||
onSelectUpper: () {
|
||||
setState(() {
|
||||
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]};
|
||||
final upper = {
|
||||
...[for (int i = 11; i <= 18; i++) i],
|
||||
...[for (int i = 21; i <= 28; i++) i]
|
||||
};
|
||||
if (upper.every(_selectedTeeth.contains)) {
|
||||
_selectedTeeth.removeAll(upper);
|
||||
} else {
|
||||
@@ -820,7 +845,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
},
|
||||
onSelectLower: () {
|
||||
setState(() {
|
||||
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]};
|
||||
final lower = {
|
||||
...[for (int i = 31; i <= 38; i++) i],
|
||||
...[for (int i = 41; i <= 48; i++) i]
|
||||
};
|
||||
if (lower.every(_selectedTeeth.contains)) {
|
||||
_selectedTeeth.removeAll(lower);
|
||||
} else {
|
||||
@@ -851,7 +879,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Color (optional)
|
||||
_SectionLabel(label: 'Renk (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Renk (İsteğe Bağlı)'),
|
||||
TextFormField(
|
||||
controller: _colorController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -861,7 +889,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description (optional)
|
||||
_SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -873,7 +901,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Due date (optional)
|
||||
_SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
|
||||
InkWell(
|
||||
onTap: _pickDueDate,
|
||||
child: InputDecorator(
|
||||
@@ -895,7 +923,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// File attachments (optional)
|
||||
_SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
|
||||
const _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
|
||||
_FilePicker(
|
||||
files: _pendingFiles,
|
||||
onAdd: () async {
|
||||
@@ -958,7 +986,9 @@ class _InlineInfoBanner extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded,
|
||||
isWarning
|
||||
? Icons.warning_amber_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 18,
|
||||
color: isWarning ? AppColors.pending : AppColors.textSecondary,
|
||||
),
|
||||
@@ -995,12 +1025,18 @@ class _TeethBulkBar extends StatelessWidget {
|
||||
final VoidCallback onClear;
|
||||
|
||||
bool _allUpperSelected() {
|
||||
final upper = [for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i];
|
||||
final upper = [
|
||||
for (int i = 11; i <= 18; i++) i,
|
||||
for (int i = 21; i <= 28; i++) i
|
||||
];
|
||||
return upper.every(selectedTeeth.contains);
|
||||
}
|
||||
|
||||
bool _allLowerSelected() {
|
||||
final lower = [for (int i = 31; i <= 38; i++) i, for (int i = 41; i <= 48; i++) i];
|
||||
final lower = [
|
||||
for (int i = 31; i <= 38; i++) i,
|
||||
for (int i = 41; i <= 48; i++) i
|
||||
];
|
||||
return lower.every(selectedTeeth.contains);
|
||||
}
|
||||
|
||||
@@ -1094,9 +1130,7 @@ class _BulkChip extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color),
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1247,7 +1281,8 @@ class _FilePicker extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary),
|
||||
const Icon(Icons.attach_file,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -1265,7 +1300,8 @@ class _FilePicker extends StatelessWidget {
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => onRemove(i),
|
||||
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1334,21 +1370,30 @@ class _PricePreviewChip extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.success),
|
||||
),
|
||||
Text(
|
||||
'${unitPrice.toStringAsFixed(2)} $currency x $units $unitLabel',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.success.withValues(alpha: 0.75)),
|
||||
),
|
||||
if (hasDiscount)
|
||||
Text(
|
||||
'Liste: ${baseAmount.toStringAsFixed(2)} $currency · İndirim uygulandı',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.success.withValues(alpha: 0.75)),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Liste fiyatı · İndirim yok',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.success.withValues(alpha: 0.75)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1381,18 +1426,28 @@ class _ProvaToggle extends StatelessWidget {
|
||||
const _ProvaToggle({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.optionalSteps,
|
||||
this.prostheticType,
|
||||
this.workflowType,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final ProstheticType? prostheticType;
|
||||
final JobWorkflowType? workflowType;
|
||||
final List<JobStep> optionalSteps;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = prostheticType != null
|
||||
? jobStepTemplate(prostheticType!, value)
|
||||
: <JobStep>[];
|
||||
final preset = prostheticType != null
|
||||
? buildJobWorkflowPreset(
|
||||
prostheticType: prostheticType!,
|
||||
workflowType: workflowType,
|
||||
provaRequired: value,
|
||||
optionalSteps: optionalSteps,
|
||||
)
|
||||
: null;
|
||||
final steps = preset?.steps ?? <JobStep>[];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
@@ -1400,7 +1455,9 @@ class _ProvaToggle extends StatelessWidget {
|
||||
color: value ? AppColors.inProgressBg : AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border,
|
||||
color: value
|
||||
? AppColors.inProgress.withValues(alpha: 0.3)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -1422,14 +1479,17 @@ class _ProvaToggle extends StatelessWidget {
|
||||
value ? 'Provalı İş' : 'Provasız İş',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: value ? AppColors.inProgress : AppColors.textPrimary,
|
||||
color: value
|
||||
? AppColors.inProgress
|
||||
: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value
|
||||
? 'Lab her adımda klinik onayı bekler'
|
||||
: 'Lab doğrudan üretip teslime gönderir',
|
||||
preset?.title ??
|
||||
(value
|
||||
? 'Lab her adımda klinik onayı bekler'
|
||||
: 'Lab doğrudan üretip teslime gönderir'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textSecondary),
|
||||
),
|
||||
@@ -1439,27 +1499,41 @@ class _ProvaToggle extends StatelessWidget {
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: AppColors.inProgress,
|
||||
activeThumbColor: AppColors.inProgress,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (preset != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
preset.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: steps.map((s) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
s.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
)).toList(),
|
||||
children: steps
|
||||
.map((s) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
s.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppColors.textSecondary),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -9,7 +9,10 @@ import '../../../core/providers/locale_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import '../../shared/location_completion_banner.dart';
|
||||
import '../../shared/tenant_team_screen.dart';
|
||||
import '../../shared/location_picker_sheet.dart';
|
||||
import '../../shared/tenant_location_data.dart';
|
||||
import '../connections/clinic_connections_screen.dart';
|
||||
|
||||
class ClinicSettingsScreen extends ConsumerWidget {
|
||||
@@ -29,6 +32,17 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (tenant?.hasLocation != true) ...[
|
||||
LocationCompletionBanner(
|
||||
title: 'Konum eksik',
|
||||
description:
|
||||
'Kliniğiniz harita tabanlı aramalarda doğru eşleşme için koordinat bilgisine ihtiyaç duyuyor.',
|
||||
buttonLabel: 'Konumu Düzenle',
|
||||
onTap: () => _showEditSheet(context, ref, tenant, s),
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// User card
|
||||
_SectionHeader(title: s.userInfo),
|
||||
_UserCard(profile: profile),
|
||||
@@ -62,6 +76,13 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
label: s.role,
|
||||
value: _roleLabel(membership?.role, s),
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.place_outlined,
|
||||
label: 'Konum',
|
||||
value: tenant?.locationLabel.isNotEmpty == true
|
||||
? tenant!.locationLabel
|
||||
: '-',
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@@ -100,7 +121,9 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||||
SnackBar(
|
||||
content:
|
||||
Text(s.tenantSelected(m.tenant.companyName))),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -120,8 +143,7 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
subtitle: s.teamSub,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const TenantTeamScreen()),
|
||||
MaterialPageRoute(builder: (_) => const TenantTeamScreen()),
|
||||
),
|
||||
),
|
||||
_NavTile(
|
||||
@@ -140,6 +162,14 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
subtitle: s.aiAssistantSub,
|
||||
onTap: () => context.push(routeClinicAi),
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
iconColor: AppColors.primary,
|
||||
iconBg: const Color(0xFFEFF6FF),
|
||||
title: 'Paketler ve AI Kredileri',
|
||||
subtitle: 'Trial ve paket görünümünü incele',
|
||||
onTap: () => context.push(routeWelcome),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
@@ -152,7 +182,8 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
iconColor: AppColors.accent,
|
||||
iconBg: AppColors.inProgressBg,
|
||||
title: s.appLanguage,
|
||||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||||
subtitle: _currentLanguageLabel(
|
||||
ref.watch(localeProvider).languageCode, s),
|
||||
onTap: () => _showLanguagePicker(context, ref, s),
|
||||
),
|
||||
]),
|
||||
@@ -176,7 +207,8 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
void _showEditSheet(
|
||||
BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||||
if (tenant == null) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -185,10 +217,12 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
builder: (_) => _EditTenantSheet(
|
||||
tenant: tenant,
|
||||
s: s,
|
||||
onSave: (name) async {
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.updateTenantInfo(tenantId: tenant.id, companyName: name);
|
||||
onSave: (name, location) async {
|
||||
await ref.read(authProvider.notifier).updateTenantInfo(
|
||||
tenantId: tenant.id,
|
||||
companyName: name,
|
||||
location: location,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -202,7 +236,8 @@ class ClinicSettingsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||||
static String _currentLanguageLabel(String code, AppStrings s) =>
|
||||
switch (code) {
|
||||
'en' => s.languageEnglish,
|
||||
'ru' => s.languageRussian,
|
||||
'ar' => s.languageArabic,
|
||||
@@ -316,7 +351,10 @@ class _EditTenantSheet extends StatefulWidget {
|
||||
});
|
||||
final Tenant tenant;
|
||||
final AppStrings s;
|
||||
final Future<void> Function(String companyName) onSave;
|
||||
final Future<void> Function(
|
||||
String companyName,
|
||||
TenantLocationData location,
|
||||
) onSave;
|
||||
|
||||
@override
|
||||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||||
@@ -324,32 +362,49 @@ class _EditTenantSheet extends StatefulWidget {
|
||||
|
||||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _addressController;
|
||||
late final TextEditingController _cityController;
|
||||
late final TextEditingController _districtController;
|
||||
late TenantLocationData _location;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||||
_location = TenantLocationData.fromTenant(widget.tenant);
|
||||
_addressController = TextEditingController(text: _location.address ?? '');
|
||||
_cityController = TextEditingController(text: _location.city ?? '');
|
||||
_districtController = TextEditingController(text: _location.district ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_addressController.dispose();
|
||||
_cityController.dispose();
|
||||
_districtController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
final location = _location.copyWith(
|
||||
address: _addressController.text.trim(),
|
||||
city: _cityController.text.trim(),
|
||||
district: _districtController.text.trim(),
|
||||
);
|
||||
if (!location.hasDetails) return;
|
||||
setState(() => _saving = true);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.onSave(name);
|
||||
await widget.onSave(name, location);
|
||||
navigator.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
messenger
|
||||
.showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
@@ -395,13 +450,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Konum',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_location.fullLabel.isNotEmpty
|
||||
? _location.fullLabel
|
||||
: 'Henüz konum veya adres bilgisi girilmedi.',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final picked = await showLocationPickerSheet(
|
||||
context,
|
||||
initialLocation: _location,
|
||||
title: 'Klinik Konumu',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _location = picked);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('Haritadan Konum Seç'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _addressController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Açık Adres',
|
||||
hintText: 'Cadde, sokak, mahalle bilgisi',
|
||||
),
|
||||
maxLines: 2,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _cityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Şehir',
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _districtController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'İlçe',
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_saving)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
onPressed: _saving ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48)),
|
||||
child: Text(s.save),
|
||||
@@ -534,7 +667,10 @@ class _InfoCard extends StatelessWidget {
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: AppColors.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -599,8 +735,7 @@ class _NavTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -615,8 +750,7 @@ class _NavTile extends StatelessWidget {
|
||||
? Text(subtitle!,
|
||||
style: const TextStyle(color: AppColors.textSecondary))
|
||||
: null,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
@@ -642,16 +776,14 @@ class _SignOutCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(9)),
|
||||
child: const Icon(Icons.logout,
|
||||
color: AppColors.cancelled, size: 18),
|
||||
child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18),
|
||||
),
|
||||
title: Text(s.signOut,
|
||||
style: const TextStyle(
|
||||
|
||||
Reference in New Issue
Block a user