Skip to main content

Owner Dashboard Redesign — Design Spec

Date: 2026-05-31 Scope: Redesign the clinic owner/admin dashboard (AdminOverview) to be dental-specific, owner-focused, fast. Owner-only (per decision); doctor & receptionist dashboards untouched. Decisions locked (no schema migration):
  1. Chair Utilization = booked appointment-minutes ÷ (active chairs × clinic open-minutes), per day.
  2. Patient flow maps onto existing appointment statuses (no new check-in state).
  3. Revenue-by-treatment groups by the clinic’s own procedure categories (no fixed taxonomy).
  4. Owner dashboard only.

1. Goals

Give a dental clinic owner, at a glance:
  • How busy is the clinic today (patient flow funnel)?
  • How much was collected today / this month, and what’s outstanding?
  • Are chairs being used efficiently?
  • How many cancels / no-shows?
  • Who needs follow-up (recalls, unaccepted plans, lab cases, missed appts)?
  • Which treatments generate revenue?
  • What needs action right now.
Performance is a first-class requirement: instant shell, single cached data call, CSS bars over heavy charts, lazy-load the one real chart.

2. Architecture

One endpoint, one cache key, additive. Extend the existing GET /api/v1/protected/stats/admin (server/src/routes/stats.ts) — it already resolves targetClinicId, uses PKT calendar dates (todayPKT()), is KV-cached at stats:admin:${clinicId} for 300s, and is clinic-scoped. We ADD new top-level keys to its JSON response. Existing keys (metrics, financialHistory, revenueData, appointmentData, recentAppointments, expenseBreakdown) stay intact for backward-compat (AI widgets, other consumers). New queries run via Promise.all batches to avoid serial latency. Cache TTL stays 300s. Client: Rewrite ui/src/components/admin/AdminOverview.tsx to render the new layout from the extended AdminStats. Keep using getAdminStats() (serverComm.ts). Migrate the data load to TanStack Query (useQuery, clinic-scoped key qk.adminDashboard(clinicId), staleTime 60s) so the cached payload persists and the shell paints instantly. New presentational components live under ui/src/components/dashboard-widgets/dental/. Performance plan (SPA, no SSR available):
  • Static shell + skeletons render immediately (no data wait).
  • KPI cards, CSS bar charts, lists render from JSON with zero chart-library cost.
  • The only Recharts usage (6-month revenue area trend) is lazy-loaded via React.lazy + Suspense, so the chart bundle is off the critical path.
  • Revenue-by-treatment and weekly-load use CSS bars (div widths), not Recharts.
  • Per-icon lucide imports only. Banknote for money (never DollarSign).
  • “View all” navigation for long lists; today’s schedule capped at ~8 rows with a View-all link.

3. API contract — additive fields on /stats/admin

// All amounts are numbers (PKR, no fraction). All counts integers.
today: {
  appointments: number;   // count today, excluding status 'requested'
  scheduled: number;       // status in (scheduled, confirmed) — upcoming today
  checkedIn: number;       // status in_progress — "in chair / in treatment"
  completed: number;       // status completed
  cancelled: number;       // status cancelled
  noShows: number;         // status in (no_show, missed)
};
collections: {
  today: number;           // SUM(receipts.amount) WHERE receiptDate = today AND status='issued'
  month: number;           // == metrics.revenue (cash-basis, this month)
  pending: number;         // SUM(invoices.balance) WHERE status IN (unpaid, partial, overdue)
};
operations: {
  chairUtilization: number | null;     // %, 0-100, null if no active rooms or no open hours today
  chairUtilizationDetail: {
    bookedMinutes: number; capacityMinutes: number; activeChairs: number; openMinutes: number;
  } | null;
  treatmentAcceptance: number | null;  // %, null if no decided plans in window
  treatmentAcceptanceDetail: { accepted: number; proposed: number; total: number; windowDays: 90 };
  recallDue: number;       // patient_recalls dueDate <= today AND status IN (pending, contacted, overdue)
  newPatientsThisMonth: number; // patients.createdAt >= monthStart, not soft-deleted
};
schedule: Array<{          // today's appointments, ordered by time, max 8, excluding 'requested'
  id: string; patient: string; doctor: string; time: string;
  treatmentType: string;   // appointments.appointmentType
  status: 'scheduled'|'confirmed'|'in_progress'|'completed'|'cancelled'|'no_show'|'missed';
  paymentStatus: 'paid'|'pending'|'none'; // from invoice linked via invoices.appointmentId
}>;
revenueByTreatment: Array<{ category: string; amount: number }>;
  // BILLED this month: SUM(invoice_items.subtotal) for non-cancelled invoices dated this month,
  // grouped by category via invoice_items.description = clinic_procedures.procedureName (per clinic).
  // Unmatched/null category -> 'Uncategorized'. Sorted desc, top 8 + 'Other' bucket.
