Add pricing entry flow and platform admin foundations

This commit is contained in:
egecankomur
2026-06-20 18:24:40 +03:00
parent 1d36ccdf30
commit ac42681f7e
44 changed files with 6567 additions and 1419 deletions
@@ -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(