Apply clinic lab workflow feedback

This commit is contained in:
egecankomur
2026-07-15 22:13:01 +03:00
parent ac42681f7e
commit f44e07f378
9 changed files with 478 additions and 119 deletions
+43 -7
View File
@@ -27,14 +27,47 @@ class PricingService {
int billableUnitsForType(ProstheticType type, int memberCount) { int billableUnitsForType(ProstheticType type, int memberCount) {
final safeCount = memberCount <= 0 ? 1 : memberCount; final safeCount = memberCount <= 0 ? 1 : memberCount;
return switch (type) { return switch (type) {
ProstheticType.tamProtez || ProstheticType.parsiyel => 1, ProstheticType.hareketli ||
ProstheticType.tamProtez ||
ProstheticType.parsiyel =>
1,
_ => safeCount, _ => safeCount,
}; };
} }
int billableUnitsForTeeth(ProstheticType type, Iterable<int> 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<int>.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) { String unitLabelForType(ProstheticType type) {
return switch (type) { return switch (type) {
ProstheticType.tamProtez || ProstheticType.parsiyel => 'vaka', ProstheticType.hareketli ||
ProstheticType.tamProtez ||
ProstheticType.parsiyel =>
'vaka',
_ => 'diş', _ => 'diş',
}; };
} }
@@ -45,14 +78,18 @@ class PricingService {
required int memberCount, required int memberCount,
required String clinicTenantId, required String clinicTenantId,
required List<ClinicDiscount> discounts, required List<ClinicDiscount> discounts,
Iterable<int>? teeth,
}) { }) {
final billableUnits = billableUnitsForType(prostheticType, memberCount); final billableUnits = teeth == null
? billableUnitsForType(prostheticType, memberCount)
: billableUnitsForTeeth(prostheticType, teeth);
final unitPrice = product.unitPrice ?? 0; final unitPrice = product.unitPrice ?? 0;
final baseAmount = unitPrice * billableUnits; final baseAmount = unitPrice * billableUnits;
final applicable = discounts.where((discount) { final applicable = discounts.where((discount) {
if (!discount.isActive) return false; if (!discount.isActive) return false;
if (!(discount.appliesToAll || discount.clinicTenantId == clinicTenantId)) { if (!(discount.appliesToAll ||
discount.clinicTenantId == clinicTenantId)) {
return false; return false;
} }
if (!(discount.appliesToAllTypes || if (!(discount.appliesToAllTypes ||
@@ -77,9 +114,8 @@ class PricingService {
billableUnits: billableUnits, billableUnits: billableUnits,
unitPrice: unitPrice, unitPrice: unitPrice,
baseAmount: baseAmount, baseAmount: baseAmount,
discountAmount: (baseAmount - finalAmount) discountAmount:
.clamp(0, double.infinity) (baseAmount - finalAmount).clamp(0, double.infinity).toDouble(),
.toDouble(),
finalAmount: finalAmount, finalAmount: finalAmount,
appliedDiscounts: applicable, appliedDiscounts: applicable,
); );
@@ -39,6 +39,7 @@ class ClinicJobsRepository {
perPage: limit, perPage: limit,
filter: filterParts.join(' && '), filter: filterParts.join(' && '),
expand: _listExpand, expand: _listExpand,
sort: '-created',
); );
return (result.items.map((r) => Job.fromJson(r.toJson())).toList() return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
@@ -136,14 +137,14 @@ class ClinicJobsRepository {
'location': 'at_lab', 'location': 'at_lab',
}); });
final updated = Job.fromJson(record.toJson()); final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append( await JobHistoryService.instance.append(
jobId: jobId, jobId: jobId,
clinicTenantId: job.clinicTenantId, clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId, labTenantId: job.labTenantId,
action: JobHistoryAction.revisionRequested, action: JobHistoryAction.revisionRequested,
step: job.currentStep, step: job.currentStep,
note: note, note: note,
)); );
return updated; return updated;
} }
@@ -195,6 +196,7 @@ class ClinicJobsRepository {
filter: 'patient_id = "$patientId"', filter: 'patient_id = "$patientId"',
perPage: limit, perPage: limit,
expand: _listExpand, expand: _listExpand,
sort: '-created',
); );
return (result.items.map((r) => Job.fromJson(r.toJson())).toList() return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
+64 -3
View File
@@ -149,8 +149,14 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
labId, labId,
isActive: true, isActive: true,
); );
final matchingProducts = final matchingProducts = products
products.where((p) => p.prostheticType == ptValue).toList(); .where(
(p) =>
p.prostheticType == ptValue ||
parseProstheticTypeValue(p.prostheticType) ==
_selectedProstheticType,
)
.toList();
ProstheticProduct? product; ProstheticProduct? product;
if (_selectedProduct != null) { if (_selectedProduct != null) {
@@ -198,6 +204,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
memberCount: _selectedTeeth.length, memberCount: _selectedTeeth.length,
clinicTenantId: clinicTenantId, clinicTenantId: clinicTenantId,
discounts: discounts, discounts: discounts,
teeth: _selectedTeeth,
); );
setState(() { setState(() {
_availableProducts = matchingProducts; _availableProducts = matchingProducts;
@@ -688,7 +695,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Protez türü seçin', hintText: 'Protez türü seçin',
), ),
items: ProstheticType.values items: visibleProstheticTypes
.map( .map(
(pt) => DropdownMenuItem( (pt) => DropdownMenuItem(
value: pt, value: pt,
@@ -753,6 +760,10 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
: _InfoBannerTone.info, : _InfoBannerTone.info,
), ),
], ],
if (_selectedProduct?.description?.trim().isNotEmpty == true) ...[
const SizedBox(height: 8),
_ProductDescriptionCard(product: _selectedProduct!),
],
const SizedBox(height: 16), const SizedBox(height: 16),
const _SectionLabel(label: 'İş Tipi'), 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 { class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label}); const _SectionLabel({required this.label});
final String label; final String label;
@@ -85,4 +85,28 @@ class LabFinanceRepository {
} }
await FinanceService.instance.confirmJobPayment(jobId); await FinanceService.instance.confirmJobPayment(jobId);
} }
Future<void> confirmPayments(List<FinanceEntry> entries) async {
final jobIds = <String>{};
final standaloneIds = <String>[];
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,
});
}
}
} }
@@ -146,6 +146,68 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
} }
} }
Future<void> _confirmCounterpartyPayments(
CounterpartyFinanceSummary item,
List<FinanceEntry> 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<bool>(
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<void> _sendPaymentReminder(CounterpartyFinanceSummary item) async {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${item.counterpartyName} için ödeme hatırlatması hazırlandı.',
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst; final isSortActive = _sort != _FinanceSort.newestFirst;
@@ -252,6 +314,9 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
title: 'Klinik Bazlı Alacak', title: 'Klinik Bazlı Alacak',
items: data.counterparties, items: data.counterparties,
formatAmount: formatAmount, formatAmount: formatAmount,
onConfirmAll: (item) => _confirmCounterpartyPayments(
item, pending, formatAmount),
onSendReminder: _sendPaymentReminder,
), ),
), ),
PillTabs( PillTabs(
@@ -568,11 +633,15 @@ class _CounterpartySummaryList extends StatelessWidget {
required this.title, required this.title,
required this.items, required this.items,
required this.formatAmount, required this.formatAmount,
required this.onConfirmAll,
required this.onSendReminder,
}); });
final String title; final String title;
final List<CounterpartyFinanceSummary> items; final List<CounterpartyFinanceSummary> items;
final String Function(double) formatAmount; final String Function(double) formatAmount;
final Future<void> Function(CounterpartyFinanceSummary item) onConfirmAll;
final Future<void> Function(CounterpartyFinanceSummary item) onSendReminder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -616,6 +685,26 @@ class _CounterpartySummaryList extends StatelessWidget {
fontWeight: FontWeight.w700, 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), const SizedBox(height: 8),
@@ -418,10 +418,32 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
icon: Icons.notes, icon: Icons.notes,
label: 'Açıklama', label: 'Açıklama',
value: job.description!), value: job.description!),
if (job.workflowType == JobWorkflowType.arjinat ||
job.workflowType == JobWorkflowType.geleneksel) ...[
const SizedBox(height: 10),
_WorkflowAttentionBanner(type: job.workflowType!),
],
], ],
), ),
), ),
FutureBuilder<List<JobHistoryEntry>>(
future: _historyFuture,
builder: (context, snapshot) {
final entries = snapshot.data ?? const <JobHistoryEntry>[];
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), const SizedBox(height: 16),
// Stepper // Stepper
@@ -549,6 +571,106 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
} }
} }
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 ───────────────────────────────────────────────────── // ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
class _HandToClinicSheet extends StatefulWidget { class _HandToClinicSheet extends StatefulWidget {
@@ -578,11 +700,14 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
final isLast = widget.job.isLastStep; final isLast = widget.job.isLastStep;
final stepLabel = currentStep?.label ?? ''; final stepLabel = currentStep?.label ?? '';
final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true; final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true;
final continuesInLab = !widget.job.provaRequired &&
currentStep == JobStep.olcuKontrol &&
widget.job.nextStep == JobStep.cilaBitim;
final buttonLabel = isLast final buttonLabel = isLast
? (widget.job.provaRequired ? (widget.job.provaRequired
? 'Son Prova · Teslime Gönder' ? 'Son Prova · Teslime Gönder'
: 'Teslime Gönder') : 'Teslime Gönder')
: requiresClinicApproval : requiresClinicApproval && !continuesInLab
? '$stepLabel için Kliniğe Gönder' ? '$stepLabel için Kliniğe Gönder'
: '$stepLabel tamamlandı, sonraki adıma geç'; : '$stepLabel tamamlandı, sonraki adıma geç';
final buttonColor = isLast ? AppColors.success : AppColors.inProgress; final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
@@ -613,7 +738,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
Text( Text(
isLast isLast
? 'İş teslim edilecek olarak işaretlenecek.' ? 'İş teslim edilecek olarak işaretlenecek.'
: requiresClinicApproval : requiresClinicApproval && !continuesInLab
? 'İş klinikteki prova veya onay için gönderilecek.' ? 'İş klinikteki prova veya onay için gönderilecek.'
: 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.', : 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.',
style: const TextStyle(color: AppColors.textSecondary), style: const TextStyle(color: AppColors.textSecondary),
@@ -649,7 +774,7 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
SnackBar( SnackBar(
content: Text(isLast content: Text(isLast
? 'İş teslim için gönderildi' ? 'İş teslim için gönderildi'
: requiresClinicApproval : requiresClinicApproval && !continuesInLab
? 'Onay için kliniğe gönderildi' ? 'Onay için kliniğe gönderildi'
: 'İş bir sonraki iç adıma geçirildi')), : 'İş bir sonraki iç adıma geçirildi')),
); );
@@ -28,6 +28,7 @@ class LabJobsRepository {
perPage: limit, perPage: limit,
filter: filterParts.join(' && '), filter: filterParts.join(' && '),
expand: _listExpand, expand: _listExpand,
sort: '-created',
); );
return (result.items.map((r) => Job.fromJson(r.toJson())).toList() return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)));
@@ -44,6 +45,7 @@ class LabJobsRepository {
perPage: limit, perPage: limit,
filter: filterParts.join(' && '), filter: filterParts.join(' && '),
expand: _listExpand, expand: _listExpand,
sort: location == null ? '-created' : null,
); );
return (result.items.map((r) => Job.fromJson(r.toJson())).toList() return (result.items.map((r) => Job.fromJson(r.toJson())).toList()
..sort((a, b) { ..sort((a, b) {
@@ -89,9 +91,12 @@ class LabJobsRepository {
final isFinal = currentStep == JobStep.cilaBitim; final isFinal = currentStep == JobStep.cilaBitim;
final nextStep = job.nextStep; final nextStep = job.nextStep;
final shouldStayAtLabAfterMeasure = !job.provaRequired &&
currentStep == JobStep.olcuKontrol &&
nextStep == JobStep.cilaBitim;
final patch = isFinal final patch = isFinal
? {'status': 'sent', 'location': 'at_clinic'} ? {'status': 'sent', 'location': 'at_clinic'}
: currentStep.requiresClinicApproval : currentStep.requiresClinicApproval && !shouldStayAtLabAfterMeasure
? {'location': 'at_clinic'} ? {'location': 'at_clinic'}
: { : {
'current_step': nextStep?.value, 'current_step': nextStep?.value,
@@ -104,7 +109,9 @@ class LabJobsRepository {
jobId: jobId, jobId: jobId,
clinicTenantId: job.clinicTenantId, clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId, labTenantId: job.labTenantId,
action: currentStep.requiresClinicApproval || isFinal action: (currentStep.requiresClinicApproval &&
!shouldStayAtLabAfterMeasure) ||
isFinal
? JobHistoryAction.handedToClinic ? JobHistoryAction.handedToClinic
: JobHistoryAction.stepCompleted, : JobHistoryAction.stepCompleted,
step: currentStep, step: currentStep,
@@ -7,11 +7,9 @@ import '../../../models/prosthetic_product.dart';
import 'lab_products_repository.dart'; import 'lab_products_repository.dart';
const _prostheticTypes = [ const _prostheticTypes = [
('metal_porselen', 'Metal Porselen'), ('sabit', 'Sabit'),
('zirkonyum', 'Zirkonyum'), ('hareketli', 'Hareketli'),
('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'),
('gecici', 'Geçici'), ('gecici', 'Geçici'),
('e_max', 'E-Max'),
('diger', 'Diğer'), ('diger', 'Diğer'),
]; ];
@@ -19,19 +17,42 @@ String _typeLabel(String value) {
for (final t in _prostheticTypes) { for (final t in _prostheticTypes) {
if (t.$1 == value) return t.$2; 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 ──────────────────────────────────────────────────── // ── Adaptive sheet helper ────────────────────────────────────────────────────
void _showAdaptive(BuildContext context, Widget content) { void _showAdaptive(BuildContext context, Widget content) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) { if (isDesktop) {
showDialog( showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
shape: shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: content, child: content,
@@ -98,8 +119,8 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Ürünü Sil'), title: const Text('Ürünü Sil'),
content: Text( content:
'"${product.name}" ürününü silmek istediğinize emin misiniz?'), Text('"${product.name}" ürününü silmek istediğinize emin misiniz?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(false), onPressed: () => Navigator.of(ctx).pop(false),
@@ -107,8 +128,7 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
backgroundColor: AppColors.cancelled),
child: const Text('Sil'), child: const Text('Sil'),
), ),
], ],
@@ -190,8 +210,7 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Hata: ${snap.error}', Text('Hata: ${snap.error}',
style: const TextStyle( style: const TextStyle(color: AppColors.textSecondary)),
color: AppColors.textSecondary)),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
onPressed: _load, onPressed: _load,
@@ -206,9 +225,11 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
final q = _searchQuery.toLowerCase().trim(); final q = _searchQuery.toLowerCase().trim();
final products = q.isEmpty final products = q.isEmpty
? allProducts ? allProducts
: allProducts.where((p) => : allProducts
.where((p) =>
p.name.toLowerCase().contains(q) || p.name.toLowerCase().contains(q) ||
_typeLabel(p.prostheticType).toLowerCase().contains(q)).toList(); _typeLabel(p.prostheticType).toLowerCase().contains(q))
.toList();
if (allProducts.isEmpty) { if (allProducts.isEmpty) {
return Center( return Center(
@@ -226,14 +247,17 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi', q.isNotEmpty
? 'Sonuç bulunamadı'
: 'Henüz ürün eklenmedi',
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary), color: AppColors.textPrimary),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (q.isEmpty) FilledButton.icon( if (q.isEmpty)
FilledButton.icon(
onPressed: () => _showProductSheet(), onPressed: () => _showProductSheet(),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('İlk Ürünü Ekle'), label: const Text('İlk Ürünü Ekle'),
@@ -260,7 +284,10 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'Sonuç bulunamadı', '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<LabProductsScreen> {
itemCount: products.length, itemCount: products.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final product = products[i]; final product = products[i];
final statusColor = final statusColor = product.isActive
product.isActive ? AppColors.inProgress : AppColors.textMuted; ? AppColors.inProgress
final statusBg = : AppColors.textMuted;
product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant; final statusBg = product.isActive
? AppColors.inProgressBg
: AppColors.surfaceVariant;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector( child: GestureDetector(
@@ -293,8 +322,7 @@ class _LabProductsScreenState extends ConsumerState<LabProductsScreen> {
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: color: Colors.black.withValues(alpha: 0.04),
Colors.black.withValues(alpha: 0.04),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2)) offset: const Offset(0, 2))
]), ]),
@@ -411,7 +439,7 @@ class _ProductFormState extends State<_ProductForm> {
_priceCtrl = TextEditingController( _priceCtrl = TextEditingController(
text: p?.unitPrice != null ? p!.unitPrice!.toString() : ''); text: p?.unitPrice != null ? p!.unitPrice!.toString() : '');
_descCtrl = TextEditingController(text: p?.description ?? ''); _descCtrl = TextEditingController(text: p?.description ?? '');
_selectedType = p?.prostheticType ?? _prostheticTypes.first.$1; _selectedType = _typeValueForForm(p?.prostheticType);
_currency = p?.currency ?? 'TRY'; _currency = p?.currency ?? 'TRY';
_isActive = p?.isActive ?? true; _isActive = p?.isActive ?? true;
} }
@@ -437,8 +465,7 @@ class _ProductFormState extends State<_ProductForm> {
unitPrice: price, unitPrice: price,
currency: _currency, currency: _currency,
isActive: _isActive, isActive: _isActive,
description: description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
); );
try { try {
@@ -466,7 +493,8 @@ class _ProductFormState extends State<_ProductForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final isEdit = widget.existing != null; final isEdit = widget.existing != null;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -479,9 +507,7 @@ class _ProductFormState extends State<_ProductForm> {
left: 20, left: 20,
right: 20, right: 20,
top: 24, top: 24,
bottom: isDesktop bottom: isDesktop ? 24 : MediaQuery.of(context).viewInsets.bottom + 24,
? 24
: MediaQuery.of(context).viewInsets.bottom + 24,
), ),
child: Form( child: Form(
key: _formKey, key: _formKey,
@@ -495,17 +521,14 @@ class _ProductFormState extends State<_ProductForm> {
Expanded( Expanded(
child: Text( child: Text(
isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün', isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün',
style: Theme.of(context) style: Theme.of(context).textTheme.titleMedium?.copyWith(
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.textPrimary), color: AppColors.textPrimary),
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close, icon:
color: AppColors.textSecondary), const Icon(Icons.close, color: AppColors.textSecondary),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
], ],
@@ -515,8 +538,7 @@ class _ProductFormState extends State<_ProductForm> {
// Name // Name
TextFormField( TextFormField(
controller: _nameCtrl, controller: _nameCtrl,
decoration: decoration: const InputDecoration(labelText: 'Ürün Adı *'),
const InputDecoration(labelText: 'Ürün Adı *'),
validator: (v) => validator: (v) =>
v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null, v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null,
), ),
@@ -525,8 +547,7 @@ class _ProductFormState extends State<_ProductForm> {
// Prosthetic type dropdown // Prosthetic type dropdown
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _selectedType, initialValue: _selectedType,
decoration: decoration: const InputDecoration(labelText: 'Protez Tipi *'),
const InputDecoration(labelText: 'Protez Tipi *'),
items: _prostheticTypes items: _prostheticTypes
.map((t) => DropdownMenuItem( .map((t) => DropdownMenuItem(
value: t.$1, value: t.$1,
@@ -546,8 +567,8 @@ class _ProductFormState extends State<_ProductForm> {
controller: _priceCtrl, controller: _priceCtrl,
decoration: decoration:
const InputDecoration(labelText: 'Birim Fiyat'), const InputDecoration(labelText: 'Birim Fiyat'),
keyboardType: const TextInputType.numberWithOptions( keyboardType:
decimal: true), const TextInputType.numberWithOptions(decimal: true),
validator: (v) { validator: (v) {
if (v != null && v.isNotEmpty) { if (v != null && v.isNotEmpty) {
if (double.tryParse(v) == null) { if (double.tryParse(v) == null) {
@@ -581,8 +602,8 @@ class _ProductFormState extends State<_ProductForm> {
// Description // Description
TextFormField( TextFormField(
controller: _descCtrl, controller: _descCtrl,
decoration: const InputDecoration( decoration:
labelText: 'Açıklama (isteğe bağlı)'), const InputDecoration(labelText: 'Açıklama (isteğe bağlı)'),
maxLines: 2, maxLines: 2,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
+43 -49
View File
@@ -21,19 +21,28 @@ enum JobLocation { atClinic, atLab }
enum JobWorkflowType { arjinat, geleneksel, dijital } enum JobWorkflowType { arjinat, geleneksel, dijital }
enum ProstheticFamily { sabit, implant, hareketli, gecici, ozel } enum ProstheticFamily { sabit, hareketli, gecici, ozel }
enum ProstheticType { enum ProstheticType {
sabit,
hareketli,
gecici,
diger,
metalPorselen, metalPorselen,
zirkonyum, zirkonyum,
implantUstuZirkonyum, implantUstuZirkonyum,
gecici,
eMax, eMax,
tamProtez, tamProtez,
parsiyel, parsiyel,
diger,
} }
const visibleProstheticTypes = <ProstheticType>[
ProstheticType.sabit,
ProstheticType.hareketli,
ProstheticType.gecici,
ProstheticType.diger,
];
// ── Status ──────────────────────────────────────────────────────────────────── // ── Status ────────────────────────────────────────────────────────────────────
extension JobStatusExt on JobStatus { extension JobStatusExt on JobStatus {
@@ -119,6 +128,7 @@ extension JobStepExt on JobStep {
}; };
bool get isLabOptional => switch (this) { bool get isLabOptional => switch (this) {
JobStep.dijitalTasarim ||
JobStep.modelHazirlik || JobStep.modelHazirlik ||
JobStep.fotografOnay || JobStep.fotografOnay ||
JobStep.kaliteKontrol || JobStep.kaliteKontrol ||
@@ -146,32 +156,38 @@ extension JobWorkflowTypeExt on JobWorkflowType {
extension ProstheticTypeExt on ProstheticType { extension ProstheticTypeExt on ProstheticType {
String get label => switch (this) { String get label => switch (this) {
ProstheticType.sabit => 'Sabit',
ProstheticType.hareketli => 'Hareketli',
ProstheticType.gecici => 'Geçici',
ProstheticType.diger => 'Diğer',
ProstheticType.metalPorselen => 'Metal Porselen', ProstheticType.metalPorselen => 'Metal Porselen',
ProstheticType.zirkonyum => 'Zirkonyum', ProstheticType.zirkonyum => 'Zirkonyum',
ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum', ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum',
ProstheticType.gecici => 'Geçici',
ProstheticType.eMax => 'E-Max', ProstheticType.eMax => 'E-Max',
ProstheticType.tamProtez => 'Tam Protez', ProstheticType.tamProtez => 'Tam Protez',
ProstheticType.parsiyel => 'Parsiyel Protez', ProstheticType.parsiyel => 'Parsiyel Protez',
ProstheticType.diger => 'Diğer',
}; };
String get value => switch (this) { String get value => switch (this) {
ProstheticType.sabit => 'sabit',
ProstheticType.hareketli => 'hareketli',
ProstheticType.gecici => 'gecici',
ProstheticType.diger => 'diger',
ProstheticType.metalPorselen => 'metal_porselen', ProstheticType.metalPorselen => 'metal_porselen',
ProstheticType.zirkonyum => 'zirkonyum', ProstheticType.zirkonyum => 'zirkonyum',
ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum', ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum',
ProstheticType.gecici => 'gecici',
ProstheticType.eMax => 'e_max', ProstheticType.eMax => 'e_max',
ProstheticType.tamProtez => 'tam_protez', ProstheticType.tamProtez => 'tam_protez',
ProstheticType.parsiyel => 'parsiyel', ProstheticType.parsiyel => 'parsiyel',
ProstheticType.diger => 'diger',
}; };
ProstheticFamily get family => switch (this) { ProstheticFamily get family => switch (this) {
ProstheticType.sabit ||
ProstheticType.metalPorselen || ProstheticType.metalPorselen ||
ProstheticType.zirkonyum || ProstheticType.zirkonyum ||
ProstheticType.implantUstuZirkonyum ||
ProstheticType.eMax => ProstheticType.eMax =>
ProstheticFamily.sabit, ProstheticFamily.sabit,
ProstheticType.implantUstuZirkonyum => ProstheticFamily.implant, ProstheticType.hareketli ||
ProstheticType.tamProtez || ProstheticType.tamProtez ||
ProstheticType.parsiyel => ProstheticType.parsiyel =>
ProstheticFamily.hareketli, 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 ───────────────────────────────────────────────────────────── // ── Step template ─────────────────────────────────────────────────────────────
class JobWorkflowPreset { class JobWorkflowPreset {
@@ -195,6 +224,7 @@ class JobWorkflowPreset {
} }
const optionalLabStepCatalog = <JobStep>[ const optionalLabStepCatalog = <JobStep>[
JobStep.dijitalTasarim,
JobStep.modelHazirlik, JobStep.modelHazirlik,
JobStep.fotografOnay, JobStep.fotografOnay,
JobStep.kaliteKontrol, JobStep.kaliteKontrol,
@@ -208,6 +238,8 @@ bool isOptionalStepApplicable(
required bool provaRequired, required bool provaRequired,
}) { }) {
switch (step) { switch (step) {
case JobStep.dijitalTasarim:
return workflowType == JobWorkflowType.dijital;
case JobStep.modelHazirlik: case JobStep.modelHazirlik:
return workflowType != JobWorkflowType.dijital && return workflowType != JobWorkflowType.dijital &&
family != ProstheticFamily.gecici; family != ProstheticFamily.gecici;
@@ -243,6 +275,8 @@ List<JobStep> mergeOptionalLabSteps({
final finalIndex = merged.indexOf(JobStep.cilaBitim); final finalIndex = merged.indexOf(JobStep.cilaBitim);
switch (step) { switch (step) {
case JobStep.dijitalTasarim:
merged.insert(0, step);
case JobStep.modelHazirlik: case JobStep.modelHazirlik:
final afterControl = merged.contains(JobStep.olcuKontrol) final afterControl = merged.contains(JobStep.olcuKontrol)
? merged.indexOf(JobStep.olcuKontrol) + 1 ? merged.indexOf(JobStep.olcuKontrol) + 1
@@ -274,48 +308,30 @@ JobWorkflowPreset buildJobWorkflowPreset({
steps = switch (family) { steps = switch (family) {
ProstheticFamily.sabit => provaRequired ProstheticFamily.sabit => provaRequired
? const [ ? const [
JobStep.dijitalTasarim,
JobStep.ustYapiProva, JobStep.ustYapiProva,
JobStep.cilaBitim, JobStep.cilaBitim,
] ]
: const [ : const [
JobStep.dijitalTasarim,
JobStep.cilaBitim,
],
ProstheticFamily.implant => provaRequired
? const [
JobStep.dijitalTasarim,
JobStep.dayanakProva,
JobStep.kronProva,
JobStep.cilaBitim,
]
: const [
JobStep.dijitalTasarim,
JobStep.cilaBitim, JobStep.cilaBitim,
], ],
ProstheticFamily.hareketli => provaRequired ProstheticFamily.hareketli => provaRequired
? const [ ? const [
JobStep.dijitalTasarim,
JobStep.mumProva, JobStep.mumProva,
JobStep.dislerProva, JobStep.dislerProva,
JobStep.cilaBitim, JobStep.cilaBitim,
] ]
: const [ : const [
JobStep.dijitalTasarim,
JobStep.cilaBitim, JobStep.cilaBitim,
], ],
ProstheticFamily.gecici => const [ ProstheticFamily.gecici => const [
JobStep.dijitalTasarim,
JobStep.cilaBitim, JobStep.cilaBitim,
], ],
ProstheticFamily.ozel => provaRequired ProstheticFamily.ozel => provaRequired
? const [ ? const [
JobStep.dijitalTasarim,
JobStep.altYapiProva, JobStep.altYapiProva,
JobStep.cilaBitim, JobStep.cilaBitim,
] ]
: const [ : const [
JobStep.dijitalTasarim,
JobStep.cilaBitim, JobStep.cilaBitim,
], ],
}; };
@@ -333,17 +349,6 @@ JobWorkflowPreset buildJobWorkflowPreset({
JobStep.olcuKontrol, JobStep.olcuKontrol,
JobStep.cilaBitim, JobStep.cilaBitim,
], ],
ProstheticFamily.implant => provaRequired
? const [
JobStep.olcuKontrol,
JobStep.dayanakProva,
JobStep.kronProva,
JobStep.cilaBitim,
]
: const [
JobStep.olcuKontrol,
JobStep.cilaBitim,
],
ProstheticFamily.hareketli => provaRequired ProstheticFamily.hareketli => provaRequired
? const [ ? const [
JobStep.olcuKontrol, JobStep.olcuKontrol,
@@ -579,7 +584,7 @@ class Job {
patientCode: j['patient_code'] as String, patientCode: j['patient_code'] as String,
prostheticId: str(j['prosthetic_id']), prostheticId: str(j['prosthetic_id']),
prostheticName: prostheticExp?['name'] as String?, 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(), memberCount: (j['member_count'] as num).toInt(),
teeth: j['teeth'] is List teeth: j['teeth'] is List
? (j['teeth'] as List).map((e) => e.toString()).toList() ? (j['teeth'] as List).map((e) => e.toString()).toList()
@@ -660,15 +665,4 @@ class Job {
final code = (patientExp['patient_code'] as String?)?.trim(); final code = (patientExp['patient_code'] as String?)?.trim();
return (code == null || code.isEmpty) ? null : code; 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,
};
} }