Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system - Clinic & lab dashboards, job tracking, patient management - Product catalog, finance tracking, multi-language support - AI assistant integration, realtime notifications - Windows installer (Inno Setup) included - Developed by kovakyazilim.com
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import 'job.dart';
|
||||
|
||||
enum DiscountType { percentage, fixed }
|
||||
|
||||
extension DiscountTypeX on DiscountType {
|
||||
String get value => name;
|
||||
String get label => this == DiscountType.percentage ? 'Yüzde (%)' : 'Sabit Tutar (TL)';
|
||||
}
|
||||
|
||||
class ClinicDiscount {
|
||||
const ClinicDiscount({
|
||||
required this.id,
|
||||
required this.labTenantId,
|
||||
this.clinicTenantId,
|
||||
this.clinicName,
|
||||
this.prostheticType,
|
||||
required this.discountType,
|
||||
required this.discountValue,
|
||||
this.minQuantity = 0,
|
||||
required this.isActive,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String labTenantId;
|
||||
final String? clinicTenantId; // null = tüm klinikler
|
||||
final String? clinicName;
|
||||
final String? prostheticType; // null = tüm ürün tipleri
|
||||
final DiscountType discountType;
|
||||
final double discountValue;
|
||||
final int minQuantity; // 0 = minimum yok
|
||||
final bool isActive;
|
||||
final String? notes;
|
||||
|
||||
bool get appliesToAll => clinicTenantId == null || clinicTenantId!.isEmpty;
|
||||
bool get appliesToAllTypes => prostheticType == null || prostheticType!.isEmpty;
|
||||
|
||||
String get displayValue => discountType == DiscountType.percentage
|
||||
? '%${discountValue.toStringAsFixed(discountValue % 1 == 0 ? 0 : 1)}'
|
||||
: '${discountValue.toStringAsFixed(2)} TL';
|
||||
|
||||
String get prostheticLabel {
|
||||
if (appliesToAllTypes) return 'Tüm Türler';
|
||||
return ProstheticType.values
|
||||
.firstWhere((e) => e.value == prostheticType,
|
||||
orElse: () => ProstheticType.diger)
|
||||
.label;
|
||||
}
|
||||
|
||||
factory ClinicDiscount.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
final dt = j['discount_type'] as String? ?? 'percentage';
|
||||
final pt = j['prosthetic_type'] as String?;
|
||||
return ClinicDiscount(
|
||||
id: j['id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
clinicTenantId: j['clinic_tenant_id'] as String?,
|
||||
clinicName: clinicExp?['company_name'] as String?,
|
||||
prostheticType: (pt == null || pt.isEmpty) ? null : pt,
|
||||
discountType: dt == 'fixed' ? DiscountType.fixed : DiscountType.percentage,
|
||||
discountValue: (j['discount_value'] as num?)?.toDouble() ?? 0,
|
||||
minQuantity: (j['min_quantity'] as num?)?.toInt() ?? 0,
|
||||
isActive: j['is_active'] as bool? ?? true,
|
||||
notes: j['notes'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
enum ConnectionStatus { pending, approved, rejected }
|
||||
|
||||
extension ConnectionStatusX on ConnectionStatus {
|
||||
String get value => name;
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ConnectionStatus.pending:
|
||||
return 'Bekliyor';
|
||||
case ConnectionStatus.approved:
|
||||
return 'Onaylı';
|
||||
case ConnectionStatus.rejected:
|
||||
return 'Reddedildi';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Connection {
|
||||
const Connection({
|
||||
required this.id,
|
||||
required this.clinicTenantId,
|
||||
required this.labTenantId,
|
||||
required this.status,
|
||||
this.clinicName,
|
||||
this.labName,
|
||||
this.dateCreated,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String clinicTenantId;
|
||||
final String labTenantId;
|
||||
final ConnectionStatus status;
|
||||
final String? clinicName;
|
||||
final String? labName;
|
||||
final String? dateCreated;
|
||||
|
||||
factory Connection.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
|
||||
return Connection(
|
||||
id: j['id'] as String,
|
||||
clinicTenantId: j['clinic_tenant_id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
status: ConnectionStatus.values.firstWhere(
|
||||
(e) => e.value == j['status'],
|
||||
orElse: () => ConnectionStatus.pending,
|
||||
),
|
||||
clinicName: clinicExp?['company_name'] as String?,
|
||||
labName: labExp?['company_name'] as String?,
|
||||
dateCreated: j['created'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
enum FinanceType { receivable, payable }
|
||||
|
||||
extension FinanceTypeX on FinanceType {
|
||||
String get value => name;
|
||||
String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç';
|
||||
}
|
||||
|
||||
enum FinanceStatus { pending, paid }
|
||||
|
||||
extension FinanceStatusX on FinanceStatus {
|
||||
String get value => name;
|
||||
String get label => this == FinanceStatus.pending ? 'Bekliyor' : 'Ödendi';
|
||||
}
|
||||
|
||||
class FinanceEntry {
|
||||
const FinanceEntry({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.jobId,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.status,
|
||||
this.paidAt,
|
||||
this.counterpartyName,
|
||||
this.patientCode,
|
||||
this.dateCreated,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
final String jobId;
|
||||
final FinanceType type;
|
||||
final double amount;
|
||||
final String currency;
|
||||
final FinanceStatus status;
|
||||
final String? paidAt;
|
||||
final String? counterpartyName;
|
||||
final String? patientCode;
|
||||
final String? dateCreated;
|
||||
|
||||
factory FinanceEntry.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final jobExp = expand?['job_id'] as Map<String, dynamic>?;
|
||||
String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; }
|
||||
return FinanceEntry(
|
||||
id: j['id'] as String,
|
||||
tenantId: j['tenant_id'] as String,
|
||||
jobId: j['job_id'] as String,
|
||||
type: FinanceType.values.firstWhere((e) => e.value == j['type'],
|
||||
orElse: () => FinanceType.receivable),
|
||||
amount: (j['amount'] as num).toDouble(),
|
||||
currency: j['currency'] as String? ?? 'TRY',
|
||||
status: FinanceStatus.values.firstWhere((e) => e.value == j['status'],
|
||||
orElse: () => FinanceStatus.pending),
|
||||
paidAt: _str(j['paid_at']),
|
||||
counterpartyName: _str(j['counterparty_name']),
|
||||
patientCode: jobExp?['patient_code'] as String?,
|
||||
dateCreated: j['created'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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)
|
||||
}
|
||||
|
||||
enum JobLocation { atClinic, atLab }
|
||||
|
||||
enum ProstheticType {
|
||||
metalPorselen,
|
||||
zirkonyum,
|
||||
implantUstuZirkonyum,
|
||||
gecici,
|
||||
eMax,
|
||||
tamProtez,
|
||||
parsiyel,
|
||||
diger,
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
extension JobStatusExt on JobStatus {
|
||||
String get label => switch (this) {
|
||||
JobStatus.pending => 'Bekliyor',
|
||||
JobStatus.inProgress => 'İşlemde',
|
||||
JobStatus.sent => 'Gönderildi',
|
||||
JobStatus.delivered => 'Teslim Alındı',
|
||||
JobStatus.cancelled => 'İptal',
|
||||
};
|
||||
String get value => switch (this) {
|
||||
JobStatus.pending => 'pending',
|
||||
JobStatus.inProgress => 'in_progress',
|
||||
JobStatus.sent => 'sent',
|
||||
JobStatus.delivered => 'delivered',
|
||||
JobStatus.cancelled => 'cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
extension JobStepExt on JobStep {
|
||||
String get label => switch (this) {
|
||||
JobStep.olcu => 'Ölçü',
|
||||
JobStep.altYapiProva => 'Alt Yapı Prova',
|
||||
JobStep.ustYapiProva => 'Üst Yapı Prova',
|
||||
JobStep.mumProva => 'Mum Prova',
|
||||
JobStep.dislerProva => 'Dişler Prova',
|
||||
JobStep.dayanakProva => 'Dayanak Prova',
|
||||
JobStep.kronProva => 'Kron Prova',
|
||||
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.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.dayanakProva => 'Dayanak klinik onayı',
|
||||
JobStep.kronProva => 'Kron klinik onayı',
|
||||
JobStep.cilaBitim => 'Son cila ve teslim hazırlığı',
|
||||
};
|
||||
|
||||
String get value => switch (this) {
|
||||
JobStep.olcu => 'olcu',
|
||||
JobStep.altYapiProva => 'alt_yapi_prova',
|
||||
JobStep.ustYapiProva => 'ust_yapi_prova',
|
||||
JobStep.mumProva => 'mum_prova',
|
||||
JobStep.dislerProva => 'disler_prova',
|
||||
JobStep.dayanakProva => 'dayanak_prova',
|
||||
JobStep.kronProva => 'kron_prova',
|
||||
JobStep.cilaBitim => 'cila_bitim',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Prosthetic type ───────────────────────────────────────────────────────────
|
||||
|
||||
extension ProstheticTypeExt on ProstheticType {
|
||||
String get label => switch (this) {
|
||||
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',
|
||||
};
|
||||
String get value => switch (this) {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Job ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Job {
|
||||
const Job({
|
||||
required this.id,
|
||||
required this.clinicTenantId,
|
||||
required this.labTenantId,
|
||||
required this.patientCode,
|
||||
required this.prostheticType,
|
||||
required this.memberCount,
|
||||
required this.status,
|
||||
required this.dateCreated,
|
||||
this.patientId,
|
||||
this.prostheticId,
|
||||
this.teeth = const [],
|
||||
this.color,
|
||||
this.description,
|
||||
this.price,
|
||||
this.currency,
|
||||
this.currentStep,
|
||||
this.location = JobLocation.atClinic,
|
||||
this.dueDate,
|
||||
this.clinicName,
|
||||
this.labName,
|
||||
this.attachments = const [],
|
||||
this.provaRequired = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String clinicTenantId;
|
||||
final String labTenantId;
|
||||
final String? patientId;
|
||||
final String patientCode;
|
||||
final String? prostheticId;
|
||||
final ProstheticType prostheticType;
|
||||
final int memberCount;
|
||||
final List<String> teeth;
|
||||
final String? color;
|
||||
final String? description;
|
||||
final double? price;
|
||||
final String? currency;
|
||||
final JobStatus status;
|
||||
final JobStep? currentStep;
|
||||
final JobLocation location;
|
||||
final DateTime? dueDate;
|
||||
final DateTime dateCreated;
|
||||
final List<String> attachments;
|
||||
final bool provaRequired;
|
||||
|
||||
// Denormalized from relation joins — list views only
|
||||
final String? clinicName;
|
||||
final String? labName;
|
||||
|
||||
// ── copyWith ──────────────────────────────────────────────────────────────
|
||||
|
||||
Job copyWith({
|
||||
JobStatus? status,
|
||||
JobStep? currentStep,
|
||||
JobLocation? location,
|
||||
String? clinicName,
|
||||
String? labName,
|
||||
bool clearCurrentStep = false,
|
||||
}) =>
|
||||
Job(
|
||||
id: id,
|
||||
clinicTenantId: clinicTenantId,
|
||||
labTenantId: labTenantId,
|
||||
patientId: patientId,
|
||||
patientCode: patientCode,
|
||||
prostheticId: prostheticId,
|
||||
prostheticType: prostheticType,
|
||||
memberCount: memberCount,
|
||||
teeth: teeth,
|
||||
color: color,
|
||||
description: description,
|
||||
price: price,
|
||||
currency: currency,
|
||||
status: status ?? this.status,
|
||||
currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep),
|
||||
location: location ?? this.location,
|
||||
dueDate: dueDate,
|
||||
dateCreated: dateCreated,
|
||||
attachments: attachments,
|
||||
provaRequired: provaRequired,
|
||||
clinicName: clinicName ?? this.clinicName,
|
||||
labName: labName ?? this.labName,
|
||||
);
|
||||
|
||||
// ── Step helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
List<JobStep> get stepTemplate => jobStepTemplate(prostheticType, provaRequired);
|
||||
|
||||
bool get isLastStep =>
|
||||
currentStep != null && currentStep == stepTemplate.last;
|
||||
|
||||
/// The next step after currentStep in this job's template, or null if done.
|
||||
JobStep? get nextStep {
|
||||
if (currentStep == null) return stepTemplate.firstOrNull;
|
||||
final idx = stepTemplate.indexOf(currentStep!);
|
||||
if (idx < 0 || idx >= stepTemplate.length - 1) return null;
|
||||
return stepTemplate[idx + 1];
|
||||
}
|
||||
|
||||
factory Job.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final clinicExp = expand?['clinic_tenant_id'] as Map<String, dynamic>?;
|
||||
final labExp = expand?['lab_tenant_id'] as Map<String, dynamic>?;
|
||||
String? str(dynamic v) {
|
||||
final s = v as String?;
|
||||
return (s == null || s.isEmpty) ? null : s;
|
||||
}
|
||||
|
||||
return Job(
|
||||
id: j['id'] as String,
|
||||
clinicTenantId: j['clinic_tenant_id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
patientId: str(j['patient_id']),
|
||||
patientCode: j['patient_code'] as String,
|
||||
prostheticId: str(j['prosthetic_id']),
|
||||
prostheticType: _parseProstheticType(j['prosthetic_type'] as String),
|
||||
memberCount: (j['member_count'] as num).toInt(),
|
||||
teeth: j['teeth'] is List
|
||||
? (j['teeth'] as List).map((e) => e.toString()).toList()
|
||||
: [],
|
||||
color: str(j['color']),
|
||||
description: str(j['description']),
|
||||
price: (j['price'] as num?)?.toDouble(),
|
||||
currency: str(j['currency']),
|
||||
status: _parseStatus(j['status'] as String),
|
||||
currentStep: str(j['current_step']) != null
|
||||
? _parseStep(j['current_step'] as String)
|
||||
: null,
|
||||
location:
|
||||
j['location'] == 'at_lab' ? JobLocation.atLab : JobLocation.atClinic,
|
||||
dueDate: str(j['due_date']) != null
|
||||
? DateTime.parse(j['due_date'] as String)
|
||||
: null,
|
||||
dateCreated: DateTime.parse(j['created'] as String),
|
||||
clinicName: clinicExp?['company_name'] as String?,
|
||||
labName: labExp?['company_name'] as String?,
|
||||
attachments: j['attachments'] is List
|
||||
? (j['attachments'] as List).map((e) => e.toString()).toList()
|
||||
: [],
|
||||
provaRequired: (j['prova_required'] as bool?) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
static JobStatus _parseStatus(String s) => switch (s) {
|
||||
'in_progress' => JobStatus.inProgress,
|
||||
'sent' => JobStatus.sent,
|
||||
'delivered' => JobStatus.delivered,
|
||||
'cancelled' => JobStatus.cancelled,
|
||||
_ => JobStatus.pending,
|
||||
};
|
||||
|
||||
static JobStep _parseStep(String s) => switch (s) {
|
||||
'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,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
enum JobFileKind { scan, image, document }
|
||||
|
||||
extension JobFileKindExt on JobFileKind {
|
||||
String get label => switch (this) {
|
||||
JobFileKind.scan => 'Tarama',
|
||||
JobFileKind.image => 'Görsel',
|
||||
JobFileKind.document => 'Belge',
|
||||
};
|
||||
String get value => switch (this) {
|
||||
JobFileKind.scan => 'scan',
|
||||
JobFileKind.image => 'image',
|
||||
JobFileKind.document => 'document',
|
||||
};
|
||||
|
||||
static JobFileKind fromValue(String s) => switch (s) {
|
||||
'image' => JobFileKind.image,
|
||||
'document' => JobFileKind.document,
|
||||
_ => JobFileKind.scan,
|
||||
};
|
||||
}
|
||||
|
||||
class JobFile {
|
||||
const JobFile({
|
||||
required this.id,
|
||||
required this.jobId,
|
||||
required this.clinicTenantId,
|
||||
required this.labTenantId,
|
||||
required this.uploadedBy,
|
||||
required this.kind,
|
||||
required this.fileName,
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.createdAt,
|
||||
required this.downloadUrl,
|
||||
this.mimeType,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String jobId;
|
||||
final String clinicTenantId;
|
||||
final String labTenantId;
|
||||
final String uploadedBy;
|
||||
final JobFileKind kind;
|
||||
final String fileName;
|
||||
final String name;
|
||||
final int size;
|
||||
final String? mimeType;
|
||||
final DateTime createdAt;
|
||||
final String downloadUrl;
|
||||
|
||||
String get sizeLabel {
|
||||
if (size < 1024) return '$size B';
|
||||
if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(size / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
factory JobFile.fromJson(Map<String, dynamic> j, String baseUrl) {
|
||||
String str(String key, [String fallback = '']) =>
|
||||
(j[key] as String?) ?? fallback;
|
||||
final id = str('id');
|
||||
final collectionId = str('collectionId', 'job_files');
|
||||
final fileName = str('file');
|
||||
final url = fileName.isNotEmpty
|
||||
? '$baseUrl/api/files/$collectionId/$id/$fileName'
|
||||
: '';
|
||||
final createdRaw = str('created');
|
||||
return JobFile(
|
||||
id: id,
|
||||
jobId: str('job_id'),
|
||||
clinicTenantId: str('clinic_tenant_id'),
|
||||
labTenantId: str('lab_tenant_id'),
|
||||
uploadedBy: str('uploaded_by'),
|
||||
kind: JobFileKindExt.fromValue(str('kind')),
|
||||
fileName: fileName,
|
||||
name: str('name'),
|
||||
size: (j['size'] as num?)?.toInt() ?? 0,
|
||||
mimeType: j['mime_type'] as String?,
|
||||
createdAt: createdRaw.isNotEmpty
|
||||
? DateTime.tryParse(createdRaw) ?? DateTime(2000)
|
||||
: DateTime(2000),
|
||||
downloadUrl: url,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
class Patient {
|
||||
const Patient({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.patientCode,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.birthDate,
|
||||
this.phone,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
final String patientCode;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? birthDate;
|
||||
final String? phone;
|
||||
final String? notes;
|
||||
|
||||
String get displayName {
|
||||
final parts = [firstName, lastName].where((s) => s != null && s.isNotEmpty);
|
||||
return parts.isEmpty ? patientCode : parts.join(' ');
|
||||
}
|
||||
|
||||
factory Patient.fromJson(Map<String, dynamic> j) => Patient(
|
||||
id: j['id'] as String,
|
||||
tenantId: j['tenant_id'] is Map
|
||||
? (j['tenant_id'] as Map)['id'] as String
|
||||
: j['tenant_id'] as String,
|
||||
patientCode: j['patient_code'] as String,
|
||||
firstName: j['first_name'] as String?,
|
||||
lastName: j['last_name'] as String?,
|
||||
birthDate: j['birth_date'] as String?,
|
||||
phone: j['phone'] as String?,
|
||||
notes: j['notes'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'tenant_id': tenantId,
|
||||
'patient_code': patientCode,
|
||||
if (firstName != null) 'first_name': firstName,
|
||||
if (lastName != null) 'last_name': lastName,
|
||||
if (birthDate != null) 'birth_date': birthDate,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (notes != null) 'notes': notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
class ProstheticProduct {
|
||||
const ProstheticProduct({
|
||||
required this.id,
|
||||
required this.labTenantId,
|
||||
required this.name,
|
||||
required this.prostheticType,
|
||||
this.unitPrice,
|
||||
this.currency,
|
||||
this.isActive = true,
|
||||
this.description,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String labTenantId;
|
||||
final String name;
|
||||
final String prostheticType;
|
||||
final double? unitPrice;
|
||||
final String? currency;
|
||||
final bool isActive;
|
||||
final String? description;
|
||||
|
||||
factory ProstheticProduct.fromJson(Map<String, dynamic> j) {
|
||||
String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; }
|
||||
return ProstheticProduct(
|
||||
id: j['id'] as String,
|
||||
labTenantId: j['lab_tenant_id'] as String,
|
||||
name: j['name'] as String,
|
||||
prostheticType: j['prosthetic_type'] as String,
|
||||
unitPrice: (j['unit_price'] as num?)?.toDouble(),
|
||||
currency: j['currency'] as String? ?? 'TRY',
|
||||
isActive: j['is_active'] as bool? ?? true,
|
||||
description: _str(j['description']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lab_tenant_id': labTenantId,
|
||||
'name': name,
|
||||
'prosthetic_type': prostheticType,
|
||||
if (unitPrice != null) 'unit_price': unitPrice,
|
||||
'currency': currency ?? 'TRY',
|
||||
'is_active': isActive,
|
||||
if (description != null) 'description': description,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
enum TenantKind { lab, clinic }
|
||||
|
||||
enum TenantRole {
|
||||
owner,
|
||||
admin,
|
||||
technician, // lab: işler + ürünler
|
||||
delivery, // lab: işler
|
||||
finance, // lab+clinic: finans
|
||||
doctor, // clinic: işler + hastalar
|
||||
member, // legacy — full access
|
||||
;
|
||||
|
||||
String get value => name;
|
||||
|
||||
String get label => switch (this) {
|
||||
TenantRole.owner => 'Sahibi',
|
||||
TenantRole.admin => 'Yönetici',
|
||||
TenantRole.technician => 'Teknisyen',
|
||||
TenantRole.delivery => 'Teslimat Elemanı',
|
||||
TenantRole.finance => 'Finans Elemanı',
|
||||
TenantRole.doctor => 'Hekim',
|
||||
TenantRole.member => 'Üye',
|
||||
};
|
||||
}
|
||||
|
||||
enum TenantPlan { starter, pro, enterprise }
|
||||
|
||||
class Tenant {
|
||||
const Tenant({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.memberNumber,
|
||||
required this.companyName,
|
||||
this.logo,
|
||||
this.defaultCurrency = 'TRY',
|
||||
this.status = 'active',
|
||||
this.plan,
|
||||
this.maxMembers,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final TenantKind kind;
|
||||
final String memberNumber;
|
||||
final String companyName;
|
||||
final String? logo;
|
||||
final String defaultCurrency;
|
||||
final String status;
|
||||
final TenantPlan? plan;
|
||||
final int? maxMembers;
|
||||
|
||||
bool get isLab => kind == TenantKind.lab;
|
||||
bool get isClinic => kind == TenantKind.clinic;
|
||||
|
||||
factory Tenant.fromJson(Map<String, dynamic> j) => Tenant(
|
||||
id: j['id'] as String,
|
||||
kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic,
|
||||
memberNumber: (j['member_number'] as String?) ?? '',
|
||||
companyName: j['company_name'] as String,
|
||||
logo: j['logo'] as String?,
|
||||
defaultCurrency: (j['default_currency'] as String?) ?? 'TRY',
|
||||
status: (j['status'] as String?) ?? 'active',
|
||||
plan: _parsePlan(j['plan'] as String?),
|
||||
maxMembers: (j['max_members'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
static TenantPlan? _parsePlan(String? p) => switch (p) {
|
||||
'starter' => TenantPlan.starter,
|
||||
'pro' => TenantPlan.pro,
|
||||
'enterprise' => TenantPlan.enterprise,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
class TenantMembership {
|
||||
const TenantMembership({
|
||||
required this.id,
|
||||
required this.tenant,
|
||||
required this.role,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final Tenant tenant;
|
||||
final TenantRole role;
|
||||
|
||||
// ── Access helpers ────────────────────────────────────────────────────────
|
||||
bool get isOwner => role == TenantRole.owner;
|
||||
bool get isAdmin => role == TenantRole.admin || role == TenantRole.owner;
|
||||
bool get canManageUsers => role == TenantRole.owner || role == TenantRole.admin;
|
||||
bool get canManageJobs => role != TenantRole.finance;
|
||||
bool get canManageFinance => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.finance || role == TenantRole.member;
|
||||
bool get canManageProducts => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.technician || role == TenantRole.member;
|
||||
bool get canViewPatients => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.doctor || role == TenantRole.member;
|
||||
bool get canManageConnections => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member;
|
||||
|
||||
// ── Fine-grained job actions ──────────────────────────────────────────────
|
||||
/// Can create new jobs (clinic side: owner/admin/doctor/member; not delivery/finance)
|
||||
bool get canCreateJobs => role != TenantRole.delivery && role != TenantRole.finance;
|
||||
|
||||
/// Can confirm physical delivery (delivery role + supervisors)
|
||||
bool get canDeliverJobs => role != TenantRole.finance;
|
||||
|
||||
/// Can cancel or fully manage job lifecycle (not delivery-only or finance)
|
||||
bool get canCancelJobs => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member || role == TenantRole.doctor;
|
||||
|
||||
/// Primary focus is delivery — restrict to delivery-relevant UI
|
||||
bool get isDeliveryOnly => role == TenantRole.delivery;
|
||||
|
||||
// ── Nav visibility ────────────────────────────────────────────────────────
|
||||
bool get showDashboard => true;
|
||||
bool get showJobs => canManageJobs;
|
||||
bool get showProducts => tenant.isLab && canManageProducts;
|
||||
bool get showPatients => tenant.isClinic && canViewPatients;
|
||||
bool get showConnections => canManageConnections;
|
||||
bool get showFinance => canManageFinance;
|
||||
|
||||
factory TenantMembership.fromJson(Map<String, dynamic> j) {
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final tenantData = expand?['tenant_id'] as Map<String, dynamic>?;
|
||||
return TenantMembership(
|
||||
id: j['id'] as String,
|
||||
tenant: Tenant.fromJson(tenantData!),
|
||||
role: parseRole(j['role'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
static TenantRole parseRole(String r) => switch (r) {
|
||||
'owner' => TenantRole.owner,
|
||||
'admin' => TenantRole.admin,
|
||||
'technician' => TenantRole.technician,
|
||||
'delivery' => TenantRole.delivery,
|
||||
'finance' => TenantRole.finance,
|
||||
'doctor' => TenantRole.doctor,
|
||||
_ => TenantRole.member,
|
||||
};
|
||||
|
||||
String get roleLabel => switch (role) {
|
||||
TenantRole.owner => 'Sahibi',
|
||||
TenantRole.admin => 'Yönetici',
|
||||
TenantRole.technician => 'Teknisyen',
|
||||
TenantRole.delivery => 'Teslimat Elemanı',
|
||||
TenantRole.finance => 'Finans Elemanı',
|
||||
TenantRole.doctor => 'Hekim',
|
||||
TenantRole.member => 'Üye',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'tenant.dart';
|
||||
|
||||
class TenantInvite {
|
||||
const TenantInvite({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.email,
|
||||
required this.jobRole,
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.status,
|
||||
required this.invitedById,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
final String email;
|
||||
final TenantRole jobRole;
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final String status; // pending | accepted | expired
|
||||
final String invitedById;
|
||||
|
||||
bool get isPending => status == 'pending';
|
||||
bool get isExpired => status == 'expired' || expiresAt.isBefore(DateTime.now());
|
||||
|
||||
factory TenantInvite.fromJson(Map<String, dynamic> j) => TenantInvite(
|
||||
id: j['id'] as String,
|
||||
tenantId: j['tenant_id'] as String,
|
||||
email: j['email'] as String,
|
||||
jobRole: TenantMembership.parseRole(j['job_role'] as String),
|
||||
token: j['token'] as String,
|
||||
expiresAt: DateTime.parse(j['expires_at'] as String),
|
||||
status: j['status'] as String,
|
||||
invitedById: j['invited_by'] as String,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
class UserProfile {
|
||||
const UserProfile({
|
||||
required this.id,
|
||||
required this.email,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.preferredLanguage,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String email;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? preferredLanguage;
|
||||
|
||||
String get displayName =>
|
||||
[firstName, lastName].where((s) => s != null && s.isNotEmpty).join(' ');
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
|
||||
id: j['id'] as String,
|
||||
email: j['email'] as String,
|
||||
firstName: _str(j['first_name']),
|
||||
lastName: _str(j['last_name']),
|
||||
preferredLanguage: _str(j['preferred_language']),
|
||||
);
|
||||
|
||||
static String? _str(dynamic v) {
|
||||
final s = v as String?;
|
||||
return (s == null || s.isEmpty) ? null : s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user