Make admin Patients, Appointments, Inventory, Billing, and Reports data-driven.
Deploy Ladill Care / deploy (push) Successful in 44s

Facility admins see analytics overviews instead of operational directories; floor roles keep list UIs, with optional ?view=list for admins who need records.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 13:38:55 +00:00
co-authored by Cursor
parent 612e021e85
commit 8e406dc93c
9 changed files with 757 additions and 9 deletions
@@ -10,6 +10,7 @@ use App\Models\Department;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Services\Care\AdminOverviewService;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
@@ -27,6 +28,7 @@ class AppointmentController extends Controller
public function __construct(
protected AppointmentService $appointments,
protected AdminOverviewService $adminOverview,
) {}
public function index(Request $request): View
@@ -37,6 +39,20 @@ class AppointmentController extends Controller
$resolver = app(OrganizationResolver::class);
$branchScope = $resolver->branchScope($member);
$practitionerScope = $resolver->practitionerScope($organization, $member);
$permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member) && $request->query('view') !== 'list') {
return view('care.admin.analytics', [
'title' => 'Appointments',
'badge' => 'Analytics · Throughput',
'description' => 'Scheduling, waiting, and completion rates across the facility.',
'adminOverview' => $this->adminOverview->appointments(
$organization,
$this->ownerRef($request),
$branchScope,
),
]);
}
$filters = $request->only(['status', 'practitioner_id', 'date', 'patient_id']);
if ($practitionerScope !== null) {
+20 -3
View File
@@ -7,8 +7,10 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Bill;
use App\Models\Consultation;
use App\Models\Visit;
use App\Services\Care\AdminOverviewService;
use App\Services\Care\BillService;
use App\Services\Care\BranchContext;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\ConsultationReturnContext;
@@ -28,6 +30,7 @@ class BillController extends Controller
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
protected BranchContext $branchContext,
protected AdminOverviewService $adminOverview,
) {}
public function index(Request $request): View
@@ -35,9 +38,23 @@ class BillController extends Controller
$this->authorizeAbility($request, 'bills.view');
$organization = $this->organization($request);
$member = $this->member($request);
$permissions = app(CarePermissions::class);
$branches = $this->branchContext->branches($request, $organization, $member);
$branchId = $this->branchContext->resolve($request, $organization, $member);
if ($permissions->isAdmin($member) && $request->query('view') !== 'list') {
return view('care.admin.analytics', [
'title' => 'Billing',
'badge' => 'Analytics · Revenue',
'description' => 'Collections, outstanding balances, and invoice throughput — not a cashier queue.',
'adminOverview' => $this->adminOverview->billing(
$organization,
$this->ownerRef($request),
$branchId > 0 ? $branchId : null,
),
]);
}
$bills = $this->bills->list(
$this->ownerRef($request),
$organization->id,
@@ -52,11 +69,11 @@ class BillController extends Controller
'branches' => $branches,
'branchId' => $branchId,
'canSwitchBranch' => $this->branchContext->canSwitch($member),
'queueIntegration' => $branchId
'queueIntegration' => $branchId && $permissions->handlesFloorCare($member)
? $this->queueBridge->listMeta($organization, CareQueueContexts::BILLING, $branchId)
: ['enabled' => false],
'canManageQueue' => app(\App\Services\Care\CarePermissions::class)
->can($member, 'service_queues.console'),
'canManageQueue' => $permissions->can($member, 'service_queues.console')
&& $permissions->handlesFloorCare($member),
]);
}
+20 -3
View File
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Drug;
use App\Services\Care\AdminOverviewService;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PharmacyService;
use Illuminate\Http\RedirectResponse;
@@ -17,13 +19,29 @@ class DrugController extends Controller
public function __construct(
protected PharmacyService $pharmacy,
protected AdminOverviewService $adminOverview,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'pharmacy.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member) && $request->query('view') !== 'list') {
return view('care.admin.analytics', [
'title' => 'Inventory',
'badge' => 'Analytics · Stock health',
'description' => 'Stock value, reorder risk, and expiry exposure across the pharmacy catalog.',
'adminOverview' => $this->adminOverview->inventory(
$organization,
$this->ownerRef($request),
$branchScope,
),
]);
}
$drugs = $this->pharmacy->listDrugs(
$this->ownerRef($request),
@@ -50,8 +68,7 @@ class DrugController extends Controller
'drugs' => $drugs,
'lowStock' => $lowStock,
'expired' => $expired,
'canManage' => app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'pharmacy.manage'),
'canManage' => $permissions->can($member, 'pharmacy.manage'),
'heroStats' => $heroStats,
]);
}
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Patient;
use App\Services\Care\AdminOverviewService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
@@ -21,6 +22,7 @@ class PatientController extends Controller
public function __construct(
protected PatientService $patients,
protected AdminOverviewService $adminOverview,
) {}
public function index(Request $request): View
@@ -31,6 +33,20 @@ class PatientController extends Controller
$resolver = app(OrganizationResolver::class);
$branchScope = $resolver->branchScope($member);
$practitionerScope = $resolver->practitionerScope($organization, $member);
$permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member) && $request->query('view') !== 'list') {
return view('care.admin.analytics', [
'title' => 'Patients',
'badge' => 'Analytics · Population',
'description' => 'Registration and visit trends across your facility — not a bedside patient list.',
'adminOverview' => $this->adminOverview->patients(
$organization,
$this->ownerRef($request),
$branchScope,
),
]);
}
$patients = $this->patients->search(
$this->ownerRef($request),
+14 -3
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Care\AdminOverviewService;
use App\Services\Care\CareFeatures;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PlanService;
@@ -20,12 +21,15 @@ class ReportController extends Controller
public function __construct(
protected ReportService $reports,
protected AdminOverviewService $adminOverview,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'reports.finance.view');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
@@ -33,10 +37,17 @@ class ReportController extends Controller
->orderBy('name')
->get();
return view('care.reports.index', [
'organization' => $organization,
return view('care.admin.analytics', [
'title' => 'Reports',
'badge' => 'Analytics · Facility performance',
'description' => 'Live operational and financial snapshot. Open a detailed report for date ranges and CSV export.',
'adminOverview' => $this->adminOverview->reportsHub(
$organization,
$this->ownerRef($request),
$branchScope,
),
'catalog' => config('care.report_types'),
'branches' => $branches,
'reports' => config('care.report_types'),
]);
}
+437
View File
@@ -0,0 +1,437 @@
<?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();
$statusLabels = config('care.appointment_statuses', []);
$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.",
];
}
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($byStatus->map(fn ($row) => [
'label' => $statusLabels[$row->status] ?? str_replace('_', ' ', (string) $row->status),
'value' => (int) $row->total,
])->all(), $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);
$byStatus = Bill::owned($ownerRef)
->where('organization_id', $organization->id)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('status', '!=', Bill::STATUS_VOID)
->whereBetween('created_at', [$from, $to])
->select('status', DB::raw('count(*) as total'), DB::raw('sum(balance_minor) as balance'))
->groupBy('status')
->orderByDesc('total')
->get();
$statusLabels = config('care.bill_statuses', []);
$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 (this month)',
'rows' => $this->pctRows($byStatus->map(fn ($row) => [
'label' => $statusLabels[$row->status] ?? (string) $row->status,
'value' => (int) $row->total,
])->all(), $period['invoice_count']),
],
],
'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(),
'kpis' => [
['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'])],
['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'])],
],
'breakdowns' => [
[
'title' => 'Month-to-date snapshot',
'rows' => [
['label' => 'Visits', 'value' => number_format($patients['total_visits']), 'pct' => null],
['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();
}
}
@@ -0,0 +1,37 @@
<x-app-layout :title="$title ?? 'Overview'">
<div class="space-y-6">
<x-care.page-hero
:badge="$badge ?? 'Analytics'"
:title="$title ?? 'Overview'"
:description="$description ?? ''"
:stats="collect($adminOverview['kpis'] ?? [])->take(3)->map(fn ($kpi) => [
'value' => $kpi['value'],
'label' => $kpi['label'],
])->values()->all()"
/>
<p class="text-sm text-slate-500">
{{ $adminOverview['period_label'] ?? 'Period' }}
@if (! empty($adminOverview['from']) && ! empty($adminOverview['to']))
· {{ $adminOverview['from'] }} to {{ $adminOverview['to'] }}
@endif
</p>
@include('care.partials.admin-overview', ['adminOverview' => $adminOverview])
@isset($catalog)
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Detailed reports</h2>
<p class="mt-1 text-sm text-slate-500">Open a report for date-range filters and CSV export.</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($catalog as $key => $label)
<a href="{{ route('care.reports.show', $key) }}" class="rounded-xl border border-slate-200 px-4 py-3 hover:border-indigo-200 hover:bg-indigo-50/40">
<p class="font-medium text-slate-900">{{ $label }}</p>
<p class="mt-0.5 text-xs text-slate-500">View and export</p>
</a>
@endforeach
</div>
</section>
@endisset
</div>
</x-app-layout>
@@ -0,0 +1,77 @@
{{-- Shared admin analytics overview (KPIs, breakdowns, alerts). --}}
@php
$overview = $adminOverview ?? [];
$kpis = $overview['kpis'] ?? [];
$breakdowns = $overview['breakdowns'] ?? [];
$alerts = $overview['alerts'] ?? [];
$links = $overview['links'] ?? [];
@endphp
@if ($alerts !== [])
<div class="space-y-2">
@foreach ($alerts as $alert)
@php
$tone = match ($alert['severity'] ?? 'info') {
'critical' => 'border-rose-200 bg-rose-50 text-rose-900',
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
default => 'border-sky-200 bg-sky-50 text-sky-900',
};
@endphp
<div class="rounded-xl border px-4 py-3 text-sm {{ $tone }}">{{ $alert['message'] }}</div>
@endforeach
</div>
@endif
@if ($kpis !== [])
<div class="grid grid-cols-2 gap-3 lg:grid-cols-3">
@foreach ($kpis as $kpi)
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-4">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">{{ $kpi['label'] }}</p>
<p class="mt-1 text-2xl font-semibold text-slate-900">{{ $kpi['value'] }}</p>
@if (! empty($kpi['hint']))
<p class="mt-1 text-xs text-slate-500">{{ $kpi['hint'] }}</p>
@endif
</div>
@endforeach
</div>
@endif
@foreach ($breakdowns as $section)
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">{{ $section['title'] }}</h2>
@if (($section['rows'] ?? []) === [])
<p class="mt-3 text-sm text-slate-500">No data for this period.</p>
@else
<ul class="mt-4 space-y-3">
@foreach ($section['rows'] as $row)
<li>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="font-medium text-slate-800">{{ $row['label'] }}</span>
<span class="tabular-nums text-slate-600">
{{ $row['value'] }}
@if (isset($row['pct']) && $row['pct'] !== null)
<span class="text-slate-400">({{ $row['pct'] }}%)</span>
@endif
</span>
</div>
@if (isset($row['pct']) && $row['pct'] !== null)
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full bg-indigo-500" style="width: {{ min(100, max(0, (float) $row['pct'])) }}%"></div>
</div>
@endif
</li>
@endforeach
</ul>
@endif
</section>
@endforeach
@if ($links !== [])
<div class="flex flex-wrap gap-2">
@foreach ($links as $link)
<a href="{{ $link['href'] }}" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:border-indigo-200 hover:bg-indigo-50/50">
{{ $link['label'] }}
</a>
@endforeach
</div>
@endif
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareAdminOverviewTest extends TestCase
{
use RefreshDatabase;
protected User $admin;
protected Organization $organization;
protected Branch $branch;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->admin = User::create([
'public_id' => 'admin-overview',
'name' => 'Hospital Admin',
'email' => 'admin-overview@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->admin->public_id,
'name' => 'Overview Hospital',
'slug' => 'overview-hospital',
'settings' => [
'onboarded' => true,
'facility_type' => 'hospital',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
],
]);
Member::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->admin->public_id,
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
Patient::create([
'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-OV-1',
'first_name' => 'Kwame',
'last_name' => 'Mensah',
'gender' => 'male',
'date_of_birth' => '1990-01-01',
]);
}
public function test_admin_patients_page_is_analytics_not_a_directory_list(): void
{
$this->actingAs($this->admin)
->get(route('care.patients.index'))
->assertOk()
->assertSee('Registered patients')
->assertSee('Patients by branch')
->assertDontSee('Barcode / QR scan')
->assertDontSee('P-OV-1');
$this->actingAs($this->admin)
->get(route('care.patients.index', ['view' => 'list']))
->assertOk()
->assertSee('P-OV-1')
->assertSee('Kwame');
}
public function test_admin_appointments_billing_inventory_and_reports_are_data_driven(): void
{
$this->actingAs($this->admin)
->get(route('care.appointments.index'))
->assertOk()
->assertSee('Scheduled today')
->assertSee('Appointments by status')
->assertDontSee('Walk-in');
$this->actingAs($this->admin)
->get(route('care.bills.index'))
->assertOk()
->assertSee('Revenue today')
->assertSee('Outstanding')
->assertDontSee('Call next');
$this->actingAs($this->admin)
->get(route('care.pharmacy.drugs.index'))
->assertOk()
->assertSee('Catalog items')
->assertSee('Est. stock value')
->assertDontSee('Search drugs');
$this->actingAs($this->admin)
->get(route('care.reports.index'))
->assertOk()
->assertSee('Revenue today')
->assertSee('Detailed reports')
->assertSee('Finance');
}
}