@extends('layouts.app') @section('title', __('common.dashboard')) @php /* |-------------------------------------------------------------------------- | Widget data |-------------------------------------------------------------------------- | The controller stays Phase-0 (module count + blocked list) by design, so | everything clinical is assembled here in the view layer, each block | guarded by the module's own routes: disable a module and its widgets | disappear with it, exactly like the sidebar entries do. Cross-module | patient names go through the published PatientDirectory contract — the | same door the Appointments screens use. */ $settings = app(\App\Support\Settings\SettingsContract::class); $tz = (string) $settings->get('clinic.timezone', config('app.timezone', 'UTC')); $currency = strtoupper((string) $settings->get('clinic.currency', 'USD')); $symbol = (string) $settings->get('clinic.currency_symbol', '$'); $nowLocal = now($tz); $todayStart = $nowLocal->copy()->startOfDay()->utc(); $todayEnd = $nowLocal->copy()->endOfDay()->utc(); $weekStart = $nowLocal->copy()->startOfWeek()->utc(); /* | The ninety-day series that fed the analytics chart, the card sparklines | and the gender doughnut are gone with them (2026-08-10). | | Worth noting what went with the pictures: each series loaded every row in | its table for the last ninety days into PHP to bucket it by local day — | five such reads per dashboard load, for patients, appointments, payments, | invoices and the audit log. The tallies below are counts, which the | database does without handing anything back. */ $hasPatients = Route::has('patients.index'); $hasAppointments = Route::has('appointments.index'); $hasBilling = Route::has('invoices.index'); // ---- Patients ----------------------------------------------------------- if ($hasPatients) { $patientModel = \App\Modules\Patients\Models\Patient::query(); $patientCount = (clone $patientModel)->count(); $patientsThisWeek = (clone $patientModel)->where('created_at', '>=', $weekStart)->count(); $recentPatients = (clone $patientModel)->latest()->take(5)->get(); $genderCounts = (clone $patientModel) ->selectRaw('gender, count(*) as total') ->groupBy('gender') ->pluck('total', 'gender'); } // ---- Appointments ------------------------------------------------------- if ($hasAppointments) { $appointmentModel = \App\Modules\Appointments\Models\Appointment::query(); $todaysAppointments = (clone $appointmentModel)->occupying() ->whereBetween('starts_at', [$todayStart, $todayEnd]) ->with('doctor') ->orderBy('starts_at') ->get(); $todayTotal = $todaysAppointments->count(); $todayCompleted = $todaysAppointments ->where('status', \App\Modules\Appointments\Enums\AppointmentStatus::Completed) ->count(); $completionPct = $todayTotal > 0 ? (int) round($todayCompleted * 100 / $todayTotal) : 0; $upcomingAppointments = (clone $appointmentModel)->occupying()->upcoming() ->with('doctor')->take(5)->get(); $doctorCount = \App\Modules\Appointments\Models\DoctorSchedule::query() ->where('is_active', true)->distinct()->count('doctor_id'); $doctorsToday = \App\Modules\Appointments\Models\DoctorSchedule::query() ->where('is_active', true) ->where('weekday', $nowLocal->isoWeekday()) ->distinct()->count('doctor_id'); // Patient names for the lists, batch-loaded through the contract. $patientSummaries = []; if ($hasPatients) { $patientIds = $upcomingAppointments->pluck('patient_id') ->merge($todaysAppointments->pluck('patient_id')) ->unique()->values()->all(); $patientSummaries = $patientIds === [] ? [] : app(\App\Modules\Patients\Contracts\PatientDirectory::class)->summariesFor($patientIds); } } // ---- Billing ------------------------------------------------------------ if ($hasBilling) { /* | Today's takings and the standing debt, both split by the counter they | belong to. The two headline cards read their totals off this same | board, so the breakdown further down can never disagree with the | figure above it. | | The revenue figure now nets reversals and refunds off the day, which | the old sum of `kind = payment` rows did not: a day whose only event | was a refund used to report the original takings again. */ $takings = app(\App\Modules\Billing\Queries\DepartmentTakingsQuery::class) ->between($todayStart, $todayEnd, $currency); $revenueToday = $takings->collected; $outstanding = $takings->outstanding; $outstandingCount = $takings->outstandingCount; } // ---- Core --------------------------------------------------------------- // The two audit counts that used to be read here went with the card on // 2026-08-14. They were a COUNT over the whole audit table on every // dashboard load, and that table only ever grows. // System health, measured rather than asserted. try { $dbPingStart = hrtime(true); \Illuminate\Support\Facades\DB::select('select 1'); $dbPing = (int) round((hrtime(true) - $dbPingStart) / 1_000_000); $dbStatus = 'ok'; } catch (\Throwable) { $dbPing = null; $dbStatus = 'down'; } try { cache()->put('dash-health-probe', 1, 10); $cacheStatus = cache()->get('dash-health-probe') === 1 ? 'ok' : 'warn'; } catch (\Throwable) { $cacheStatus = 'down'; } $storageFree = @disk_free_space(storage_path()); $storageTotal = @disk_total_space(storage_path()); $storagePct = ($storageFree !== false && $storageTotal !== false && $storageTotal > 0) ? (int) round(100 - ($storageFree * 100 / $storageTotal)) : null; @endphp @section('content') {{-- ── Page head ──────────────────────────────────────────────────────── --}} {{-- No breadcrumb. The dashboard is the root of the application, so the trail could only ever read "Clinic › Dashboard" with its first link pointing at the page you are already on. --}}
{{ __('common.welcome_back') }}, {{ auth()->user()->name }} {{ $nowLocal->translatedFormat('l, j F Y') }} {{ $nowLocal->format('H:i:s') }}
{{ __('nav.modules') }}
{{ $kpi['label'] }}
{{ $kpi['value'] }}
@if ($kpi['note']){{ $kpi['note'] }}
@endif{{ __('common.upcoming_appointments') }}
{{ __('common.view_all') }}{{ $appointment->localStart()->format('H:i') }}
{{ $appointment->localStart()->isSameDay($nowLocal) ? __('common.today') : $appointment->localStart()->format('d M') }}
{{ $patientName }}
{{ $appointment->doctor?->name }} @if ($summary && $summary->activeAllergyCount > 0) · {{ trans_choice('laboratory::messages.wizard.allergies', $summary->activeAllergyCount, ['count' => $summary->activeAllergyCount]) }} @endif
{{ __('common.no_upcoming') }}
{{ __('common.no_upcoming_text') }}
{{ __('common.revenue_by_department') }}
{{ __('common.view_all') }}| {{ __('billing::messages.ledger.department') }} | {{ __('common.revenue_today') }} | {{ __('common.outstanding') }} |
|---|---|---|
| {{ $line->serviceLine->label() }} | {{ $line->collected->format($symbol) }} | {{ $line->outstanding->format($symbol) }} @if ($line->outstandingCount > 0) {{ $line->outstandingCount }} {{ __('common.invoices') }} @endif |
| {{ __('billing::messages.ledger.total') }} | {{ $takings->collected->format($symbol) }} | {{ $takings->outstanding->format($symbol) }} |
{{ __('common.recent_patients') }}
{{ __('common.view_all') }}{{ __('common.no_recent_patients') }}
{{ __('common.no_recent_patients_text') }}
| {{ $patient->fullName() }} {{ $patient->mrn }} | {{ $patient->phone ?: '—' }} | {{ $patient->created_at?->setTimezone($tz)->format('d M') }} |
{{ __('common.quick_actions') }}
{{ __('common.todays_schedule') }}
{{ $nowLocal->translatedFormat('l, j F') }}
@if ($todaysAppointments->isEmpty()){{ __('common.nothing_today') }}
{{ __('common.nothing_today_text') }}
{{ $slot->localStart()->format('H:i') }}
{{ $slotSummary?->name ?? $slot->reference }}
{{ $slot->doctor?->name }} · {{ $slot->status->label() }}
{{ __('common.system_health') }}
{{ config('clinic.product.name') }} v{{ config('clinic.product.version') }}
@php $health = [ ['label' => 'Database', 'icon' => 'bi-database', 'status' => $dbStatus, 'detail' => $dbPing !== null ? $dbPing.' ms' : null], ['label' => 'Cache', 'icon' => 'bi-lightning-charge', 'status' => $cacheStatus, 'detail' => config('cache.default')], ['label' => 'Queue', 'icon' => 'bi-list-check', 'status' => 'ok', 'detail' => config('queue.default')], ['label' => 'Mail', 'icon' => 'bi-envelope', 'status' => 'ok', 'detail' => config('mail.default')], ]; if ($storagePct !== null) { $health[] = [ 'label' => 'Storage', 'icon' => 'bi-hdd', 'status' => $storagePct >= 90 ? 'warn' : 'ok', 'detail' => $storagePct.'%', ]; } $healthPill = static fn (string $status): string => match ($status) { 'ok' => 'px-pill--green', 'warn' => 'px-pill--amber', default => 'px-pill--red', }; @endphp @foreach ($health as $row){{ __('common.gender_distribution') }}
{{-- The doughnut is gone; the tally it decorated is not. The total moves to the top of the list, which is where the middle of the ring used to hold it. --}}