, * breakdowns: list}>, * alerts: list, * links: list * } */ public function patients(Organization $organization, string $ownerRef, ?int $branchId = null): array { $from = now()->startOfMonth(); $to = now()->endOfDay(); $period = $this->reports->patientsReport($ownerRef, $organization->id, $from, $to, $branchId); $total = Patient::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->count(); $today = Patient::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->whereDate('created_at', today()) ->count(); $activeVisits = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) ->count(); $byBranch = Patient::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->select('branch_id', DB::raw('count(*) as total')) ->groupBy('branch_id') ->orderByDesc('total') ->limit(8) ->get(); $branchNames = $this->branchNames($organization, $ownerRef, $byBranch->pluck('branch_id')); return [ 'period_label' => 'This month', 'from' => $from->toDateString(), 'to' => $to->toDateString(), 'kpis' => [ ['label' => 'Registered patients', 'value' => number_format($total), 'hint' => 'All time'], ['label' => 'New this month', 'value' => number_format($period['new_patients']), 'hint' => 'Registrations'], ['label' => 'Visits this month', 'value' => number_format($period['total_visits'])], ['label' => 'New today', 'value' => number_format($today)], ['label' => 'Open visits', 'value' => number_format($activeVisits), 'hint' => 'In facility now'], ['label' => 'Returning this month', 'value' => number_format($period['returning_patients'])], ], 'breakdowns' => [ [ 'title' => 'Patients by branch', 'rows' => $this->pctRows($byBranch->map(fn ($row) => [ 'label' => $branchNames[(int) $row->branch_id] ?? 'Unassigned', 'value' => (int) $row->total, ])->all(), $total), ], ], 'alerts' => $today === 0 && $period['new_patients'] === 0 ? [['severity' => 'info', 'message' => 'No new patient registrations so far this month.']] : [], 'links' => [ ['label' => 'Patients report', 'href' => route('care.reports.show', ['type' => 'patients'])], ['label' => 'Browse patient records', 'href' => route('care.patients.index', ['view' => 'list'])], ], ]; } /** * @return array */ public function appointments(Organization $organization, string $ownerRef, ?int $branchId = null): array { $from = now()->startOfMonth(); $to = now()->endOfDay(); $period = $this->reports->appointmentsReport($ownerRef, $organization->id, $from, $to, $branchId); $baseToday = Appointment::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->whereDate('scheduled_at', today()); $waiting = Appointment::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->whereIn('status', [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING]) ->count(); $inConsult = Appointment::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->where('status', Appointment::STATUS_IN_CONSULTATION) ->count(); $byStatus = Appointment::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->whereBetween('scheduled_at', [$from, $to]) ->select('status', DB::raw('count(*) as total')) ->groupBy('status') ->orderByDesc('total') ->get() ->keyBy('status'); $completionRate = $period['total'] > 0 ? round(($period['completed'] / $period['total']) * 100, 1) : 0.0; $noShowRate = $period['total'] > 0 ? round(($period['no_show'] / $period['total']) * 100, 1) : 0.0; $alerts = []; if ($noShowRate >= 15) { $alerts[] = [ 'severity' => 'warning', 'message' => "No-show rate is {$noShowRate}% this month — review booking reminders.", ]; } if ($waiting >= 20) { $alerts[] = [ 'severity' => 'warning', 'message' => "{$waiting} patients are waiting across the facility right now.", ]; } $appointmentStatusRows = collect(config('care.appointment_statuses', [])) ->map(fn (string $label, string $status) => [ 'label' => $label, 'value' => (int) ($byStatus->get($status)?->total ?? 0), ]) ->values() ->all(); return [ 'period_label' => 'This month', 'from' => $from->toDateString(), 'to' => $to->toDateString(), 'kpis' => [ ['label' => 'Scheduled today', 'value' => number_format((clone $baseToday)->count())], ['label' => 'Waiting now', 'value' => number_format($waiting)], ['label' => 'In consultation', 'value' => number_format($inConsult)], ['label' => 'This month', 'value' => number_format($period['total'])], ['label' => 'Completed', 'value' => number_format($period['completed']), 'hint' => $completionRate.'%'], ['label' => 'No-shows', 'value' => number_format($period['no_show']), 'hint' => $noShowRate.'%'], ], 'breakdowns' => [ [ 'title' => 'Appointments by status (this month)', 'rows' => $this->pctRows($appointmentStatusRows, $period['total']), ], ], 'alerts' => $alerts, 'links' => [ ['label' => 'Appointments report', 'href' => route('care.reports.show', ['type' => 'appointments'])], ['label' => 'Browse schedule', 'href' => route('care.appointments.index', ['view' => 'list'])], ], ]; } /** * @return array */ public function inventory(Organization $organization, string $ownerRef, ?int $branchId = null): array { $drugQuery = Drug::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); $total = (clone $drugQuery)->count(); $lowStock = $this->pharmacy->lowStock($ownerRef, $organization->id); $expired = $this->pharmacy->expiredBatches($ownerRef, $organization->id); if ($branchId) { $lowStock = $lowStock->where('branch_id', $branchId)->values(); $expired = $expired->filter( fn ($batch) => (int) ($batch->drug?->branch_id ?? 0) === $branchId )->values(); } $stockValue = DrugBatch::query() ->whereHas('drug', function ($q) use ($ownerRef, $organization, $branchId) { $q->owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($dq) => $dq->where('branch_id', $branchId)); }) ->where('quantity_on_hand', '>', 0) ->where(function ($q) { $q->whereNull('expiry_date')->orWhere('expiry_date', '>=', now()->toDateString()); }) ->with('drug') ->get() ->sum(function (DrugBatch $batch) { $price = (int) ($batch->drug?->unit_price_minor ?? 0); return $price * (int) $batch->quantity_on_hand; }); $expiringSoon = DrugBatch::query() ->whereHas('drug', function ($q) use ($ownerRef, $organization, $branchId) { $q->owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($dq) => $dq->where('branch_id', $branchId)); }) ->where('quantity_on_hand', '>', 0) ->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(30)->toDateString()]) ->count(); $topLow = $lowStock->take(8)->map(fn (Drug $drug) => [ 'label' => $drug->name, 'value' => $drug->stockOnHand().' '.$drug->unit, ])->all(); $alerts = []; if ($lowStock->isNotEmpty()) { $alerts[] = [ 'severity' => 'warning', 'message' => $lowStock->count().' drug(s) are at or below reorder level.', ]; } if ($expired->isNotEmpty()) { $alerts[] = [ 'severity' => 'critical', 'message' => $expired->count().' expired batch(es) still have stock on hand.', ]; } if ($expiringSoon > 0) { $alerts[] = [ 'severity' => 'info', 'message' => "{$expiringSoon} batch(es) expire within 30 days.", ]; } $currency = strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))); return [ 'period_label' => 'Live stock', 'from' => now()->toDateString(), 'to' => now()->toDateString(), 'kpis' => [ ['label' => 'Catalog items', 'value' => number_format($total)], ['label' => 'Low stock', 'value' => number_format($lowStock->count())], ['label' => 'Expired batches', 'value' => number_format($expired->count())], ['label' => 'Expiring ≤30 days', 'value' => number_format($expiringSoon)], ['label' => 'Est. stock value', 'value' => $currency.' '.number_format($stockValue / 100, 2)], ], 'breakdowns' => [ [ 'title' => 'Lowest stock items', 'rows' => array_map(fn ($row) => [ 'label' => $row['label'], 'value' => $row['value'], 'pct' => null, ], $topLow), ], ], 'alerts' => $alerts, 'links' => [ ['label' => 'Browse inventory list', 'href' => route('care.pharmacy.drugs.index', ['view' => 'list'])], ], ]; } /** * @return array */ public function billing(Organization $organization, string $ownerRef, ?int $branchId = null): array { $from = now()->startOfMonth(); $to = now()->endOfDay(); $period = $this->reports->financeReport($ownerRef, $organization->id, $from, $to, $branchId); $today = $this->reports->dashboardStats($ownerRef, $organization->id, $branchId, true); // Current book by status (not limited to invoices created this month — // MTD create-date filtering made demos look like "100% Open"). $byStatus = Bill::owned($ownerRef) ->where('organization_id', $organization->id) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->where('status', '!=', Bill::STATUS_VOID) ->select('status', DB::raw('count(*) as total'), DB::raw('sum(balance_minor) as balance')) ->groupBy('status') ->orderByDesc('total') ->get() ->keyBy('status'); $statusLabels = config('care.bill_statuses', []); $statusOrder = [ Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_PAID, Bill::STATUS_DRAFT, ]; $statusTotal = (int) $byStatus->sum(fn ($row) => (int) $row->total); $statusRows = []; foreach ($statusOrder as $status) { if (! array_key_exists($status, $statusLabels) && ! $byStatus->has($status)) { continue; } $count = (int) ($byStatus->get($status)?->total ?? 0); $statusRows[] = [ 'label' => $statusLabels[$status] ?? $status, 'value' => $count, ]; } foreach ($byStatus as $status => $row) { if (in_array($status, $statusOrder, true)) { continue; } $statusRows[] = [ 'label' => $statusLabels[$status] ?? (string) $status, 'value' => (int) $row->total, ]; } $currency = strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))); $money = fn (int $minor) => $currency.' '.number_format($minor / 100, 2); $collectionRate = $period['invoiced_minor'] > 0 ? round(($period['collected_minor'] / $period['invoiced_minor']) * 100, 1) : 0.0; $alerts = []; if ($period['outstanding_minor'] > 0) { $alerts[] = [ 'severity' => 'warning', 'message' => 'Outstanding balances total '.$money($period['outstanding_minor']).'.', ]; } return [ 'period_label' => 'This month', 'from' => $from->toDateString(), 'to' => $to->toDateString(), 'kpis' => [ ['label' => 'Revenue today', 'value' => $money((int) $today['revenue_today_minor'])], ['label' => 'Collected this month', 'value' => $money($period['collected_minor']), 'hint' => $collectionRate.'% of invoiced'], ['label' => 'Invoiced this month', 'value' => $money($period['invoiced_minor'])], ['label' => 'Outstanding', 'value' => $money($period['outstanding_minor'])], ['label' => 'Open / partial bills', 'value' => number_format((int) $today['open_bills'])], ['label' => 'Invoices this month', 'value' => number_format($period['invoice_count'])], ], 'breakdowns' => [ [ 'title' => 'Invoices by status (current book)', 'rows' => $this->pctRows($statusRows, $statusTotal), ], ], 'alerts' => $alerts, 'links' => [ ['label' => 'Finance report', 'href' => route('care.reports.show', ['type' => 'finance'])], ['label' => 'Browse invoices', 'href' => route('care.bills.index', ['view' => 'list'])], ], ]; } /** * Hub snapshot for Reports index. * * @return array */ public function reportsHub(Organization $organization, string $ownerRef, ?int $branchId = null): array { $from = now()->startOfMonth(); $to = now()->endOfDay(); $patients = $this->reports->patientsReport($ownerRef, $organization->id, $from, $to, $branchId); $appointments = $this->reports->appointmentsReport($ownerRef, $organization->id, $from, $to, $branchId); $finance = $this->reports->financeReport($ownerRef, $organization->id, $from, $to, $branchId); $lab = $this->reports->laboratoryReport($ownerRef, $organization->id, $from, $to, $branchId); $today = $this->reports->dashboardStats($ownerRef, $organization->id, $branchId, true); $currency = strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))); $money = fn (int $minor) => $currency.' '.number_format($minor / 100, 2); return [ 'period_label' => 'This month', 'from' => $from->toDateString(), 'to' => $to->toDateString(), // Lead with compact counts for the page hero (first 3); money lives in the grid below. 'kpis' => [ ['label' => 'New patients MTD', 'value' => number_format($patients['new_patients'])], ['label' => 'Appointments MTD', 'value' => number_format($appointments['total'])], ['label' => 'Lab pending', 'value' => number_format($lab['pending'])], ['label' => 'Visits MTD', 'value' => number_format($patients['total_visits'])], ['label' => 'Revenue today', 'value' => $money((int) $today['revenue_today_minor'])], ['label' => 'Collected MTD', 'value' => $money($finance['collected_minor'])], ['label' => 'Outstanding', 'value' => $money($finance['outstanding_minor'])], ], 'breakdowns' => [ [ 'title' => 'Month-to-date snapshot', 'rows' => [ ['label' => 'Completed appointments', 'value' => number_format($appointments['completed']), 'pct' => null], ['label' => 'No-shows', 'value' => number_format($appointments['no_show']), 'pct' => null], ['label' => 'Lab completed', 'value' => number_format($lab['completed']), 'pct' => null], ['label' => 'Invoices', 'value' => number_format($finance['invoice_count']), 'pct' => null], ], ], ], 'alerts' => [], 'links' => [], ]; } /** * @param list $rows * @return list */ protected function pctRows(array $rows, int $total): array { return array_map(function (array $row) use ($total) { $value = (int) $row['value']; return [ 'label' => $row['label'], 'value' => number_format($value), 'pct' => $total > 0 ? round(($value / $total) * 100, 1) : null, ]; }, $rows); } /** * @param Collection $ids * @return array */ protected function branchNames(Organization $organization, string $ownerRef, Collection $ids): array { return Branch::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $ids->filter()->all() ?: [0]) ->pluck('name', 'id') ->mapWithKeys(fn ($name, $id) => [(int) $id => (string) $name]) ->all(); } }