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
+397 -87
View File
@@ -1,20 +1,28 @@
enum JobStatus { pending, inProgress, sent, delivered, cancelled }
enum JobStep {
olcu, // legacy fallback
altYapiProva, // sabit seramik/metal — alt yapı (coping)
ustYapiProva, // sabit seramik — bisküvi prova
mumProva, // hareketli protez — mum prova
dislerProva, // hareketli protez — dişler prova
dayanakProva, // implant — dayanak prova
kronProva, // implant — kron prova
cilaBitim, // son cila / bitim (her şablonda son adım)
olcu, // legacy fallback
olcuKontrol, // geleneksel/arjinat ölçü veya model kontrolü
dijitalTasarim, // dijital tasarım klinik onayı
modelHazirlik, // internal hazırlık/model döküm
altYapiProva, // sabit seramik/metal — alt yapı (coping)
ustYapiProva, // sabit seramik — bisküvi prova
mumProva, // hareketli protez — mum prova
dislerProva, // hareketli protez — dişler prova
dayanakProva, // implant — dayanak prova
kronProva, // implant — kron prova
fotografOnay, // foto/mockup ile klinik onayı
kaliteKontrol, // internal kalite kontrol
teslimOncesiKontrol, // internal final kontrol
cilaBitim, // son cila / bitim (her şablonda son adım)
}
enum JobLocation { atClinic, atLab }
enum JobWorkflowType { arjinat, geleneksel, dijital }
enum ProstheticFamily { sabit, implant, hareketli, gecici, ozel }
enum ProstheticType {
metalPorselen,
zirkonyum,
@@ -30,18 +38,18 @@ enum ProstheticType {
extension JobStatusExt on JobStatus {
String get label => switch (this) {
JobStatus.pending => 'Bekliyor',
JobStatus.pending => 'Bekliyor',
JobStatus.inProgress => 'İşlemde',
JobStatus.sent => 'Gönderildi',
JobStatus.delivered => 'Teslim Alındı',
JobStatus.cancelled => 'İptal',
JobStatus.sent => 'Gönderildi',
JobStatus.delivered => 'Teslim Alındı',
JobStatus.cancelled => 'İptal',
};
String get value => switch (this) {
JobStatus.pending => 'pending',
JobStatus.pending => 'pending',
JobStatus.inProgress => 'in_progress',
JobStatus.sent => 'sent',
JobStatus.delivered => 'delivered',
JobStatus.cancelled => 'cancelled',
JobStatus.sent => 'sent',
JobStatus.delivered => 'delivered',
JobStatus.cancelled => 'cancelled',
};
}
@@ -49,37 +57,74 @@ extension JobStatusExt on JobStatus {
extension JobStepExt on JobStep {
String get label => switch (this) {
JobStep.olcu => 'Ölçü',
JobStep.olcu => 'Ölçü',
JobStep.olcuKontrol => 'Ölçü / Model Kontrol',
JobStep.dijitalTasarim => 'Dijital Tasarım Onayı',
JobStep.modelHazirlik => 'Model Hazırlık',
JobStep.altYapiProva => 'Alt Yapı Prova',
JobStep.ustYapiProva => 'Üst Yapı Prova',
JobStep.mumProva => 'Mum Prova',
JobStep.dislerProva => 'Dişler Prova',
JobStep.mumProva => 'Mum Prova',
JobStep.dislerProva => 'Dişler Prova',
JobStep.dayanakProva => 'Dayanak Prova',
JobStep.kronProva => 'Kron Prova',
JobStep.cilaBitim => 'Cila / Bitim',
JobStep.kronProva => 'Kron Prova',
JobStep.fotografOnay => 'Fotoğraf / Mockup Onayı',
JobStep.kaliteKontrol => 'Kalite Kontrol',
JobStep.teslimOncesiKontrol => 'Teslim Öncesi Kontrol',
JobStep.cilaBitim => 'Cila / Bitim',
};
/// One-liner shown under the step on the stepper
String get description => switch (this) {
JobStep.olcu => 'İlk ölçü alındı',
JobStep.olcu => 'İlk ölçü alındı',
JobStep.olcuKontrol => 'Ölçü, model veya kapanış kaydı kontrolü',
JobStep.dijitalTasarim =>
'Dijital tasarım ekranı veya mockup klinik onayı',
JobStep.modelHazirlik =>
'Model hazırlık, döküm veya artikülatör aşaması',
JobStep.altYapiProva => 'Metal/zirkonyum coping klinik onayı',
JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı',
JobStep.mumProva => 'Mum prova klinik onayı',
JobStep.dislerProva => 'Diş dizimi klinik onayı',
JobStep.mumProva => 'Mum prova klinik onayı',
JobStep.dislerProva => 'Diş dizimi klinik onayı',
JobStep.dayanakProva => 'Dayanak klinik onayı',
JobStep.kronProva => 'Kron klinik onayı',
JobStep.cilaBitim => 'Son cila ve teslim hazırlığı',
JobStep.kronProva => 'Kron klinik onayı',
JobStep.fotografOnay => 'Fotoğraf veya mockup üzerinden klinik teyidi',
JobStep.kaliteKontrol => 'Laboratuvar iç kalite kontrol aşaması',
JobStep.teslimOncesiKontrol => 'Teslimat öncesi son iç kontrol',
JobStep.cilaBitim => 'Son cila ve teslim hazırlığı',
};
String get value => switch (this) {
JobStep.olcu => 'olcu',
JobStep.olcu => 'olcu',
JobStep.olcuKontrol => 'olcu_kontrol',
JobStep.dijitalTasarim => 'dijital_tasarim',
JobStep.modelHazirlik => 'model_hazirlik',
JobStep.altYapiProva => 'alt_yapi_prova',
JobStep.ustYapiProva => 'ust_yapi_prova',
JobStep.mumProva => 'mum_prova',
JobStep.dislerProva => 'disler_prova',
JobStep.mumProva => 'mum_prova',
JobStep.dislerProva => 'disler_prova',
JobStep.dayanakProva => 'dayanak_prova',
JobStep.kronProva => 'kron_prova',
JobStep.cilaBitim => 'cila_bitim',
JobStep.kronProva => 'kron_prova',
JobStep.fotografOnay => 'fotograf_onay',
JobStep.kaliteKontrol => 'kalite_kontrol',
JobStep.teslimOncesiKontrol => 'teslim_oncesi_kontrol',
JobStep.cilaBitim => 'cila_bitim',
};
bool get requiresClinicApproval => switch (this) {
JobStep.modelHazirlik ||
JobStep.kaliteKontrol ||
JobStep.teslimOncesiKontrol =>
false,
_ => true,
};
bool get isLabOptional => switch (this) {
JobStep.modelHazirlik ||
JobStep.fotografOnay ||
JobStep.kaliteKontrol ||
JobStep.teslimOncesiKontrol =>
true,
_ => false,
};
}
@@ -101,52 +146,277 @@ extension JobWorkflowTypeExt on JobWorkflowType {
extension ProstheticTypeExt on ProstheticType {
String get label => switch (this) {
ProstheticType.metalPorselen => 'Metal Porselen',
ProstheticType.zirkonyum => 'Zirkonyum',
ProstheticType.metalPorselen => 'Metal Porselen',
ProstheticType.zirkonyum => 'Zirkonyum',
ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum',
ProstheticType.gecici => 'Geçici',
ProstheticType.eMax => 'E-Max',
ProstheticType.tamProtez => 'Tam Protez',
ProstheticType.parsiyel => 'Parsiyel Protez',
ProstheticType.diger => 'Diğer',
ProstheticType.gecici => 'Geçici',
ProstheticType.eMax => 'E-Max',
ProstheticType.tamProtez => 'Tam Protez',
ProstheticType.parsiyel => 'Parsiyel Protez',
ProstheticType.diger => 'Diğer',
};
String get value => switch (this) {
ProstheticType.metalPorselen => 'metal_porselen',
ProstheticType.zirkonyum => 'zirkonyum',
ProstheticType.metalPorselen => 'metal_porselen',
ProstheticType.zirkonyum => 'zirkonyum',
ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum',
ProstheticType.gecici => 'gecici',
ProstheticType.eMax => 'e_max',
ProstheticType.tamProtez => 'tam_protez',
ProstheticType.parsiyel => 'parsiyel',
ProstheticType.diger => 'diger',
ProstheticType.gecici => 'gecici',
ProstheticType.eMax => 'e_max',
ProstheticType.tamProtez => 'tam_protez',
ProstheticType.parsiyel => 'parsiyel',
ProstheticType.diger => 'diger',
};
ProstheticFamily get family => switch (this) {
ProstheticType.metalPorselen ||
ProstheticType.zirkonyum ||
ProstheticType.eMax =>
ProstheticFamily.sabit,
ProstheticType.implantUstuZirkonyum => ProstheticFamily.implant,
ProstheticType.tamProtez ||
ProstheticType.parsiyel =>
ProstheticFamily.hareketli,
ProstheticType.gecici => ProstheticFamily.gecici,
ProstheticType.diger => ProstheticFamily.ozel,
};
}
// ── Step template ─────────────────────────────────────────────────────────────
/// Returns the ordered step list for a given prosthetic type + prova flag.
List<JobStep> jobStepTemplate(ProstheticType type, bool provaRequired) {
if (!provaRequired) return const [JobStep.cilaBitim];
return switch (type) {
// Sabit seramik: alt yapı coping + bisküvi prova + cila
ProstheticType.metalPorselen ||
ProstheticType.zirkonyum ||
ProstheticType.eMax =>
const [JobStep.altYapiProva, JobStep.ustYapiProva, JobStep.cilaBitim],
// İmplant: dayanak + kron prova + cila
ProstheticType.implantUstuZirkonyum =>
const [JobStep.dayanakProva, JobStep.kronProva, JobStep.cilaBitim],
// Hareketli protez: mum + dişler prova + cila
ProstheticType.tamProtez ||
ProstheticType.parsiyel =>
const [JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim],
// Geçici: sadece cila (prova gereksiz)
ProstheticType.gecici =>
const [JobStep.cilaBitim],
// Diğer: tek ara prova + cila
_ =>
const [JobStep.altYapiProva, JobStep.cilaBitim],
class JobWorkflowPreset {
const JobWorkflowPreset({
required this.title,
required this.summary,
required this.steps,
});
final String title;
final String summary;
final List<JobStep> steps;
}
const optionalLabStepCatalog = <JobStep>[
JobStep.modelHazirlik,
JobStep.fotografOnay,
JobStep.kaliteKontrol,
JobStep.teslimOncesiKontrol,
];
bool isOptionalStepApplicable(
JobStep step, {
required JobWorkflowType workflowType,
required ProstheticFamily family,
required bool provaRequired,
}) {
switch (step) {
case JobStep.modelHazirlik:
return workflowType != JobWorkflowType.dijital &&
family != ProstheticFamily.gecici;
case JobStep.fotografOnay:
return family != ProstheticFamily.hareketli || provaRequired;
case JobStep.kaliteKontrol:
case JobStep.teslimOncesiKontrol:
return true;
default:
return false;
}
}
List<JobStep> mergeOptionalLabSteps({
required List<JobStep> baseSteps,
required List<JobStep> optionalSteps,
required JobWorkflowType workflowType,
required ProstheticFamily family,
required bool provaRequired,
}) {
final merged = List<JobStep>.from(baseSteps);
for (final step in optionalSteps) {
if (!step.isLabOptional ||
merged.contains(step) ||
!isOptionalStepApplicable(
step,
workflowType: workflowType,
family: family,
provaRequired: provaRequired,
)) {
continue;
}
final finalIndex = merged.indexOf(JobStep.cilaBitim);
switch (step) {
case JobStep.modelHazirlik:
final afterControl = merged.contains(JobStep.olcuKontrol)
? merged.indexOf(JobStep.olcuKontrol) + 1
: 0;
merged.insert(afterControl, step);
case JobStep.fotografOnay:
case JobStep.kaliteKontrol:
case JobStep.teslimOncesiKontrol:
merged.insert(finalIndex.clamp(0, merged.length), step);
default:
break;
}
}
return merged;
}
JobWorkflowPreset buildJobWorkflowPreset({
required ProstheticType prostheticType,
JobWorkflowType? workflowType,
required bool provaRequired,
List<JobStep> optionalSteps = const [],
}) {
final normalizedWorkflow = workflowType ?? JobWorkflowType.geleneksel;
final family = prostheticType.family;
List<JobStep> steps;
switch (normalizedWorkflow) {
case JobWorkflowType.dijital:
steps = switch (family) {
ProstheticFamily.sabit => provaRequired
? const [
JobStep.dijitalTasarim,
JobStep.ustYapiProva,
JobStep.cilaBitim,
]
: const [
JobStep.dijitalTasarim,
JobStep.cilaBitim,
],
ProstheticFamily.implant => provaRequired
? const [
JobStep.dijitalTasarim,
JobStep.dayanakProva,
JobStep.kronProva,
JobStep.cilaBitim,
]
: const [
JobStep.dijitalTasarim,
JobStep.cilaBitim,
],
ProstheticFamily.hareketli => provaRequired
? const [
JobStep.dijitalTasarim,
JobStep.mumProva,
JobStep.dislerProva,
JobStep.cilaBitim,
]
: const [
JobStep.dijitalTasarim,
JobStep.cilaBitim,
],
ProstheticFamily.gecici => const [
JobStep.dijitalTasarim,
JobStep.cilaBitim,
],
ProstheticFamily.ozel => provaRequired
? const [
JobStep.dijitalTasarim,
JobStep.altYapiProva,
JobStep.cilaBitim,
]
: const [
JobStep.dijitalTasarim,
JobStep.cilaBitim,
],
};
case JobWorkflowType.arjinat:
case JobWorkflowType.geleneksel:
steps = switch (family) {
ProstheticFamily.sabit => provaRequired
? const [
JobStep.olcuKontrol,
JobStep.altYapiProva,
JobStep.ustYapiProva,
JobStep.cilaBitim,
]
: const [
JobStep.olcuKontrol,
JobStep.cilaBitim,
],
ProstheticFamily.implant => provaRequired
? const [
JobStep.olcuKontrol,
JobStep.dayanakProva,
JobStep.kronProva,
JobStep.cilaBitim,
]
: const [
JobStep.olcuKontrol,
JobStep.cilaBitim,
],
ProstheticFamily.hareketli => provaRequired
? const [
JobStep.olcuKontrol,
JobStep.mumProva,
JobStep.dislerProva,
JobStep.cilaBitim,
]
: const [
JobStep.olcuKontrol,
JobStep.cilaBitim,
],
ProstheticFamily.gecici => const [
JobStep.olcuKontrol,
JobStep.cilaBitim,
],
ProstheticFamily.ozel => provaRequired
? const [
JobStep.olcuKontrol,
JobStep.altYapiProva,
JobStep.cilaBitim,
]
: const [
JobStep.olcuKontrol,
JobStep.cilaBitim,
],
};
}
final title =
'${normalizedWorkflow.label} · ${provaRequired ? "Provalı" : "Provasız"}';
final summary = switch ((normalizedWorkflow, family, provaRequired)) {
(JobWorkflowType.dijital, ProstheticFamily.sabit, false) =>
'Dijital tasarım onayı sonrası üretim ve teslim odaklı akış.',
(JobWorkflowType.dijital, _, false) =>
'Fiziksel prova azaltılmış, dijital onay ve hızlı teslim akışı.',
(JobWorkflowType.dijital, _, true) =>
'Dijital hazırlık üzerine klinik prova kapıları eklenmiş hibrit akış.',
(JobWorkflowType.arjinat, _, false) =>
'Ölçü/model kontrolü sonrası doğrudan üretim ve teslim akışı.',
(JobWorkflowType.arjinat, _, true) =>
'Arjinat ölçüden gelen işlerde klinik prova kapılarıyla ilerleyen akış.',
(JobWorkflowType.geleneksel, _, false) =>
'Klasik ölçüden gelen, minimum temaslı ve hızlı teslim akışı.',
(JobWorkflowType.geleneksel, _, true) =>
'Klasik laboratuvar süreçlerine uygun, prova bazlı aşamalı akış.',
};
return JobWorkflowPreset(
title: title,
summary: summary,
steps: mergeOptionalLabSteps(
baseSteps: steps,
optionalSteps: optionalSteps,
workflowType: normalizedWorkflow,
family: family,
provaRequired: provaRequired,
),
);
}
/// Returns the ordered clinic-facing approval steps for a job.
List<JobStep> jobStepTemplate(
ProstheticType type,
bool provaRequired, {
JobWorkflowType? workflowType,
List<JobStep> optionalSteps = const [],
}) {
return buildJobWorkflowPreset(
prostheticType: type,
workflowType: workflowType,
provaRequired: provaRequired,
optionalSteps: optionalSteps,
).steps;
}
// ── Job ───────────────────────────────────────────────────────────────────────
@@ -178,6 +448,7 @@ class Job {
this.labName,
this.attachments = const [],
this.provaRequired = true,
this.workflowSteps = const [],
});
final String id;
@@ -203,6 +474,7 @@ class Job {
final DateTime dateCreated;
final List<String> attachments;
final bool provaRequired;
final List<JobStep> workflowSteps;
// Denormalized from relation joins — list views only
final String? clinicName;
@@ -236,20 +508,45 @@ class Job {
price: price,
currency: currency,
status: status ?? this.status,
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep),
currentStep:
clearCurrentStep ? null : (currentStep ?? this.currentStep),
location: location ?? this.location,
workflowType: workflowType ?? this.workflowType,
dueDate: dueDate,
dateCreated: dateCreated,
attachments: attachments,
provaRequired: provaRequired,
workflowSteps: workflowSteps,
clinicName: clinicName ?? this.clinicName,
labName: labName ?? this.labName,
);
// ── Step helpers ──────────────────────────────────────────────────────────
List<JobStep> get stepTemplate => jobStepTemplate(prostheticType, provaRequired);
List<JobStep> get stepTemplate => workflowSteps.isNotEmpty
? workflowSteps
: jobStepTemplate(
prostheticType,
provaRequired,
workflowType: workflowType,
optionalSteps:
workflowSteps.where((step) => step.isLabOptional).toList(),
);
JobWorkflowPreset get workflowPreset {
final preset = buildJobWorkflowPreset(
prostheticType: prostheticType,
workflowType: workflowType,
provaRequired: provaRequired,
optionalSteps: workflowSteps.where((step) => step.isLabOptional).toList(),
);
if (workflowSteps.isEmpty) return preset;
return JobWorkflowPreset(
title: preset.title,
summary: preset.summary,
steps: workflowSteps,
);
}
bool get isLastStep =>
currentStep != null && currentStep == stepTemplate.last;
@@ -310,28 +607,41 @@ class Job {
? (j['attachments'] as List).map((e) => e.toString()).toList()
: [],
provaRequired: (j['prova_required'] as bool?) ?? true,
workflowSteps: j['workflow_steps'] is List
? (j['workflow_steps'] as List)
.map((e) => _parseStep(e.toString()))
.toList()
: const [],
);
}
static JobStatus _parseStatus(String s) => switch (s) {
'in_progress' => JobStatus.inProgress,
'sent' => JobStatus.sent,
'delivered' => JobStatus.delivered,
'cancelled' => JobStatus.cancelled,
_ => JobStatus.pending,
'sent' => JobStatus.sent,
'delivered' => JobStatus.delivered,
'cancelled' => JobStatus.cancelled,
_ => JobStatus.pending,
};
static JobStep _parseStep(String s) => switch (s) {
'olcu_kontrol' => JobStep.olcuKontrol,
'dijital_tasarim' => JobStep.dijitalTasarim,
'model_hazirlik' => JobStep.modelHazirlik,
'alt_yapi_prova' => JobStep.altYapiProva,
'ust_yapi_prova' => JobStep.ustYapiProva,
'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva,
'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu,
'mum_prova' => JobStep.mumProva,
'disler_prova' => JobStep.dislerProva,
'dayanak_prova' => JobStep.dayanakProva,
'kron_prova' => JobStep.kronProva,
'fotograf_onay' => JobStep.fotografOnay,
'kalite_kontrol' => JobStep.kaliteKontrol,
'teslim_oncesi_kontrol' => JobStep.teslimOncesiKontrol,
'cila_bitim' => JobStep.cilaBitim,
_ => JobStep.olcu,
};
static JobStep parseStepValue(String s) => _parseStep(s);
static JobWorkflowType _parseWorkflowType(String s) => switch (s) {
'arjinat' => JobWorkflowType.arjinat,
'dijital' => JobWorkflowType.dijital,
@@ -352,13 +662,13 @@ class Job {
}
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,
'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,
};
}