weeklyAppointments: Array<{
  day: string;             // 'Mon'..'Sun', last 7 days
  booked: number;          // appts not in (cancelled, no_show, missed, requested)
  cancelled: number;
  noShows: number;         // no_show + missed
  capacity: number | null; // est. = floor(activeChairs * openMinutes(day) / 30); null if not computable
}>;
actionItems: {
  recallsOverdue: number;        // patient_recalls dueDate < today AND status IN (pending, contacted, overdue)
  unacceptedPlans: number;       // treatment_plans status='proposed' AND createdAt < today-7d
  pendingPayments: number;       // count invoices status IN (unpaid, partial, overdue)
  pendingPaymentsAmount: number; // SUM(balance) of those
  labCasesDue: number;           // lab_cases dueDate <= today AND status NOT IN (completed, rejected)
  missedToCallback: number;      // appts status IN (no_show, missed) in last 7 days
  inventoryLowStock: number;     // inventory_items WHERE quantity <= GREATEST(reorderPoint, minStock); 0 if inventory unused
};
providers: Array<{               // this month, doctors of this clinic, sorted by revenue desc
  id: string; name: string;
  patientsSeen: number;          // distinct patientId, completed appts this month
  appointments: number;          // completed appts this month
  revenue: number;               // SUM(receipts.amount) via invoice.appointmentId -> appointment.doctorId, this month
  plansProposed: number;         // treatment_plans by doctorId, createdAt this month
  acceptanceRate: number | null; // accepted/(accepted+proposed+cancelled) for that doctor's plans this month
}>;
Treatment acceptance definition: over plans created in the trailing 90 days: accepted = status IN (approved, in_progress, completed); total = accepted + proposed + cancelled; rate = accepted/total. null if total = 0. Chair utilization: bookedMinutes = SUM(durationMinutes) of today’s appts not in (cancelled, no_show, missed, requested). activeChairs = COUNT(rooms WHERE isActive). openMinutes = (closeTime − openTime) for today’s weekday from clinic_operating_hours if not isClosed. capacityMinutes = activeChairs * openMinutes. chairUtilization = min(100, round(bookedMinutes / capacityMinutes * 100)); null if activeChairs=0 or openMinutes=0.

4. Layout (12-col, responsive, mobile-stacks)

Header: greeting + primary action “Book Appointment” (was “Invite Patient”). Secondary quick actions in a compact row/menu: Add Patient, Record Payment, Create Treatment Plan, Send Recall. Each navigates to its existing create flow (endpoints already exist):
  • Book Appointment → /dashboard?view=appointments&new=1
  • Add Patient → opens existing InvitePatientModal / /dashboard?view=patients&new=1
  • Record Payment → /dashboard?view=finance-receipts
  • Create Treatment Plan → /dashboard?view=treatment-plans
  • Send Recall → /dashboard?view=patients recall flow (no send endpoint; routes to recall module)
Top KPI row (5 compact cards):
  1. Today’s Appointments — big number = today.appointments; sub-line chips: checkedIn in / completed done / cancelled cxl / noShows no-show.
  2. Today’s Collections — collections.today; sub: pending outstanding (amber).
  3. Chair Utilization — chairUtilization% with a thin CSS progress bar; sub: “X of Y chair-hours”. Empty/setup state when null.
  4. No-show Rate — noShows / appointments % today (red accent if >0); sub: count.
  5. Treatment Acceptance — treatmentAcceptance%; sub: ” of plans (90d)”. Empty state when null.
