@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.dashboard') }}

{{ __('common.welcome_back') }}, {{ auth()->user()->name }} {{ $nowLocal->translatedFormat('l, j F Y') }} {{ $nowLocal->format('H:i:s') }}

@if ($hasPatients && Route::has('patients.create')) {{ __('common.add_patient') }} @endif @if ($hasAppointments && Route::has('appointments.create')) {{ __('common.new_appointment') }} @endif
{{-- ── Modules that could not start ───────────────────────────────────── --}} @if (! empty($blockedModules)) @endif {{-- ── Headline numbers ───────────────────────────────────────────────── --}} @php /* | Built as data rather than as eight near-identical blocks of markup: | which cards exist depends on which modules are installed, and a list | makes that condition readable in one place. | | The value is what the card counts; the note underneath is what it | changed by. A number with no sense of movement is a number nobody | comes back to look at twice. */ $kpis = []; if ($hasPatients) { $kpis[] = [ 'label' => __('common.patients'), 'value' => number_format($patientCount), 'icon' => 'bi-people-fill', 'tone' => 'violet', 'note' => '+'.$patientsThisWeek.' '.__('common.this_week'), 'colour' => '#6d4cff', 'href' => route('patients.index'), ]; } if ($hasAppointments) { $kpis[] = [ 'label' => __('common.appointments_today'), 'value' => number_format($todayTotal), 'icon' => 'bi-calendar3', 'tone' => 'blue', 'note' => $completionPct.'% '.__('common.completed'), 'colour' => '#2563eb', 'href' => route('appointments.index'), ]; $kpis[] = [ 'label' => __('common.doctors'), 'value' => number_format($doctorCount), 'icon' => 'bi-person-vcard-fill', 'tone' => 'violet', 'note' => $doctorsToday.' '.__('common.available_today'), 'colour' => '#6d4cff', 'href' => null, ]; } if ($hasBilling) { $kpis[] = [ 'label' => __('common.revenue_today'), 'value' => $revenueToday->format($symbol), 'icon' => 'bi-cash-stack', 'tone' => 'green', 'note' => null, 'colour' => '#047857', 'href' => route('invoices.index'), ]; $kpis[] = [ 'label' => __('common.outstanding'), 'value' => $outstanding->format($symbol), 'icon' => 'bi-receipt', 'tone' => 'amber', 'note' => $outstandingCount.' '.__('common.invoices'), 'colour' => '#b45309', 'href' => route('invoices.index'), ]; } /* | The audit-events card was taken off this row on 2026-08-14. The | trail itself is untouched and still recording — it is simply not a | number anybody acts on at the start of a shift, and it was sitting | among five that are. It is read from the audit screen, where the | entries can actually be looked at. */ // A fresh installation with no clinical modules yet still gets a // dashboard that says something true about itself. if (! ($hasPatients && $hasAppointments && $hasBilling)) { $kpis[] = [ 'label' => __('nav.modules'), 'value' => number_format($moduleCount), 'icon' => 'bi-diagram-3', 'tone' => 'violet', 'note' => null, 'colour' => '#6d4cff', 'href' => null, ]; } @endphp
@foreach ($kpis as $kpi) @php $tag = $kpi['href'] ? 'a' : 'div'; @endphp <{{ $tag }} class="px-metric px-metric--{{ $kpi['tone'] }} text-decoration-none text-body" @if ($kpi['href']) href="{{ $kpi['href'] }}" @endif>

{{ $kpi['label'] }}

{{ $kpi['value'] }}

@if ($kpi['note'])

{{ $kpi['note'] }}

