diff --git a/app/Http/Controllers/Care/AppointmentController.php b/app/Http/Controllers/Care/AppointmentController.php index af8f980..7fc8098 100644 --- a/app/Http/Controllers/Care/AppointmentController.php +++ b/app/Http/Controllers/Care/AppointmentController.php @@ -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) { diff --git a/app/Http/Controllers/Care/BillController.php b/app/Http/Controllers/Care/BillController.php index 21988e1..00b3a24 100644 --- a/app/Http/Controllers/Care/BillController.php +++ b/app/Http/Controllers/Care/BillController.php @@ -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), ]); } diff --git a/app/Http/Controllers/Care/DrugController.php b/app/Http/Controllers/Care/DrugController.php index 1674415..57c3ddc 100644 --- a/app/Http/Controllers/Care/DrugController.php +++ b/app/Http/Controllers/Care/DrugController.php @@ -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, ]); } diff --git a/app/Http/Controllers/Care/PatientController.php b/app/Http/Controllers/Care/PatientController.php index 09d2573..9fb3ab6 100644 --- a/app/Http/Controllers/Care/PatientController.php +++ b/app/Http/Controllers/Care/PatientController.php @@ -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), diff --git a/app/Http/Controllers/Care/ReportController.php b/app/Http/Controllers/Care/ReportController.php index 198dd65..b145b7e 100644 --- a/app/Http/Controllers/Care/ReportController.php +++ b/app/Http/Controllers/Care/ReportController.php @@ -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'), ]); } diff --git a/app/Services/Care/AdminOverviewService.php b/app/Services/Care/AdminOverviewService.php new file mode 100644 index 0000000..afc3ef4 --- /dev/null +++ b/app/Services/Care/AdminOverviewService.php @@ -0,0 +1,437 @@ +, + * 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(); + + $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 + */ + 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); + + $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 + */ + 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 $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(); + } +} diff --git a/resources/views/care/admin/analytics.blade.php b/resources/views/care/admin/analytics.blade.php new file mode 100644 index 0000000..60e1500 --- /dev/null +++ b/resources/views/care/admin/analytics.blade.php @@ -0,0 +1,37 @@ + +
+ + +

+ {{ $adminOverview['period_label'] ?? 'Period' }} + @if (! empty($adminOverview['from']) && ! empty($adminOverview['to'])) + · {{ $adminOverview['from'] }} to {{ $adminOverview['to'] }} + @endif +

+ + @include('care.partials.admin-overview', ['adminOverview' => $adminOverview]) + + @isset($catalog) +
+

Detailed reports

+

Open a report for date-range filters and CSV export.

+
+ @foreach ($catalog as $key => $label) + +

{{ $label }}

+

View and export

+
+ @endforeach +
+
+ @endisset +
+
diff --git a/resources/views/care/partials/admin-overview.blade.php b/resources/views/care/partials/admin-overview.blade.php new file mode 100644 index 0000000..93eb0ea --- /dev/null +++ b/resources/views/care/partials/admin-overview.blade.php @@ -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 !== []) +
+ @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 +
{{ $alert['message'] }}
+ @endforeach +
+@endif + +@if ($kpis !== []) +
+ @foreach ($kpis as $kpi) +
+

{{ $kpi['label'] }}

+

{{ $kpi['value'] }}

+ @if (! empty($kpi['hint'])) +

{{ $kpi['hint'] }}

+ @endif +
+ @endforeach +
+@endif + +@foreach ($breakdowns as $section) +
+

{{ $section['title'] }}

+ @if (($section['rows'] ?? []) === []) +

No data for this period.

+ @else +
    + @foreach ($section['rows'] as $row) +
  • +
    + {{ $row['label'] }} + + {{ $row['value'] }} + @if (isset($row['pct']) && $row['pct'] !== null) + ({{ $row['pct'] }}%) + @endif + +
    + @if (isset($row['pct']) && $row['pct'] !== null) +
    +
    +
    + @endif +
  • + @endforeach +
+ @endif +
+@endforeach + +@if ($links !== []) +
+ @foreach ($links as $link) + + {{ $link['label'] }} + + @endforeach +
+@endif diff --git a/tests/Feature/CareAdminOverviewTest.php b/tests/Feature/CareAdminOverviewTest.php new file mode 100644 index 0000000..ec8ec01 --- /dev/null +++ b/tests/Feature/CareAdminOverviewTest.php @@ -0,0 +1,120 @@ +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'); + } +}