Files
lab-app/lib/features/shared/reports_repository.dart
Emre Emir 8bbc9dbff2 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
2026-06-11 15:57:31 +03:00

326 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}