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 jobsByStatus; final List monthlyCounts; final List monthlyRevenue; final Map byProstheticType; final List counterpartStats; final List recentActivity; } // ── Repository ──────────────────────────────────────────────────────────────── class ReportsRepository { ReportsRepository._(); static final instance = ReportsRepository._(); PocketBase get _pb => PocketBaseClient.instance.pb; Future 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()), _pb.collection('finance_entries').getList( filter: 'tenant_id = "$tenantId"', perPage: 300, fields: 'id,amount,currency,status,created,counterparty_name', ).catchError((_) => ResultList()), _pb.collection('job_status_history').getList( filter: historyFilter, perPage: 100, expand: 'job_id', fields: 'id,action_type,created,note,job_id,expand', ).catchError((_) => ResultList()), ]); final jobRecords = (results[0] as ResultList).items; final financeRecords = (results[1] as ResultList).items; final historyRecords = (results[2] as ResultList).items; return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords); } ReportMetrics _aggregate( String tenantId, TenantKind kind, List jobRecords, List financeRecords, List historyRecords, ) { final now = DateTime.now(); final thisMonthStart = DateTime(now.year, now.month, 1); // ── Parse jobs ──────────────────────────────────────────────────────────── String _s(Map 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?; final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id'; final cpExp = exp?[cpKey] as Map?; 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?; final jobExp = exp?['job_id'] as Map?; 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(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(0, (s, f) => s + f.amount); final pendingRevenue = financeList.where((f) => f.status == 'pending').fold(0, (s, f) => s + f.amount); // ── Job status distribution ─────────────────────────────────────────────── final Map 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 byType = {}; for (final j in jobs) { if (j.prostheticType.isNotEmpty) { byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1; } } // ── By counterpart ──────────────────────────────────────────────────────── final Map cpCount = {}; final Map 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; }