@endif
@endforeach
{{-- ── Left column ────────────────────────────────────────────────── --}}
{{-- The analytics chart was removed on 2026-08-10 at the clinic's request, along with the sparklines on the summary cards and the gender doughnut. Nothing else read the day-by-day series, so the queries that built them went with it. --}} @if ($hasAppointments)

{{ __('common.upcoming_appointments') }}

{{ __('common.view_all') }}
@forelse ($upcomingAppointments as $appointment) @php $summary = $patientSummaries[$appointment->patient_id] ?? null; $patientName = $summary?->name ?? $appointment->reference; @endphp

{{ $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

{{-- The enum names a Bootstrap contextual tone; the pills use their own palette, so the two are mapped here rather than by renaming a value every other screen already relies on. --}} @php $statusPill = match ($appointment->status->tone()) { 'success' => 'px-pill--green', 'warning' => 'px-pill--amber', 'danger' => 'px-pill--red', 'info' => 'px-pill--blue', 'primary' => 'px-pill--violet', default => 'px-pill--grey', }; @endphp {{ $appointment->status->label() }}
@empty

{{ __('common.no_upcoming') }}

{{ __('common.no_upcoming_text') }}

@endforelse
@endif {{-- ── Where the money came from ────────────────────────────── The two cards above say how much; this says from which counter. A clinic that runs its own laboratory needs to know whether a quiet day was quiet everywhere or only at reception, and one total can never answer that. --}} @if ($hasBilling && ! $takings->isEmpty())

{{ __('common.revenue_by_department') }}

{{ __('common.view_all') }}
@foreach ($takings->lines as $line) @endforeach
{{ __('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) }}
@endif @if ($hasPatients)

{{ __('common.recent_patients') }}

{{ __('common.view_all') }}
@if ($recentPatients->isEmpty())

{{ __('common.no_recent_patients') }}

{{ __('common.no_recent_patients_text') }}

@else
@foreach ($recentPatients as $patient) @endforeach
{{ $patient->fullName() }} {{ $patient->mrn }} {{ $patient->phone ?: '—' }} {{ $patient->created_at?->setTimezone($tz)->format('d M') }}
@endif
@endif
{{-- ── Right column ───────────────────────────────────────────────── --}}
@php // Only the routes that exist for the installed modules: disable // a module and its shortcut goes with it, exactly as its sidebar // entry does. $quickActions = array_values(array_filter([ Route::has('patients.create') ? ['patients.create', 'bi-person-plus'] : null, Route::has('appointments.create') ? ['appointments.create', 'bi-calendar-plus'] : null, Route::has('appointments.calendar') ? ['appointments.calendar', 'bi-calendar3'] : null, Route::has('invoices.index') ? ['invoices.index', 'bi-receipt'] : null, Route::has('encounters.index') ? ['encounters.index', 'bi-clipboard2-pulse'] : null, Route::has('prescriptions.index') ? ['prescriptions.index', 'bi-capsule'] : null, Route::has('lab.orders.index') ? ['lab.orders.index', 'bi-eyedropper'] : null, Route::has('users.index') ? ['users.index', 'bi-people'] : null, ])); $quickLabels = [ 'patients.create' => __('common.add_patient'), 'appointments.create' => __('common.new_appointment'), 'appointments.calendar' => __('common.calendar'), 'invoices.index' => __('common.billing'), 'encounters.index' => __('common.encounters'), 'prescriptions.index' => __('common.prescriptions'), 'lab.orders.index' => __('common.laboratory'), 'users.index' => __('nav.users'), ]; @endphp @if ($quickActions !== [])

{{ __('common.quick_actions') }}

@foreach (array_slice($quickActions, 0, 6) as [$routeName, $icon]) {{ $quickLabels[$routeName] ?? $routeName }} @endforeach
@endif @if ($hasAppointments)

{{ __('common.todays_schedule') }}

{{ $nowLocal->translatedFormat('l, j F') }}

@if ($todaysAppointments->isEmpty())

{{ __('common.nothing_today') }}

{{ __('common.nothing_today_text') }}

@else
    @foreach ($todaysAppointments->take(6) as $slot) @php $slotSummary = $patientSummaries[$slot->patient_id] ?? null; $past = $slot->localStart()->lessThan($nowLocal); @endphp
  1. $past])>

    {{ $slot->localStart()->format('H:i') }}

    {{ $slotSummary?->name ?? $slot->reference }}

    {{ $slot->doctor?->name }} · {{ $slot->status->label() }}

  2. @endforeach
@endif
@endif {{-- Health measured rather than asserted: the database is pinged and the disk is read, so a green row means something happened. --}}

{{ __('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)
{{ $row['label'] }} @if ($row['detail']) {{ $row['detail'] }} @endif {{ strtoupper($row['status']) }}
@endforeach
@if ($hasPatients && $genderCounts->isNotEmpty()) @php $genderSpec = [ 'labels' => $genderCounts->keys() ->map(fn ($g) => \App\Modules\Patients\Enums\Gender::from($g instanceof \App\Modules\Patients\Enums\Gender ? $g->value : $g)->label()) ->all(), 'values' => $genderCounts->values()->map(fn ($v) => (int) $v)->all(), 'colors' => $genderCounts->keys()->map(fn ($g) => match ($g instanceof \App\Modules\Patients\Enums\Gender ? $g->value : $g) { 'male' => '#2563eb', 'female' => '#ec4899', 'other' => '#f59e0b', default => '#94a3b8', })->all(), ]; @endphp

{{ __('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. --}}
  • {{ __('common.patients') }} {{ number_format($patientCount) }}
  • @foreach ($genderSpec['labels'] as $i => $genderLabel)
  • {{ $genderLabel }} {{ $genderSpec['values'][$i] }}
  • @endforeach
@endif
@endsection