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:
Emre Emir
2026-06-11 15:57:31 +03:00
commit 8bbc9dbff2
226 changed files with 31308 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
import 'package:pocketbase/pocketbase.dart';
import '../../core/api/pocketbase_client.dart';
import '../../models/tenant.dart';
// ── Value objects ─────────────────────────────────────────────────────────────
const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara'];
class MonthlyCount {
const MonthlyCount({required this.year, required this.month, required this.count});
final int year, month, count;
String get label => _monthLabels[month - 1];
}
class MonthlyAmount {
const MonthlyAmount({required this.year, required this.month, required this.amount});
final int year, month;
final double amount;
String get label => _monthLabels[month - 1];
}
class CounterpartStat {
const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue});
final String name;
final int jobCount;
final double pendingRevenue, paidRevenue;
double get totalRevenue => pendingRevenue + paidRevenue;
}
class ActivityItem {
const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note});
final String jobId, action;
final String? patientCode, note;
final DateTime createdAt;
String get actionLabel => switch (action) {
'accepted' => 'İş kabul edildi',
'handed_to_clinic' => 'Provaya gönderildi',
'approved' => 'Onaylandı',
'revision_requested' => 'Revizyon istendi',
'delivered' => 'Teslim edildi',
'cancelled' => 'İptal edildi',
_ => action,
};
bool get isNegative => action == 'revision_requested' || action == 'cancelled';
bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted';
}
// ── Aggregated metrics ────────────────────────────────────────────────────────
class ReportMetrics {
const ReportMetrics({
required this.activeJobs,
required this.completedThisMonth,
required this.overdueJobs,
required this.revisionRate,
required this.avgCompletionDays,
required this.totalRevenue,
required this.pendingRevenue,
required this.currency,
required this.jobsByStatus,
required this.monthlyCounts,
required this.monthlyRevenue,
required this.byProstheticType,
required this.counterpartStats,
required this.recentActivity,
});
final int activeJobs;
final int completedThisMonth;
final int overdueJobs;
final double revisionRate; // 0-100
final double avgCompletionDays;
final double totalRevenue;
final double pendingRevenue;
final String currency;
final Map<String, int> jobsByStatus;
final List<MonthlyCount> monthlyCounts;
final List<MonthlyAmount> monthlyRevenue;
final Map<String, int> byProstheticType;
final List<CounterpartStat> counterpartStats;
final List<ActivityItem> recentActivity;
}
// ── Repository ────────────────────────────────────────────────────────────────
class ReportsRepository {
ReportsRepository._();
static final instance = ReportsRepository._();
PocketBase get _pb => PocketBaseClient.instance.pb;
Future<ReportMetrics> load(String tenantId, TenantKind kind) async {
final jobFilter = kind == TenantKind.lab
? 'lab_tenant_id = "$tenantId"'
: 'clinic_tenant_id = "$tenantId"';
final historyFilter = kind == TenantKind.lab
? 'lab_tenant_id = "$tenantId"'
: 'clinic_tenant_id = "$tenantId"';
final results = await Future.wait([
_pb.collection('jobs').getList(
filter: jobFilter,
perPage: 500,
expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id',
fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand',
).catchError((_) => ResultList<RecordModel>()),
_pb.collection('finance_entries').getList(
filter: 'tenant_id = "$tenantId"',
perPage: 300,
fields: 'id,amount,currency,status,created,counterparty_name',
).catchError((_) => ResultList<RecordModel>()),
_pb.collection('job_status_history').getList(
filter: historyFilter,
perPage: 100,
expand: 'job_id',
fields: 'id,action_type,created,note,job_id,expand',
).catchError((_) => ResultList<RecordModel>()),
]);
final jobRecords = (results[0] as ResultList<RecordModel>).items;
final financeRecords = (results[1] as ResultList<RecordModel>).items;
final historyRecords = (results[2] as ResultList<RecordModel>).items;
return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords);
}
ReportMetrics _aggregate(
String tenantId,
TenantKind kind,
List<RecordModel> jobRecords,
List<RecordModel> financeRecords,
List<RecordModel> historyRecords,
) {
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
// ── Parse jobs ────────────────────────────────────────────────────────────
String _s(Map<String, dynamic> j, String k) {
final v = j[k];
if (v == null || v == '') return '';
return v.toString();
}
final jobs = jobRecords.map((r) {
final j = r.toJson();
final exp = j['expand'] as Map<String, dynamic>?;
final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id';
final cpExp = exp?[cpKey] as Map<String, dynamic>?;
return _RawJob(
id: _s(j, 'id'),
status: _s(j, 'status'),
prostheticType: _s(j, 'prosthetic_type'),
clinicTenantId: _s(j, 'clinic_tenant_id'),
labTenantId: _s(j, 'lab_tenant_id'),
created: _parseDate(j['created']),
updated: _parseDate(j['updated']),
dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null,
currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY',
counterpartName: cpExp?['company_name'] as String? ?? '',
);
}).toList();
// ── Parse finance ─────────────────────────────────────────────────────────
String topCurrency = 'TRY';
final financeList = financeRecords.map((r) {
final j = r.toJson();
final cur = (j['currency'] as String?) ?? 'TRY';
if (cur.isNotEmpty) topCurrency = cur;
return _RawFinance(
status: (j['status'] as String?) ?? '',
amount: (j['amount'] as num?)?.toDouble() ?? 0,
created: _parseDate(j['created']),
counterpartyName: j['counterparty_name'] as String?,
);
}).toList();
// ── Parse history ─────────────────────────────────────────────────────────
final activity = historyRecords.map((r) {
final j = r.toJson();
final exp = j['expand'] as Map<String, dynamic>?;
final jobExp = exp?['job_id'] as Map<String, dynamic>?;
return ActivityItem(
jobId: (j['job_id'] as String?) ?? '',
patientCode: jobExp?['patient_code'] as String?,
action: (j['action_type'] as String?) ?? '',
createdAt: _parseDate(j['created']),
note: j['note'] as String?,
);
}).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
// ── KPI metrics ───────────────────────────────────────────────────────────
final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length;
final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length;
final overdueJobs = jobs.where((j) =>
j.dueDate != null &&
j.dueDate!.isBefore(now) &&
(j.status == 'in_progress' || j.status == 'pending')).length;
final revisions = activity.where((a) => a.action == 'revision_requested').length;
final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0;
final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList();
final avgCompletionDays = deliveredJobs.isNotEmpty
? deliveredJobs
.fold<int>(0, (s, j) => s + j.updated.difference(j.created).inDays) /
deliveredJobs.length
: 0.0;
// ── Finance totals ────────────────────────────────────────────────────────
final totalRevenue = financeList.where((f) => f.status == 'paid').fold<double>(0, (s, f) => s + f.amount);
final pendingRevenue = financeList.where((f) => f.status == 'pending').fold<double>(0, (s, f) => s + f.amount);
// ── Job status distribution ───────────────────────────────────────────────
final Map<String, int> jobsByStatus = {};
for (final j in jobs) {
jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1;
}
// ── Monthly job counts (last 6 months) ────────────────────────────────────
final monthKeys = List.generate(6, (i) {
final d = DateTime(now.year, now.month - 5 + i, 1);
return '${d.year}-${d.month}';
});
final monthMap = {for (final k in monthKeys) k: 0};
for (final j in jobs) {
final key = '${j.created.year}-${j.created.month}';
if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1;
}
final monthlyCounts = monthKeys.map((k) {
final parts = k.split('-');
return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!);
}).toList();
// ── Monthly revenue (last 6 months) ──────────────────────────────────────
final revMap = {for (final k in monthKeys) k: 0.0};
for (final f in financeList) {
if (f.status == 'paid') {
final key = '${f.created.year}-${f.created.month}';
if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount;
}
}
final monthlyRevenue = monthKeys.map((k) {
final parts = k.split('-');
return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!);
}).toList();
// ── By prosthetic type ────────────────────────────────────────────────────
final Map<String, int> byType = {};
for (final j in jobs) {
if (j.prostheticType.isNotEmpty) {
byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1;
}
}
// ── By counterpart ────────────────────────────────────────────────────────
final Map<String, int> cpCount = {};
final Map<String, double> cpPending = {}, cpPaid = {};
for (final j in jobs) {
final name = j.counterpartName.isNotEmpty ? j.counterpartName : '';
cpCount[name] = (cpCount[name] ?? 0) + 1;
}
for (final f in financeList) {
final name = f.counterpartyName ?? '';
if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount;
if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount;
}
final counterparts = cpCount.entries
.map((e) => CounterpartStat(
name: e.key,
jobCount: e.value,
pendingRevenue: cpPending[e.key] ?? 0,
paidRevenue: cpPaid[e.key] ?? 0,
))
.toList()
..sort((a, b) => b.jobCount.compareTo(a.jobCount));
return ReportMetrics(
activeJobs: activeJobs,
completedThisMonth: completedThisMonth,
overdueJobs: overdueJobs,
revisionRate: revisionRate,
avgCompletionDays: avgCompletionDays,
totalRevenue: totalRevenue,
pendingRevenue: pendingRevenue,
currency: topCurrency,
jobsByStatus: jobsByStatus,
monthlyCounts: monthlyCounts,
monthlyRevenue: monthlyRevenue,
byProstheticType: byType,
counterpartStats: counterparts.take(5).toList(),
recentActivity: activity.take(30).toList(),
);
}
static DateTime _parseDate(dynamic v) {
if (v == null || v == '') return DateTime(2000);
return DateTime.tryParse(v.toString()) ?? DateTime(2000);
}
}
// ── Internal raw models ───────────────────────────────────────────────────────
class _RawJob {
const _RawJob({
required this.id, required this.status, required this.prostheticType,
required this.clinicTenantId, required this.labTenantId,
required this.created, required this.updated,
required this.currency, required this.counterpartName, this.dueDate,
});
final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName;
final DateTime created, updated;
final DateTime? dueDate;
}
class _RawFinance {
const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName});
final String status;
final double amount;
final DateTime created;
final String? counterpartyName;
}