Files
ladill-care/app/Services/Care/AdminOverviewService.php
isaaccladandCursor 76241ac2b5
Deploy Ladill Care / deploy (push) Successful in 56s
Use compact operational KPIs in the Reports page hero.
Hero cards now show patient/appointment/lab counts instead of long currency strings, and page-hero values truncate safely for large figures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 11:09:25 +00:00

470 lines
20 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Branch;
use App\Models\Drug;
use App\Models\DrugBatch;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Payment;
use App\Models\Visit;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Facility-admin analytics for Patients / Appointments / Inventory / Billing / Reports hubs.
* Floor roles keep operational list UIs; admins get data-driven overviews.
*/
class AdminOverviewService
{
public function __construct(
protected ReportService $reports,
protected PharmacyService $pharmacy,
) {}
/**
* @return array{
* period_label: string,
* from: string,
* to: string,
* kpis: list<array{label: string, value: string, hint?: string}>,
* breakdowns: list<array{title: string, rows: list<array{label: string, value: string|int, pct?: float|null}>}>,
* alerts: list<array{severity: string, message: string}>,
* links: list<array{label: string, href: string}>
* }
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<array{label: string, value: int}> $rows
* @return list<array{label: string, value: string|int, pct: float|null}>
*/
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<int, mixed> $ids
* @return array<int, string>
*/
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();
}
}