diff --git a/lib/features/clinic/jobs/clinic_job_detail_screen.dart b/lib/features/clinic/jobs/clinic_job_detail_screen.dart index 4ba584a..aaef644 100644 --- a/lib/features/clinic/jobs/clinic_job_detail_screen.dart +++ b/lib/features/clinic/jobs/clinic_job_detail_screen.dart @@ -540,10 +540,14 @@ class _StepperWidget extends StatelessWidget { builder: (ctx, snap) { final history = snap.data ?? []; final Map revisionCounts = {}; + final Map> notesByStep = {}; for (final e in history) { 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) { + notesByStep.putIfAbsent(e.step!, () => []).add(e); + } } return Column( @@ -553,6 +557,7 @@ class _StepperWidget extends StatelessWidget { final isCompleted = index < currentStepIndex; final isCurrent = index == currentStepIndex; final revCount = revisionCounts[step] ?? 0; + final stepNotes = notesByStep[step] ?? const []; Color dotColor; IconData dotIcon; @@ -635,6 +640,10 @@ class _StepperWidget extends StatelessWidget { color: AppColors.textSecondary, ), ), + if (stepNotes.isNotEmpty) ...[ + const SizedBox(height: 8), + ...stepNotes.map((entry) => _StepNoteCard(entry: entry)), + ], ], ), ), @@ -648,6 +657,58 @@ class _StepperWidget extends StatelessWidget { } } +class _StepNoteCard extends StatelessWidget { + const _StepNoteCard({required this.entry}); + + final JobHistoryEntry entry; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _label(entry.action), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + entry.note!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + } + + String _label(JobHistoryAction action) { + return switch (action) { + JobHistoryAction.revisionRequested => 'Revizyon Notu', + JobHistoryAction.handedToClinic => 'Laboratuvar Notu', + JobHistoryAction.approved => 'Onay Notu', + JobHistoryAction.delivered => 'Teslim Notu', + JobHistoryAction.accepted => 'Kabul Notu', + JobHistoryAction.cancelled => 'İptal Notu', + }; + } +} + class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.title}); final String title; diff --git a/lib/features/clinic/jobs/new_job_screen.dart b/lib/features/clinic/jobs/new_job_screen.dart index e08ca33..49ca5dc 100644 --- a/lib/features/clinic/jobs/new_job_screen.dart +++ b/lib/features/clinic/jobs/new_job_screen.dart @@ -85,6 +85,7 @@ class _NewJobScreenState extends ConsumerState { PricingBreakdown? _pricingBreakdown; double? _effectivePrice; bool _priceLoading = false; + String? _productAvailabilityMessage; @override void initState() { @@ -134,6 +135,7 @@ class _NewJobScreenState extends ConsumerState { _labProduct = null; _pricingBreakdown = null; _effectivePrice = null; + _productAvailabilityMessage = null; }); return; } @@ -163,13 +165,29 @@ class _NewJobScreenState extends ConsumerState { } product ??= matchingProducts.isNotEmpty ? matchingProducts.first : null; - if (product == null || product.unitPrice == null) { + if (product == null) { setState(() { _availableProducts = matchingProducts; - _selectedProduct = product; + _selectedProduct = null; _labProduct = null; _pricingBreakdown = null; _effectivePrice = null; + _productAvailabilityMessage = + 'Bu laboratuvarda seçtiğiniz protez türü için ürün tanımlı değil.'; + _priceLoading = false; + }); + return; + } + + if (product.unitPrice == null) { + setState(() { + _availableProducts = matchingProducts; + _selectedProduct = product; + _labProduct = product; + _pricingBreakdown = null; + _effectivePrice = null; + _productAvailabilityMessage = + 'Seçilen ürün laboratuvarda mevcut ancak fiyatı tanımlı değil. Laboratuvarın fiyat tanımlaması gerekiyor.'; _priceLoading = false; }); return; @@ -188,6 +206,7 @@ class _NewJobScreenState extends ConsumerState { _labProduct = product; _pricingBreakdown = breakdown; _effectivePrice = breakdown.finalAmount; + _productAvailabilityMessage = null; _priceLoading = false; }); } catch (_) { @@ -197,11 +216,29 @@ class _NewJobScreenState extends ConsumerState { _labProduct = null; _pricingBreakdown = null; _effectivePrice = null; + _productAvailabilityMessage = + 'Ürün ve fiyat bilgisi alınırken bir hata oluştu.'; _priceLoading = false; }); } } + bool get _hasMissingProductForType => + _selectedLab != null && + _selectedProstheticType != null && + !_priceLoading && + _availableProducts.isEmpty; + + bool get _hasSelectedProductWithoutPrice => + _selectedProduct != null && + _selectedProduct!.unitPrice == null; + + bool get _canSubmitJob => + !_isSubmitting && + !_priceLoading && + !_hasMissingProductForType && + !_hasSelectedProductWithoutPrice; + Future _searchPatients(String query) async { final normalizedQuery = query.trim(); if (normalizedQuery.length < 2) { @@ -307,6 +344,26 @@ class _NewJobScreenState extends ConsumerState { ); return; } + if (_hasMissingProductForType) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Bu laboratuvarda seçtiğiniz protez türü için ürün tanımlı değil.', + ), + ), + ); + return; + } + if (_hasSelectedProductWithoutPrice) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Seçilen ürünün fiyatı laboratuvarda tanımlı değil. İş açmadan önce fiyat tanımlanmalı.', + ), + ), + ); + return; + } if (_selectedTeeth.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('En az bir diş seçmelisiniz.')), @@ -651,7 +708,11 @@ class _NewJobScreenState extends ConsumerState { .map( (product) => DropdownMenuItem( value: product, - child: Text(product.name), + child: Text( + product.unitPrice == null + ? '${product.name} · Fiyat tanımsız' + : product.name, + ), ), ) .toList(), @@ -668,6 +729,15 @@ class _NewJobScreenState extends ConsumerState { return null; }, ), + if (_productAvailabilityMessage != null) ...[ + const SizedBox(height: 8), + _InlineInfoBanner( + message: _productAvailabilityMessage!, + tone: _hasMissingProductForType || _hasSelectedProductWithoutPrice + ? _InfoBannerTone.warning + : _InfoBannerTone.info, + ), + ], const SizedBox(height: 16), _SectionLabel(label: 'İş Tipi'), @@ -846,7 +916,7 @@ class _NewJobScreenState extends ConsumerState { const Center(child: CircularProgressIndicator()) else FilledButton.icon( - onPressed: _submit, + onPressed: _canSubmitJob ? _submit : null, icon: const Icon(Icons.check), label: const Text('İş Oluştur'), style: FilledButton.styleFrom( @@ -861,6 +931,54 @@ class _NewJobScreenState extends ConsumerState { } } +enum _InfoBannerTone { info, warning } + +class _InlineInfoBanner extends StatelessWidget { + const _InlineInfoBanner({ + required this.message, + required this.tone, + }); + + final String message; + final _InfoBannerTone tone; + + @override + Widget build(BuildContext context) { + final isWarning = tone == _InfoBannerTone.warning; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isWarning ? AppColors.pendingBg : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isWarning ? AppColors.pending : AppColors.border, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isWarning ? Icons.warning_amber_rounded : Icons.info_outline_rounded, + size: 18, + color: isWarning ? AppColors.pending : AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle( + fontSize: 12, + color: isWarning ? AppColors.pending : AppColors.textSecondary, + fontWeight: isWarning ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + class _TeethBulkBar extends StatelessWidget { const _TeethBulkBar({ required this.selectedTeeth, diff --git a/lib/features/lab/jobs/lab_job_detail_screen.dart b/lib/features/lab/jobs/lab_job_detail_screen.dart index 89c6af3..74b86ee 100644 --- a/lib/features/lab/jobs/lab_job_detail_screen.dart +++ b/lib/features/lab/jobs/lab_job_detail_screen.dart @@ -684,10 +684,14 @@ class _JobStepper extends StatelessWidget { final history = snap.data ?? []; // Revizyon sayısı per adım final Map revisionCounts = {}; + final Map> notesByStep = {}; for (final e in history) { 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) { + notesByStep.putIfAbsent(e.step!, () => []).add(e); + } } final currentIndex = currentStep != null ? steps.indexOf(currentStep!) : -1; @@ -699,6 +703,7 @@ class _JobStepper extends StatelessWidget { final isCurrent = i == currentIndex; final isLastItem = i == steps.length - 1; final revCount = revisionCounts[step] ?? 0; + final stepNotes = notesByStep[step] ?? const []; Color dotColor; IconData dotIcon; @@ -781,6 +786,10 @@ class _JobStepper extends StatelessWidget { color: AppColors.textSecondary, ), ), + if (stepNotes.isNotEmpty) ...[ + const SizedBox(height: 8), + ...stepNotes.map((entry) => _StepNoteCard(entry: entry)), + ], ], ), ), @@ -793,3 +802,55 @@ class _JobStepper extends StatelessWidget { ); } } + +class _StepNoteCard extends StatelessWidget { + const _StepNoteCard({required this.entry}); + + final JobHistoryEntry entry; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _label(entry.action), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + entry.note!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + } + + String _label(JobHistoryAction action) { + return switch (action) { + JobHistoryAction.revisionRequested => 'Revizyon Notu', + JobHistoryAction.handedToClinic => 'Laboratuvar Notu', + JobHistoryAction.approved => 'Onay Notu', + JobHistoryAction.delivered => 'Teslim Notu', + JobHistoryAction.accepted => 'Kabul Notu', + JobHistoryAction.cancelled => 'İptal Notu', + }; + } +}