Make admin Patients, Appointments, Inventory, Billing, and Reports data-driven.
Deploy Ladill Care / deploy (push) Successful in 44s
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:
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user