(Recall Due + New Patients shown as smaller stat chips in the Action/secondary band so the top row stays 5 wide.) Second row:
  • Today’s Schedule / Patient Flow (col-span-5): a tiny status funnel (scheduled→in-chair→completed) + a compact list of up to 8 of today’s patients: time, name, treatment type, dentist, status badge, payment-status dot. Empty state: “No appointments today.”
  • Revenue by Treatment (col-span-4): horizontal CSS bars by category, amount labels, top 8 + Other. Empty state: “No payments recorded yet this month.” (never an empty chart).
  • Weekly Appointment Load (col-span-3): 7-day CSS column bars, booked vs capacity, with cancelled/no-show count under each. Compact.
Third row:
  • Action Required (col-span-5, most prominent): grouped, color-coded, count-badged rows — Recalls overdue, Unaccepted plans, Pending payments (+amount), Lab cases due, Missed appts to call back. Each row links to the relevant module. Zero-state per row collapses; whole-section empty state when all zero.
  • Provider Performance (col-span-4): one row per dentist — name, patients seen, revenue, plans proposed, acceptance %. Hidden if only one provider or none.
  • Pending / Inventory band (col-span-3): Lab cases due + low-stock inventory alerts (reuse existing inventory low-stock if cheaply available; otherwise lab + pending payments). Keep light.
Below the fold: the existing 6-month Revenue trend (lazy Recharts area chart) + AI/Ruby insights (RequireModule ai_insights, preserved) + existing PendingBookingsCard / PendingAppointments retained.

5. UI style (matches docs/design.md)

  • Existing tokens: --primary (indigo/purple) is the accent — no new color. Green = positive, amber/orange = warning, red = urgent only.
  • Cards: bg-card, subtle border, rounded-xl, shadow-sm. Generous but compact spacing. No heavy gradients, no big animations.
  • Reuse primitives: Card, Badge, Button, Skeleton, KPICard (extend to support a footer/progress slot or wrap). Skeleton loaders only for dynamic data.
  • CSS bars: simple div with width: % and token bg; no JS.

6. Components (new, under dashboard-widgets/dental/)

ComponentPurposeData
TodayFlowCardKPI #1 + funnel chipstoday
CollectionsCardKPI #2collections
ChairUtilizationCardKPI #3 + progress bar + setup empty stateoperations.chairUtilization*
NoShowCardKPI #4today
AcceptanceCardKPI #5 + empty stateoperations.treatmentAcceptance*
TodayScheduleCardsecond-row list + funnelschedule, today
RevenueByTreatmentCardCSS bars + empty staterevenueByTreatment
WeeklyLoadCard7-day CSS barsweeklyAppointments
ActionRequiredCardgrouped action rowsactionItems
ProviderPerformanceCardper-dentist tableproviders
QuickActionsprimary + secondary action buttonsnav
CssBar / CssBarRowshared primitive
AdminOverview.tsx composes these in the 12-col grid; owns the single useQuery and passes slices down.

7. Error / empty / loading

  • Loading: skeletons sized to each card (no layout shift). The shell + grid render instantly.
  • Empty: every data section has a purpose-built empty state (esp. revenue-by-treatment, schedule, action-required, providers). Never an empty chart.
  • Error: useQuery error → toast + a retry affordance; cards fall back to zero/empty, never crash (existing ModuleErrorBoundary wraps the view).
  • null metrics (chair util, acceptance) render a “set up X” or ”—” hint, not 0%.

8. Out of scope / non-goals

  • No new DB tables/columns (per decisions).
  • No real check-in workflow (mapped to statuses).
  • No changes to doctor/receptionist/patient dashboards.
  • No new charting library (Recharts already bundled; used once, lazily).
  • “Send Recall” does not send — it routes to the recall module (no send endpoint exists).

9. Verification

  • cd server && npx tsc --noEmit (or repo’s typecheck) passes.
  • cd ui && npx tsc --noEmit and npm run build pass.
  • Endpoint returns the new keys for the canonical test tenant (ssh & Associates, clinic b6d3a3f3-…) — read-only check only, no writes (per no-live-tenant rule, confirm before any live call).
  • Visual: KPI row + sections render with skeleton→data, empty states verified by forcing zero data.