Apply clinic lab workflow feedback
This commit is contained in:
@@ -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)));
|
||||
|
||||
@@ -149,8 +149,14 @@ 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 ||
|
||||
parseProstheticTypeValue(p.prostheticType) ==
|
||||
_selectedProstheticType,
|
||||
)
|
||||
.toList();
|
||||
|
||||
ProstheticProduct? product;
|
||||
if (_selectedProduct != null) {
|
||||
@@ -198,6 +204,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
memberCount: _selectedTeeth.length,
|
||||
clinicTenantId: clinicTenantId,
|
||||
discounts: discounts,
|
||||
teeth: _selectedTeeth,
|
||||
);
|
||||
setState(() {
|
||||
_availableProducts = matchingProducts;
|
||||
@@ -688,7 +695,7 @@ class _NewJobScreenState extends ConsumerState<NewJobScreen> {
|
||||
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<NewJobScreen> {
|
||||
: _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;
|
||||
|
||||
@@ -85,4 +85,28 @@ class LabFinanceRepository {
|
||||
}
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final isSortActive = _sort != _FinanceSort.newestFirst;
|
||||
@@ -252,6 +314,9 @@ class _LabFinanceScreenState extends ConsumerState<LabFinanceScreen>
|
||||
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<CounterpartyFinanceSummary> items;
|
||||
final String Function(double) formatAmount;
|
||||
final Future<void> Function(CounterpartyFinanceSummary item) onConfirmAll;
|
||||
final Future<void> 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),
|
||||
|
||||
@@ -418,10 +418,32 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
|
||||
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<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),
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────
|
||||
|
||||
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')),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LabProductsScreen> {
|
||||
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<LabProductsScreen> {
|
||||
),
|
||||
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<LabProductsScreen> {
|
||||
),
|
||||
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<LabProductsScreen> {
|
||||
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<LabProductsScreen> {
|
||||
),
|
||||
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<LabProductsScreen> {
|
||||
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<LabProductsScreen> {
|
||||
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<LabProductsScreen> {
|
||||
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<String>(
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user