Initial commit — DLS lab-app Flutter project
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user