diff --git a/lib/core/services/pricing_service.dart b/lib/core/services/pricing_service.dart index ec85851..168911c 100644 --- a/lib/core/services/pricing_service.dart +++ b/lib/core/services/pricing_service.dart @@ -27,14 +27,47 @@ class PricingService { int billableUnitsForType(ProstheticType type, int memberCount) { final safeCount = memberCount <= 0 ? 1 : memberCount; return switch (type) { - ProstheticType.tamProtez || ProstheticType.parsiyel => 1, + ProstheticType.hareketli || + ProstheticType.tamProtez || + ProstheticType.parsiyel => + 1, _ => safeCount, }; } + int billableUnitsForTeeth(ProstheticType type, Iterable teeth) { + final selected = teeth.toSet(); + if (selected.isEmpty) return billableUnitsForType(type, 0); + if (type.family == ProstheticFamily.hareketli) return 1; + + final upper = { + for (int i = 11; i <= 18; i++) i, + for (int i = 21; i <= 28; i++) i, + }; + final lower = { + for (int i = 31; i <= 38; i++) i, + for (int i = 41; i <= 48; i++) i, + }; + + var units = 0; + final remaining = Set.from(selected); + if (upper.every(selected.contains)) { + units += 1; + remaining.removeAll(upper); + } + if (lower.every(selected.contains)) { + units += 1; + remaining.removeAll(lower); + } + return units + remaining.length; + } + String unitLabelForType(ProstheticType type) { return switch (type) { - ProstheticType.tamProtez || ProstheticType.parsiyel => 'vaka', + ProstheticType.hareketli || + ProstheticType.tamProtez || + ProstheticType.parsiyel => + 'vaka', _ => 'diş', }; } @@ -45,14 +78,18 @@ class PricingService { required int memberCount, required String clinicTenantId, required List discounts, + Iterable? teeth, }) { - final billableUnits = billableUnitsForType(prostheticType, memberCount); + final billableUnits = teeth == null + ? billableUnitsForType(prostheticType, memberCount) + : billableUnitsForTeeth(prostheticType, teeth); final unitPrice = product.unitPrice ?? 0; final baseAmount = unitPrice * billableUnits; final applicable = discounts.where((discount) { if (!discount.isActive) return false; - if (!(discount.appliesToAll || discount.clinicTenantId == clinicTenantId)) { + if (!(discount.appliesToAll || + discount.clinicTenantId == clinicTenantId)) { return false; } if (!(discount.appliesToAllTypes || @@ -77,9 +114,8 @@ class PricingService { billableUnits: billableUnits, unitPrice: unitPrice, baseAmount: baseAmount, - discountAmount: (baseAmount - finalAmount) - .clamp(0, double.infinity) - .toDouble(), + discountAmount: + (baseAmount - finalAmount).clamp(0, double.infinity).toDouble(), finalAmount: finalAmount, appliedDiscounts: applicable, ); diff --git a/lib/features/clinic/jobs/clinic_jobs_repository.dart b/lib/features/clinic/jobs/clinic_jobs_repository.dart index 449602f..5b2eff1 100644 --- a/lib/features/clinic/jobs/clinic_jobs_repository.dart +++ b/lib/features/clinic/jobs/clinic_jobs_repository.dart @@ -39,6 +39,7 @@ class ClinicJobsRepository { perPage: limit, filter: filterParts.join(' && '), expand: _listExpand, + sort: '-created', ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); @@ -136,14 +137,14 @@ class ClinicJobsRepository { 'location': 'at_lab', }); final updated = Job.fromJson(record.toJson()); - unawaited(JobHistoryService.instance.append( + await JobHistoryService.instance.append( jobId: jobId, clinicTenantId: job.clinicTenantId, labTenantId: job.labTenantId, action: JobHistoryAction.revisionRequested, step: job.currentStep, note: note, - )); + ); return updated; } @@ -195,6 +196,7 @@ class ClinicJobsRepository { filter: 'patient_id = "$patientId"', perPage: limit, expand: _listExpand, + sort: '-created', ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); diff --git a/lib/features/clinic/jobs/new_job_screen.dart b/lib/features/clinic/jobs/new_job_screen.dart index beb1aca..2a79abf 100644 --- a/lib/features/clinic/jobs/new_job_screen.dart +++ b/lib/features/clinic/jobs/new_job_screen.dart @@ -149,8 +149,14 @@ class _NewJobScreenState extends ConsumerState { labId, isActive: true, ); - final matchingProducts = - products.where((p) => p.prostheticType == ptValue).toList(); + final matchingProducts = products + .where( + (p) => + p.prostheticType == ptValue || + parseProstheticTypeValue(p.prostheticType) == + _selectedProstheticType, + ) + .toList(); ProstheticProduct? product; if (_selectedProduct != null) { @@ -198,6 +204,7 @@ class _NewJobScreenState extends ConsumerState { memberCount: _selectedTeeth.length, clinicTenantId: clinicTenantId, discounts: discounts, + teeth: _selectedTeeth, ); setState(() { _availableProducts = matchingProducts; @@ -688,7 +695,7 @@ class _NewJobScreenState extends ConsumerState { decoration: const InputDecoration( hintText: 'Protez türü seçin', ), - items: ProstheticType.values + items: visibleProstheticTypes .map( (pt) => DropdownMenuItem( value: pt, @@ -753,6 +760,10 @@ class _NewJobScreenState extends ConsumerState { : _InfoBannerTone.info, ), ], + if (_selectedProduct?.description?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 8), + _ProductDescriptionCard(product: _selectedProduct!), + ], const SizedBox(height: 16), const _SectionLabel(label: 'İş Tipi'), @@ -1404,6 +1415,56 @@ class _PricePreviewChip extends StatelessWidget { } } +class _ProductDescriptionCard extends StatelessWidget { + const _ProductDescriptionCard({required this.product}); + + final ProstheticProduct product; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.notes_rounded, size: 16, color: AppColors.textMuted), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ürün açıklaması', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 3), + Text( + product.description!.trim(), + style: const TextStyle( + fontSize: 12, + height: 1.35, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.label}); final String label; diff --git a/lib/features/lab/finance/lab_finance_repository.dart b/lib/features/lab/finance/lab_finance_repository.dart index a1f89df..1edbbbd 100644 --- a/lib/features/lab/finance/lab_finance_repository.dart +++ b/lib/features/lab/finance/lab_finance_repository.dart @@ -85,4 +85,28 @@ class LabFinanceRepository { } await FinanceService.instance.confirmJobPayment(jobId); } + + Future confirmPayments(List entries) async { + final jobIds = {}; + final standaloneIds = []; + for (final entry in entries) { + if (!entry.status.isOpen) continue; + if (entry.jobId.isNotEmpty) { + jobIds.add(entry.jobId); + } else { + standaloneIds.add(entry.id); + } + } + + for (final jobId in jobIds) { + await FinanceService.instance.confirmJobPayment(jobId); + } + final paidAt = DateTime.now().toIso8601String(); + for (final id in standaloneIds) { + await _pb.collection('finance_entries').update(id, body: { + 'status': 'paid', + 'paid_at': paidAt, + }); + } + } } diff --git a/lib/features/lab/finance/lab_finance_screen.dart b/lib/features/lab/finance/lab_finance_screen.dart index 3abd534..cc3d88e 100644 --- a/lib/features/lab/finance/lab_finance_screen.dart +++ b/lib/features/lab/finance/lab_finance_screen.dart @@ -146,6 +146,68 @@ class _LabFinanceScreenState extends ConsumerState } } + Future _confirmCounterpartyPayments( + CounterpartyFinanceSummary item, + List pending, + String Function(double) formatAmount, + ) async { + final entries = pending + .where((entry) => + entry.status.isOpen && + (entry.counterpartyTenantId == item.counterpartyTenantId || + entry.counterpartyName == item.counterpartyName)) + .toList(); + if (entries.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Toplu Ödeme Onayla'), + content: Text( + '${item.counterpartyName} için ${entries.length} açık kayıt ' + '${formatAmount(item.pendingAmount)} toplam tutarla onaylansın mı?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Toplu Onayla'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + try { + await LabFinanceRepository.instance.confirmPayments(entries); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Toplu ödeme onaylandı.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + Future _sendPaymentReminder(CounterpartyFinanceSummary item) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${item.counterpartyName} için ödeme hatırlatması hazırlandı.', + ), + ), + ); + } + @override Widget build(BuildContext context) { final isSortActive = _sort != _FinanceSort.newestFirst; @@ -252,6 +314,9 @@ class _LabFinanceScreenState extends ConsumerState title: 'Klinik Bazlı Alacak', items: data.counterparties, formatAmount: formatAmount, + onConfirmAll: (item) => _confirmCounterpartyPayments( + item, pending, formatAmount), + onSendReminder: _sendPaymentReminder, ), ), PillTabs( @@ -568,11 +633,15 @@ class _CounterpartySummaryList extends StatelessWidget { required this.title, required this.items, required this.formatAmount, + required this.onConfirmAll, + required this.onSendReminder, }); final String title; final List items; final String Function(double) formatAmount; + final Future Function(CounterpartyFinanceSummary item) onConfirmAll; + final Future Function(CounterpartyFinanceSummary item) onSendReminder; @override Widget build(BuildContext context) { @@ -616,6 +685,26 @@ class _CounterpartySummaryList extends StatelessWidget { fontWeight: FontWeight.w700, ), ), + if (item.pendingAmount > 0) ...[ + const SizedBox(width: 8), + IconButton( + onPressed: () => onSendReminder(item), + tooltip: 'Ödeme hatırlat', + icon: const Icon(Icons.notifications_active_outlined), + color: AppColors.pending, + visualDensity: VisualDensity.compact, + ), + TextButton.icon( + onPressed: () => onConfirmAll(item), + icon: const Icon(Icons.done_all_rounded, size: 16), + label: const Text('Toplu Onay'), + style: TextButton.styleFrom( + foregroundColor: AppColors.success, + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + ), + ], ], ), const SizedBox(height: 8), diff --git a/lib/features/lab/jobs/lab_job_detail_screen.dart b/lib/features/lab/jobs/lab_job_detail_screen.dart index c494eeb..3783695 100644 --- a/lib/features/lab/jobs/lab_job_detail_screen.dart +++ b/lib/features/lab/jobs/lab_job_detail_screen.dart @@ -418,10 +418,32 @@ class _LabJobDetailScreenState extends ConsumerState { icon: Icons.notes, label: 'Açıklama', value: job.description!), + if (job.workflowType == JobWorkflowType.arjinat || + job.workflowType == JobWorkflowType.geleneksel) ...[ + const SizedBox(height: 10), + _WorkflowAttentionBanner(type: job.workflowType!), + ], ], ), ), + FutureBuilder>( + future: _historyFuture, + builder: (context, snapshot) { + final entries = snapshot.data ?? const []; + final revisions = entries + .where((entry) => + entry.action == JobHistoryAction.revisionRequested && + entry.note?.trim().isNotEmpty == true) + .toList(); + if (revisions.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 12), + child: _LatestRevisionBanner(entry: revisions.last), + ); + }, + ), + const SizedBox(height: 16), // Stepper @@ -549,6 +571,106 @@ class _LabJobDetailScreenState extends ConsumerState { } } +class _WorkflowAttentionBanner extends StatelessWidget { + const _WorkflowAttentionBanner({required this.type}); + + final JobWorkflowType type; + + @override + Widget build(BuildContext context) { + final isArjinat = type == JobWorkflowType.arjinat; + final color = isArjinat ? AppColors.pending : AppColors.accent; + final bg = isArjinat ? AppColors.pendingBg : AppColors.inProgressBg; + final text = isArjinat + ? 'Arjinat ölçü: ölçü/model kontrolünü daha dikkatli doğrulayın.' + : 'Geleneksel ölçü: fiziksel ölçü ve kapanış kaydı kontrolü beklenir.'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Icon( + isArjinat + ? Icons.priority_high_rounded + : Icons.assignment_turned_in_outlined, + color: color, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ], + ), + ); + } +} + +class _LatestRevisionBanner extends StatelessWidget { + const _LatestRevisionBanner({required this.entry}); + + final JobHistoryEntry entry; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.loop_rounded, color: AppColors.cancelled, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.step == null + ? 'Revizyon Notu' + : 'Revizyon Notu · ${entry.step!.label}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.cancelled, + ), + ), + const SizedBox(height: 3), + Text( + entry.note!.trim(), + style: const TextStyle( + fontSize: 13, + height: 1.35, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + // ── Hand to Clinic Sheet ───────────────────────────────────────────────────── class _HandToClinicSheet extends StatefulWidget { @@ -578,11 +700,14 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { final isLast = widget.job.isLastStep; final stepLabel = currentStep?.label ?? ''; final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true; + final continuesInLab = !widget.job.provaRequired && + currentStep == JobStep.olcuKontrol && + widget.job.nextStep == JobStep.cilaBitim; final buttonLabel = isLast ? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder') - : requiresClinicApproval + : requiresClinicApproval && !continuesInLab ? '$stepLabel için Kliniğe Gönder' : '$stepLabel tamamlandı, sonraki adıma geç'; final buttonColor = isLast ? AppColors.success : AppColors.inProgress; @@ -613,7 +738,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { Text( isLast ? 'İş teslim edilecek olarak işaretlenecek.' - : requiresClinicApproval + : requiresClinicApproval && !continuesInLab ? 'İş klinikteki prova veya onay için gönderilecek.' : 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.', style: const TextStyle(color: AppColors.textSecondary), @@ -649,7 +774,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { SnackBar( content: Text(isLast ? 'İş teslim için gönderildi' - : requiresClinicApproval + : requiresClinicApproval && !continuesInLab ? 'Onay için kliniğe gönderildi' : 'İş bir sonraki iç adıma geçirildi')), ); diff --git a/lib/features/lab/jobs/lab_jobs_repository.dart b/lib/features/lab/jobs/lab_jobs_repository.dart index 7dcd68f..a31a763 100644 --- a/lib/features/lab/jobs/lab_jobs_repository.dart +++ b/lib/features/lab/jobs/lab_jobs_repository.dart @@ -28,6 +28,7 @@ class LabJobsRepository { perPage: limit, filter: filterParts.join(' && '), expand: _listExpand, + sort: '-created', ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); @@ -44,6 +45,7 @@ class LabJobsRepository { perPage: limit, filter: filterParts.join(' && '), expand: _listExpand, + sort: location == null ? '-created' : null, ); return (result.items.map((r) => Job.fromJson(r.toJson())).toList() ..sort((a, b) { @@ -89,9 +91,12 @@ class LabJobsRepository { final isFinal = currentStep == JobStep.cilaBitim; final nextStep = job.nextStep; + final shouldStayAtLabAfterMeasure = !job.provaRequired && + currentStep == JobStep.olcuKontrol && + nextStep == JobStep.cilaBitim; final patch = isFinal ? {'status': 'sent', 'location': 'at_clinic'} - : currentStep.requiresClinicApproval + : currentStep.requiresClinicApproval && !shouldStayAtLabAfterMeasure ? {'location': 'at_clinic'} : { 'current_step': nextStep?.value, @@ -104,7 +109,9 @@ class LabJobsRepository { jobId: jobId, clinicTenantId: job.clinicTenantId, labTenantId: job.labTenantId, - action: currentStep.requiresClinicApproval || isFinal + action: (currentStep.requiresClinicApproval && + !shouldStayAtLabAfterMeasure) || + isFinal ? JobHistoryAction.handedToClinic : JobHistoryAction.stepCompleted, step: currentStep, diff --git a/lib/features/lab/products/lab_products_screen.dart b/lib/features/lab/products/lab_products_screen.dart index 9d9f953..b040b9a 100644 --- a/lib/features/lab/products/lab_products_screen.dart +++ b/lib/features/lab/products/lab_products_screen.dart @@ -7,11 +7,9 @@ import '../../../models/prosthetic_product.dart'; import 'lab_products_repository.dart'; const _prostheticTypes = [ - ('metal_porselen', 'Metal Porselen'), - ('zirkonyum', 'Zirkonyum'), - ('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'), + ('sabit', 'Sabit'), + ('hareketli', 'Hareketli'), ('gecici', 'Geçici'), - ('e_max', 'E-Max'), ('diger', 'Diğer'), ]; @@ -19,19 +17,42 @@ String _typeLabel(String value) { for (final t in _prostheticTypes) { if (t.$1 == value) return t.$2; } - return value; + return switch (value) { + 'metal_porselen' || + 'zirkonyum' || + 'implant_ustu_zirkonyum' || + 'e_max' => + 'Sabit', + 'tam_protez' || 'parsiyel' => 'Hareketli', + _ => value, + }; +} + +String _typeValueForForm(String? value) { + return switch (value) { + 'sabit' || + 'metal_porselen' || + 'zirkonyum' || + 'implant_ustu_zirkonyum' || + 'e_max' => + 'sabit', + 'hareketli' || 'tam_protez' || 'parsiyel' => 'hareketli', + 'gecici' => 'gecici', + 'diger' => 'diger', + _ => _prostheticTypes.first.$1, + }; } // ── Adaptive sheet helper ──────────────────────────────────────────────────── void _showAdaptive(BuildContext context, Widget content) { - final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; if (isDesktop) { showDialog( context: context, builder: (_) => Dialog( - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: content, @@ -98,8 +119,8 @@ class _LabProductsScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Ürünü Sil'), - content: Text( - '"${product.name}" ürününü silmek istediğinize emin misiniz?'), + content: + Text('"${product.name}" ürününü silmek istediğinize emin misiniz?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), @@ -107,8 +128,7 @@ class _LabProductsScreenState extends ConsumerState { ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom( - backgroundColor: AppColors.cancelled), + style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), child: const Text('Sil'), ), ], @@ -190,8 +210,7 @@ class _LabProductsScreenState extends ConsumerState { ), const SizedBox(height: 16), Text('Hata: ${snap.error}', - style: const TextStyle( - color: AppColors.textSecondary)), + style: const TextStyle(color: AppColors.textSecondary)), const SizedBox(height: 12), FilledButton.icon( onPressed: _load, @@ -206,9 +225,11 @@ class _LabProductsScreenState extends ConsumerState { final q = _searchQuery.toLowerCase().trim(); final products = q.isEmpty ? allProducts - : allProducts.where((p) => - p.name.toLowerCase().contains(q) || - _typeLabel(p.prostheticType).toLowerCase().contains(q)).toList(); + : allProducts + .where((p) => + p.name.toLowerCase().contains(q) || + _typeLabel(p.prostheticType).toLowerCase().contains(q)) + .toList(); if (allProducts.isEmpty) { return Center( @@ -226,18 +247,21 @@ class _LabProductsScreenState extends ConsumerState { ), const SizedBox(height: 16), Text( - q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi', + q.isNotEmpty + ? 'Sonuç bulunamadı' + : 'Henüz ürün eklenmedi', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), ), const SizedBox(height: 12), - if (q.isEmpty) FilledButton.icon( - onPressed: () => _showProductSheet(), - icon: const Icon(Icons.add), - label: const Text('İlk Ürünü Ekle'), - ), + if (q.isEmpty) + FilledButton.icon( + onPressed: () => _showProductSheet(), + icon: const Icon(Icons.add), + label: const Text('İlk Ürünü Ekle'), + ), ], ), ); @@ -260,7 +284,10 @@ class _LabProductsScreenState extends ConsumerState { const SizedBox(height: 16), const Text( 'Sonuç bulunamadı', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), ), ], ), @@ -272,10 +299,12 @@ class _LabProductsScreenState extends ConsumerState { itemCount: products.length, itemBuilder: (ctx, i) { final product = products[i]; - final statusColor = - product.isActive ? AppColors.inProgress : AppColors.textMuted; - final statusBg = - product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant; + final statusColor = product.isActive + ? AppColors.inProgress + : AppColors.textMuted; + final statusBg = product.isActive + ? AppColors.inProgressBg + : AppColors.surfaceVariant; return Padding( padding: const EdgeInsets.only(bottom: 10), child: GestureDetector( @@ -293,8 +322,7 @@ class _LabProductsScreenState extends ConsumerState { border: Border.all(color: AppColors.border), boxShadow: [ BoxShadow( - color: - Colors.black.withValues(alpha: 0.04), + color: Colors.black.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2)) ]), @@ -411,7 +439,7 @@ class _ProductFormState extends State<_ProductForm> { _priceCtrl = TextEditingController( text: p?.unitPrice != null ? p!.unitPrice!.toString() : ''); _descCtrl = TextEditingController(text: p?.description ?? ''); - _selectedType = p?.prostheticType ?? _prostheticTypes.first.$1; + _selectedType = _typeValueForForm(p?.prostheticType); _currency = p?.currency ?? 'TRY'; _isActive = p?.isActive ?? true; } @@ -437,8 +465,7 @@ class _ProductFormState extends State<_ProductForm> { unitPrice: price, currency: _currency, isActive: _isActive, - description: - _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), ); try { @@ -466,7 +493,8 @@ class _ProductFormState extends State<_ProductForm> { @override Widget build(BuildContext context) { - final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isEdit = widget.existing != null; return Container( decoration: BoxDecoration( @@ -479,9 +507,7 @@ class _ProductFormState extends State<_ProductForm> { left: 20, right: 20, top: 24, - bottom: isDesktop - ? 24 - : MediaQuery.of(context).viewInsets.bottom + 24, + bottom: isDesktop ? 24 : MediaQuery.of(context).viewInsets.bottom + 24, ), child: Form( key: _formKey, @@ -495,17 +521,14 @@ class _ProductFormState extends State<_ProductForm> { Expanded( child: Text( isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: AppColors.textPrimary), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), ), ), IconButton( - icon: const Icon(Icons.close, - color: AppColors.textSecondary), + icon: + const Icon(Icons.close, color: AppColors.textSecondary), onPressed: () => Navigator.of(context).pop(), ), ], @@ -515,8 +538,7 @@ class _ProductFormState extends State<_ProductForm> { // Name TextFormField( controller: _nameCtrl, - decoration: - const InputDecoration(labelText: 'Ürün Adı *'), + decoration: const InputDecoration(labelText: 'Ürün Adı *'), validator: (v) => v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null, ), @@ -525,8 +547,7 @@ class _ProductFormState extends State<_ProductForm> { // Prosthetic type dropdown DropdownButtonFormField( initialValue: _selectedType, - decoration: - const InputDecoration(labelText: 'Protez Tipi *'), + decoration: const InputDecoration(labelText: 'Protez Tipi *'), items: _prostheticTypes .map((t) => DropdownMenuItem( value: t.$1, @@ -546,8 +567,8 @@ class _ProductFormState extends State<_ProductForm> { controller: _priceCtrl, decoration: const InputDecoration(labelText: 'Birim Fiyat'), - keyboardType: const TextInputType.numberWithOptions( - decimal: true), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), validator: (v) { if (v != null && v.isNotEmpty) { if (double.tryParse(v) == null) { @@ -581,8 +602,8 @@ class _ProductFormState extends State<_ProductForm> { // Description TextFormField( controller: _descCtrl, - decoration: const InputDecoration( - labelText: 'Açıklama (isteğe bağlı)'), + decoration: + const InputDecoration(labelText: 'Açıklama (isteğe bağlı)'), maxLines: 2, ), const SizedBox(height: 12), diff --git a/lib/models/job.dart b/lib/models/job.dart index f9859f7..c0595f7 100644 --- a/lib/models/job.dart +++ b/lib/models/job.dart @@ -21,19 +21,28 @@ enum JobLocation { atClinic, atLab } enum JobWorkflowType { arjinat, geleneksel, dijital } -enum ProstheticFamily { sabit, implant, hareketli, gecici, ozel } +enum ProstheticFamily { sabit, hareketli, gecici, ozel } enum ProstheticType { + sabit, + hareketli, + gecici, + diger, metalPorselen, zirkonyum, implantUstuZirkonyum, - gecici, eMax, tamProtez, parsiyel, - diger, } +const visibleProstheticTypes = [ + ProstheticType.sabit, + ProstheticType.hareketli, + ProstheticType.gecici, + ProstheticType.diger, +]; + // ── Status ──────────────────────────────────────────────────────────────────── extension JobStatusExt on JobStatus { @@ -119,6 +128,7 @@ extension JobStepExt on JobStep { }; bool get isLabOptional => switch (this) { + JobStep.dijitalTasarim || JobStep.modelHazirlik || JobStep.fotografOnay || JobStep.kaliteKontrol || @@ -146,32 +156,38 @@ extension JobWorkflowTypeExt on JobWorkflowType { extension ProstheticTypeExt on ProstheticType { String get label => switch (this) { + ProstheticType.sabit => 'Sabit', + ProstheticType.hareketli => 'Hareketli', + ProstheticType.gecici => 'Geçici', + ProstheticType.diger => 'Diğer', ProstheticType.metalPorselen => 'Metal Porselen', ProstheticType.zirkonyum => 'Zirkonyum', ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum', - ProstheticType.gecici => 'Geçici', ProstheticType.eMax => 'E-Max', ProstheticType.tamProtez => 'Tam Protez', ProstheticType.parsiyel => 'Parsiyel Protez', - ProstheticType.diger => 'Diğer', }; String get value => switch (this) { + ProstheticType.sabit => 'sabit', + ProstheticType.hareketli => 'hareketli', + ProstheticType.gecici => 'gecici', + ProstheticType.diger => 'diger', ProstheticType.metalPorselen => 'metal_porselen', ProstheticType.zirkonyum => 'zirkonyum', ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum', - ProstheticType.gecici => 'gecici', ProstheticType.eMax => 'e_max', ProstheticType.tamProtez => 'tam_protez', ProstheticType.parsiyel => 'parsiyel', - ProstheticType.diger => 'diger', }; ProstheticFamily get family => switch (this) { + ProstheticType.sabit || ProstheticType.metalPorselen || ProstheticType.zirkonyum || + ProstheticType.implantUstuZirkonyum || ProstheticType.eMax => ProstheticFamily.sabit, - ProstheticType.implantUstuZirkonyum => ProstheticFamily.implant, + ProstheticType.hareketli || ProstheticType.tamProtez || ProstheticType.parsiyel => ProstheticFamily.hareketli, @@ -180,6 +196,19 @@ extension ProstheticTypeExt on ProstheticType { }; } +ProstheticType parseProstheticTypeValue(String value) => switch (value) { + 'sabit' || + 'metal_porselen' || + 'zirkonyum' || + 'implant_ustu_zirkonyum' || + 'e_max' => + ProstheticType.sabit, + 'hareketli' || 'tam_protez' || 'parsiyel' => ProstheticType.hareketli, + 'gecici' => ProstheticType.gecici, + 'diger' => ProstheticType.diger, + _ => ProstheticType.diger, + }; + // ── Step template ───────────────────────────────────────────────────────────── class JobWorkflowPreset { @@ -195,6 +224,7 @@ class JobWorkflowPreset { } const optionalLabStepCatalog = [ + JobStep.dijitalTasarim, JobStep.modelHazirlik, JobStep.fotografOnay, JobStep.kaliteKontrol, @@ -208,6 +238,8 @@ bool isOptionalStepApplicable( required bool provaRequired, }) { switch (step) { + case JobStep.dijitalTasarim: + return workflowType == JobWorkflowType.dijital; case JobStep.modelHazirlik: return workflowType != JobWorkflowType.dijital && family != ProstheticFamily.gecici; @@ -243,6 +275,8 @@ List mergeOptionalLabSteps({ final finalIndex = merged.indexOf(JobStep.cilaBitim); switch (step) { + case JobStep.dijitalTasarim: + merged.insert(0, step); case JobStep.modelHazirlik: final afterControl = merged.contains(JobStep.olcuKontrol) ? merged.indexOf(JobStep.olcuKontrol) + 1 @@ -274,48 +308,30 @@ JobWorkflowPreset buildJobWorkflowPreset({ steps = switch (family) { ProstheticFamily.sabit => provaRequired ? const [ - JobStep.dijitalTasarim, JobStep.ustYapiProva, JobStep.cilaBitim, ] : const [ - JobStep.dijitalTasarim, - JobStep.cilaBitim, - ], - ProstheticFamily.implant => provaRequired - ? const [ - JobStep.dijitalTasarim, - JobStep.dayanakProva, - JobStep.kronProva, - JobStep.cilaBitim, - ] - : const [ - JobStep.dijitalTasarim, JobStep.cilaBitim, ], ProstheticFamily.hareketli => provaRequired ? const [ - JobStep.dijitalTasarim, JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim, ] : const [ - JobStep.dijitalTasarim, JobStep.cilaBitim, ], ProstheticFamily.gecici => const [ - JobStep.dijitalTasarim, JobStep.cilaBitim, ], ProstheticFamily.ozel => provaRequired ? const [ - JobStep.dijitalTasarim, JobStep.altYapiProva, JobStep.cilaBitim, ] : const [ - JobStep.dijitalTasarim, JobStep.cilaBitim, ], }; @@ -333,17 +349,6 @@ JobWorkflowPreset buildJobWorkflowPreset({ JobStep.olcuKontrol, JobStep.cilaBitim, ], - ProstheticFamily.implant => provaRequired - ? const [ - JobStep.olcuKontrol, - JobStep.dayanakProva, - JobStep.kronProva, - JobStep.cilaBitim, - ] - : const [ - JobStep.olcuKontrol, - JobStep.cilaBitim, - ], ProstheticFamily.hareketli => provaRequired ? const [ JobStep.olcuKontrol, @@ -579,7 +584,7 @@ class Job { patientCode: j['patient_code'] as String, prostheticId: str(j['prosthetic_id']), prostheticName: prostheticExp?['name'] as String?, - prostheticType: _parseProstheticType(j['prosthetic_type'] as String), + prostheticType: parseProstheticTypeValue(j['prosthetic_type'] as String), memberCount: (j['member_count'] as num).toInt(), teeth: j['teeth'] is List ? (j['teeth'] as List).map((e) => e.toString()).toList() @@ -660,15 +665,4 @@ class Job { final code = (patientExp['patient_code'] as String?)?.trim(); return (code == null || code.isEmpty) ? null : code; } - - static ProstheticType _parseProstheticType(String s) => switch (s) { - 'zirkonyum' => ProstheticType.zirkonyum, - 'implant_ustu_zirkonyum' => ProstheticType.implantUstuZirkonyum, - 'gecici' => ProstheticType.gecici, - 'e_max' => ProstheticType.eMax, - 'tam_protez' => ProstheticType.tamProtez, - 'parsiyel' => ProstheticType.parsiyel, - 'diger' => ProstheticType.diger, - _ => ProstheticType.metalPorselen, - }; }