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):
- Chair Utilization = booked appointment-minutes ÷ (active chairs × clinic open-minutes), per day.
- Patient flow maps onto existing appointment statuses (no new check-in state).
- Revenue-by-treatment groups by the clinic’s own procedure categories (no fixed taxonomy).
- 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.
2. Architecture
One endpoint, one cache key, additive. Extend the existingGET /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.
Banknotefor 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
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=patientsrecall flow (no send endpoint; routes to recall module)
- Today’s Appointments — big number =
today.appointments; sub-line chips:checkedInin /completeddone /cancelledcxl /noShowsno-show. - Today’s Collections —
collections.today; sub:pendingoutstanding (amber). - Chair Utilization —
chairUtilization% with a thin CSS progress bar; sub: “X of Y chair-hours”. Empty/setup state when null. - No-show Rate —
noShows / appointments% today (red accent if >0); sub: count. - Treatment Acceptance —
treatmentAcceptance%; sub: ” of plans (90d)”. Empty state when null.
- 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.
- 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.
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, subtleborder,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
divwithwidth: %and token bg; no JS.
6. Components (new, under dashboard-widgets/dental/)
| Component | Purpose | Data |
|---|---|---|
TodayFlowCard | KPI #1 + funnel chips | today |
CollectionsCard | KPI #2 | collections |
ChairUtilizationCard | KPI #3 + progress bar + setup empty state | operations.chairUtilization* |
NoShowCard | KPI #4 | today |
AcceptanceCard | KPI #5 + empty state | operations.treatmentAcceptance* |
TodayScheduleCard | second-row list + funnel | schedule, today |
RevenueByTreatmentCard | CSS bars + empty state | revenueByTreatment |
WeeklyLoadCard | 7-day CSS bars | weeklyAppointments |
ActionRequiredCard | grouped action rows | actionItems |
ProviderPerformanceCard | per-dentist table | providers |
QuickActions | primary + secondary action buttons | nav |
CssBar / CssBarRow | shared 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:
useQueryerror → toast + a retry affordance; cards fall back to zero/empty, never crash (existingModuleErrorBoundarywraps the view). nullmetrics (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 --noEmitandnpm run buildpass.- 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.

