fix: improve job step notes and product pricing guardrails

This commit is contained in:
egecankomur
2026-06-12 00:15:18 +03:00
parent b42f68214e
commit d504e505d3
3 changed files with 244 additions and 4 deletions
@@ -540,10 +540,14 @@ class _StepperWidget extends StatelessWidget {
builder: (ctx, snap) {
final history = snap.data ?? [];
final Map<JobStep, int> revisionCounts = {};
final Map<JobStep, List<JobHistoryEntry>> 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 <JobHistoryEntry>[];
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;
+122 -4
View File
@@ -85,6 +85,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
PricingBreakdown? _pricingBreakdown;
double? _effectivePrice;
bool _priceLoading = false;
String? _productAvailabilityMessage;
@override
void initState() {
@@ -134,6 +135,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_labProduct = null;
_pricingBreakdown = null;
_effectivePrice = null;
_productAvailabilityMessage = null;
});
return;
}
@@ -163,13 +165,29 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
}
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<NewJobScreen> {
_labProduct = product;
_pricingBreakdown = breakdown;
_effectivePrice = breakdown.finalAmount;
_productAvailabilityMessage = null;
_priceLoading = false;
});
} catch (_) {
@@ -197,11 +216,29 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
_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<void> _searchPatients(String query) async {
final normalizedQuery = query.trim();
if (normalizedQuery.length < 2) {
@@ -307,6 +344,26 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
);
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<NewJobScreen> {
.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<NewJobScreen> {
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<NewJobScreen> {
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<NewJobScreen> {
}
}
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,