Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -20,39 +20,70 @@ class ClinicJobDetailScreen extends ConsumerStatefulWidget {
|
||||
_ClinicJobDetailScreenState();
|
||||
}
|
||||
|
||||
class _ClinicJobDetailScreenState
|
||||
extends ConsumerState<ClinicJobDetailScreen> {
|
||||
class _ClinicJobDetailScreenState extends ConsumerState<ClinicJobDetailScreen> {
|
||||
Job? _job;
|
||||
String? _loadError;
|
||||
late Future<List<JobFile>> _filesFuture;
|
||||
late Future<List<JobHistoryEntry>> _historyFuture;
|
||||
bool _isActing = false;
|
||||
late UnsubFn _unsub;
|
||||
final List<UnsubFn> _unsubs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadFiles();
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
_loadHistory();
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
topic: widget.jobId,
|
||||
onEvent: (_) { if (mounted && !_isActing) _load(); },
|
||||
);
|
||||
onEvent: (_) {
|
||||
if (mounted && !_isActing) _load();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_files',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadFiles();
|
||||
},
|
||||
));
|
||||
_unsubs.add(RealtimeService.instance.watch(
|
||||
'job_status_history',
|
||||
filter: 'job_id="${widget.jobId}"',
|
||||
onEvent: (_) {
|
||||
if (mounted) _loadHistory();
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
for (final unsub in _unsubs) {
|
||||
unsub();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (mounted) setState(() { _loadError = null; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final job = await ClinicJobsRepository.instance.getJob(widget.jobId);
|
||||
if (mounted) setState(() { _job = job; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_job = job;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loadError = e.toString(); });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +93,23 @@ class _ClinicJobDetailScreenState
|
||||
});
|
||||
}
|
||||
|
||||
void _loadHistory() {
|
||||
setState(() {
|
||||
_historyFuture = JobHistoryService.instance.listForJob(widget.jobId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _approve(Job job) async {
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
final updated =
|
||||
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş onaylandı.')),
|
||||
);
|
||||
@@ -87,9 +129,12 @@ class _ClinicJobDetailScreenState
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('İşi İptal Et'),
|
||||
content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
content: const Text(
|
||||
'Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
@@ -101,15 +146,21 @@ class _ClinicJobDetailScreenState
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
final updated =
|
||||
await ClinicJobsRepository.instance.cancelJob(job.id, job);
|
||||
if (mounted) {
|
||||
setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
setState(() {
|
||||
_job = _job!.copyWith(status: updated.status);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('İş iptal edildi.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isActing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +208,11 @@ class _ClinicJobDetailScreenState
|
||||
note: noteController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Revizyon talebi gönderildi.')),
|
||||
);
|
||||
@@ -212,10 +267,16 @@ class _ClinicJobDetailScreenState
|
||||
|
||||
setState(() => _isActing = true);
|
||||
try {
|
||||
final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note);
|
||||
final note =
|
||||
noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null;
|
||||
final updated = await ClinicJobsRepository.instance
|
||||
.markDelivered(job.id, job, note: note);
|
||||
if (mounted) {
|
||||
setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; });
|
||||
setState(() {
|
||||
_job = updated.copyWith(
|
||||
clinicName: job.clinicName, labName: job.labName);
|
||||
_isActing = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')),
|
||||
);
|
||||
@@ -241,7 +302,8 @@ class _ClinicJobDetailScreenState
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_job == null && _loadError == null) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
if (_loadError != null && _job == null) {
|
||||
return Center(
|
||||
@@ -270,22 +332,28 @@ class _ClinicJobDetailScreenState
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||||
if (_job == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
}
|
||||
final job = _job!;
|
||||
final membership = ref.read(authProvider).activeTenant;
|
||||
final canDeliver = membership?.canDeliverJobs ?? true;
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
final canCancel = membership?.canCancelJobs ?? true;
|
||||
final canManage = !(membership?.isDeliveryOnly ?? false);
|
||||
return _JobDetailBody(
|
||||
job: job,
|
||||
filesFuture: _filesFuture,
|
||||
historyFuture: _historyFuture,
|
||||
isActing: _isActing,
|
||||
canDeliver: canDeliver,
|
||||
canManage: canManage,
|
||||
onApprove: canManage ? () => _approve(job) : () {},
|
||||
onRevision: canManage ? () => _requestRevision(job) : () {},
|
||||
onDelivered: () => _markDelivered(job),
|
||||
onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null,
|
||||
onCancel: (canCancel && job.status == JobStatus.pending)
|
||||
? () => _cancelJob(job)
|
||||
: null,
|
||||
onFilesRefresh: _loadFiles,
|
||||
);
|
||||
}
|
||||
@@ -295,6 +363,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const _JobDetailBody({
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.historyFuture,
|
||||
required this.isActing,
|
||||
required this.canDeliver,
|
||||
required this.canManage,
|
||||
@@ -307,6 +376,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
final bool isActing;
|
||||
final bool canDeliver;
|
||||
final bool canManage;
|
||||
@@ -355,7 +425,9 @@ class _JobDetailBody extends StatelessWidget {
|
||||
job.patientName?.isNotEmpty == true
|
||||
? job.patientName!
|
||||
: job.patientCode,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary),
|
||||
@@ -369,7 +441,7 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Patient + Lab
|
||||
_SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
const _SectionLabel(title: 'Hasta & Laboratuvar'),
|
||||
if (job.patientName != null && job.patientName!.isNotEmpty)
|
||||
_InfoRow(label: 'Hasta', value: job.patientName!),
|
||||
_InfoRow(label: 'Protokol No', value: job.patientCode),
|
||||
@@ -380,12 +452,13 @@ class _JobDetailBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Prosthetic
|
||||
_SectionLabel(title: 'Protez Bilgisi'),
|
||||
const _SectionLabel(title: 'Protez Bilgisi'),
|
||||
_InfoRow(label: 'Tür', value: job.prostheticType.label),
|
||||
if (job.prostheticName != null && job.prostheticName!.isNotEmpty)
|
||||
_InfoRow(label: 'Ürün', value: job.prostheticName!),
|
||||
if (job.workflowType != null)
|
||||
_InfoRow(label: 'İş Tipi', value: job.workflowType!.label),
|
||||
_InfoRow(label: 'Akış', value: job.workflowPreset.title),
|
||||
_InfoRow(
|
||||
label: 'Prova',
|
||||
value: job.provaRequired ? 'Provalı' : 'Provasız',
|
||||
@@ -398,7 +471,9 @@ class _JobDetailBody extends StatelessWidget {
|
||||
if (job.description != null && job.description!.isNotEmpty)
|
||||
_InfoRow(label: 'Açıklama', value: job.description!),
|
||||
if (job.dueDate != null)
|
||||
_InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)),
|
||||
_InfoRow(
|
||||
label: 'Son Tarih',
|
||||
value: _formatDate(job.dueDate!, withTime: true)),
|
||||
if (job.price != null)
|
||||
_InfoRow(
|
||||
label: 'Fiyat',
|
||||
@@ -438,7 +513,8 @@ class _JobDetailBody extends StatelessWidget {
|
||||
_StepperWidget(
|
||||
steps: steps,
|
||||
currentStepIndex: currentStepIndex,
|
||||
historyFuture: JobHistoryService.instance.listForJob(job.id),
|
||||
isDelivered: job.status == JobStatus.delivered,
|
||||
historyFuture: historyFuture,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -516,7 +592,8 @@ class _JobDetailBody extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d, {bool withTime = false}) {
|
||||
final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
final s =
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
if (!withTime || (d.hour == 0 && d.minute == 0)) return s;
|
||||
return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -526,11 +603,13 @@ class _StepperWidget extends StatelessWidget {
|
||||
const _StepperWidget({
|
||||
required this.steps,
|
||||
required this.currentStepIndex,
|
||||
required this.isDelivered,
|
||||
required this.historyFuture,
|
||||
});
|
||||
|
||||
final List<JobStep> steps;
|
||||
final int currentStepIndex;
|
||||
final bool isDelivered;
|
||||
final Future<List<JobHistoryEntry>> historyFuture;
|
||||
|
||||
@override
|
||||
@@ -542,7 +621,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
final Map<JobStep, int> revisionCounts = {};
|
||||
final Map<JobStep, List<JobHistoryEntry>> notesByStep = {};
|
||||
for (final e in history) {
|
||||
if (e.action == JobHistoryAction.revisionRequested && e.step != null) {
|
||||
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) {
|
||||
@@ -554,8 +634,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
children: steps.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final step = entry.value;
|
||||
final isCompleted = index < currentStepIndex;
|
||||
final isCurrent = index == currentStepIndex;
|
||||
final isCompleted = isDelivered || index < currentStepIndex;
|
||||
final isCurrent = !isDelivered && index == currentStepIndex;
|
||||
final revCount = revisionCounts[step] ?? 0;
|
||||
final stepNotes = notesByStep[step] ?? const <JobHistoryEntry>[];
|
||||
|
||||
@@ -582,7 +662,7 @@ class _StepperWidget extends StatelessWidget {
|
||||
Container(
|
||||
width: 2,
|
||||
height: 44,
|
||||
color: index < currentStepIndex
|
||||
color: isDelivered || index < currentStepIndex
|
||||
? AppColors.success.withValues(alpha: 0.35)
|
||||
: AppColors.border,
|
||||
),
|
||||
@@ -642,7 +722,8 @@ class _StepperWidget extends StatelessWidget {
|
||||
),
|
||||
if (stepNotes.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...stepNotes.map((entry) => _StepNoteCard(entry: entry)),
|
||||
...stepNotes
|
||||
.map((entry) => _StepNoteCard(entry: entry)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -700,6 +781,7 @@ class _StepNoteCard extends StatelessWidget {
|
||||
String _label(JobHistoryAction action) {
|
||||
return switch (action) {
|
||||
JobHistoryAction.revisionRequested => 'Revizyon Notu',
|
||||
JobHistoryAction.stepCompleted => 'İç Adım Notu',
|
||||
JobHistoryAction.handedToClinic => 'Laboratuvar Notu',
|
||||
JobHistoryAction.approved => 'Onay Notu',
|
||||
JobHistoryAction.delivered => 'Teslim Notu',
|
||||
@@ -745,8 +827,8 @@ class _InfoRow extends StatelessWidget {
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.textSecondary),
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
Reference in New Issue
Block a user