Skip to main content

Owner Dashboard Redesign Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Redesign the clinic owner/admin dashboard (AdminOverview) into a dental-specific, owner-focused, fast-loading view backed by one extended, KV-cached summary endpoint. Architecture: Extend the existing GET /api/v1/protected/stats/admin additively (new top-level keys, same 300s KV cache, same clinic scoping, same PKT dates). Isolate all non-trivial math into a pure, unit-tested server/src/lib/dashboard-metrics.ts module. Rewrite the UI to consume the extended payload via TanStack Query, render KPI cards + CSS bars instantly, and lazy-load the single Recharts trend below the fold. Tech Stack: Hono + Drizzle + Neon (server), React + Vite + Tailwind v4 + TanStack Query v5 + Recharts (ui), Vitest (tests). Design tokens per docs/design.md (indigo --primary accent; Banknote not DollarSign; per-icon lucide imports). Spec: docs/superpowers/specs/2026-05-31-owner-dashboard-redesign-design.md Conventions reminder:
  • Do NOT run anything against live tenants/prod DB without per-action confirmation (read-only included).
  • Commit only when the user asks (use the odontox-commit-deploy skill). The git commit steps below are the logical checkpoints; batch or defer per the user’s commit policy.
  • Money: Banknote icon, formatCurrency/formatCompactCurrency from @/lib/currency.

File Structure

Server
  • Create server/src/lib/dashboard-metrics.ts — pure functions (no DB/IO): time math, flow counts, chair utilization, acceptance rate, revenue bucketing, provider assembly.
  • Create server/src/lib/dashboard-metrics.test.ts — vitest unit tests.
  • Modify server/src/routes/stats.ts — inside the existing stats.get('/admin', …) handler, add the new queries + assemble new response keys via the helpers.
UI
  • Modify ui/src/lib/serverComm.ts — extend the AdminStats interface with the new optional fields.
  • Modify ui/src/lib/queryKeys.ts — add adminDashboard key.
  • Create ui/src/components/dashboard-widgets/dental/CssBar.tsx — shared CSS bar primitives + a small StatChip.
  • Create ui/src/components/dashboard-widgets/dental/TodayFlowCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/CollectionsCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/ChairUtilizationCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/NoShowCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/AcceptanceCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/TodayScheduleCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/RevenueByTreatmentCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/WeeklyLoadCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/ActionRequiredCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/ProviderPerformanceCard.tsx
  • Create ui/src/components/dashboard-widgets/dental/OwnerQuickActions.tsx
  • Create ui/src/components/dashboard-widgets/dental/RevenueTrendChart.tsx — lazy Recharts area (extracted so it can be code-split).
  • Modify ui/src/components/admin/AdminOverview.tsx — compose the new layout; switch to useQuery.

Task 1: Pure metrics helpers (TDD)

Files:
  • Create: server/src/lib/dashboard-metrics.ts
  • Test: server/src/lib/dashboard-metrics.test.ts
  • Step 1: Write the failing tests
// server/src/lib/dashboard-metrics.test.ts
import { describe, it, expect } from 'vitest';
import {
  minutesBetween,
  weekdayKey,
  summarizeTodayFlow,
  computeChairUtilization,
  computeAcceptanceRate,
  bucketRevenueByTreatment,
} from './dashboard-metrics';

describe('minutesBetween', () => {
  it('computes minute span of two HH:MM[:SS] strings', () => {
    expect(minutesBetween('09:00', '17:00')).toBe(480);
    expect(minutesBetween('09:00:00', '17:30:00')).toBe(510);
  });
  it('returns 0 for null/invalid/negative', () => {
    expect(minutesBetween(null, '17:00')).toBe(0);
    expect(minutesBetween('17:00', '09:00')).toBe(0);
    expect(minutesBetween('bad', '17:00')).toBe(0);
  });
});

describe('weekdayKey', () => {
  it('maps an ISO date string to a lowercase day key', () => {
    // 2026-06-01 is a Monday
    expect(weekdayKey('2026-06-01')).toBe('monday');
    expect(weekdayKey('2026-05-31')).toBe('sunday');
  });
});

describe('summarizeTodayFlow', () => {
  it('counts by mapped status and excludes requested from total', () => {
    const flow = summarizeTodayFlow([
      { status: 'scheduled' }, { status: 'confirmed' }, { status: 'in_progress' },
      { status: 'completed' }, { status: 'cancelled' }, { status: 'no_show' },
      { status: 'missed' }, { status: 'requested' },
    ]);
    expect(flow).toEqual({
      appointments: 7, scheduled: 2, checkedIn: 1, completed: 1, cancelled: 1, noShows: 2,
    });
  });
});

describe('computeChairUtilization', () => {
  it('returns percentage capped at 100 with detail', () => {
    const r = computeChairUtilization({ bookedMinutes: 240, activeChairs: 2, openMinutes: 480 });
    expect(r).toEqual({
      chairUtilization: 25,
      chairUtilizationDetail: { bookedMinutes: 240, capacityMinutes: 960, activeChairs: 2, openMinutes: 480 },
    });
  });
  it('caps at 100 when overbooked', () => {
    expect(computeChairUtilization({ bookedMinutes: 2000, activeChairs: 1, openMinutes: 480 }).chairUtilization).toBe(100);
  });
  it('returns null when chairs or hours are zero', () => {
    expect(computeChairUtilization({ bookedMinutes: 100, activeChairs: 0, openMinutes: 480 })).toEqual({ chairUtilization: null, chairUtilizationDetail: null });
    expect(computeChairUtilization({ bookedMinutes: 100, activeChairs: 2, openMinutes: 0 })).toEqual({ chairUtilization: null, chairUtilizationDetail: null });
  });
});

describe('computeAcceptanceRate', () => {
  it('treats approved/in_progress/completed as accepted; proposed+cancelled count against', () => {
    const r = computeAcceptanceRate([
      { status: 'approved' }, { status: 'in_progress' }, { status: 'completed' },
      { status: 'proposed' }, { status: 'cancelled' },
    ]);
    expect(r).toEqual({ treatmentAcceptance: 60, accepted: 3, proposed: 1, total: 5 });
  });
  it('returns null rate when no plans', () => {
    expect(computeAcceptanceRate([])).toEqual({ treatmentAcceptance: null, accepted: 0, proposed: 0, total: 0 });
  });
});

describe('bucketRevenueByTreatment', () => {
  it('sums by category, sorts desc, and rolls overflow into Other', () => {
    const rows = [
      { category: 'Scaling', amount: 100 }, { category: 'Scaling', amount: 50 },
      { category: 'Crowns', amount: 300 }, { category: 'Implants', amount: 200 },
      { category: 'A', amount: 9 }, { category: 'B', amount: 8 }, { category: 'C', amount: 7 },
      { category: 'D', amount: 6 }, { category: 'E', amount: 5 }, { category: 'F', amount: 4 },
    ];
    const out = bucketRevenueByTreatment(rows, 8);
    expect(out[0]).toEqual({ category: 'Crowns', amount: 300 });
    expect(out.length).toBe(8);
    expect(out[out.length - 1]).toEqual({ category: 'Other', amount: 9 }); // E(5)+F(4)
  });
  it('returns empty array for no rows', () => {
    expect(bucketRevenueByTreatment([], 8)).toEqual([]);
  });
});
  • Step 2: Run tests to verify they fail
Run: cd server && npx vitest run src/lib/dashboard-metrics.test.ts Expected: FAIL — cannot resolve ./dashboard-metrics.
  • Step 3: Implement the helpers
// server/src/lib/dashboard-metrics.ts
// Pure, dependency-free helpers for the owner dashboard summary.
// Kept free of DB/IO so they are unit-testable in isolation.

export type ApptStatus =
  | 'requested' | 'scheduled' | 'confirmed' | 'in_progress'
  | 'completed' | 'cancelled' | 'no_show' | 'missed';

const DAY_KEYS = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;
export type DayKey = typeof DAY_KEYS[number];

/** Minutes between two 'HH:MM' or 'HH:MM:SS' strings. 0 if invalid/negative/null. */
export function minutesBetween(open: string | null | undefined, close: string | null | undefined): number {
  const toMin = (t?: string | null): number | null => {
    if (!t) return null;
    const m = /^(\d{1,2}):(\d{2})/.exec(t);
    if (!m) return null;
    const h = Number(m[1]); const mm = Number(m[2]);
    if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
    return h * 60 + mm;
  };
  const a = toMin(open); const b = toMin(close);
  if (a == null || b == null) return 0;
  return Math.max(0, b - a);
}

/** Lowercase day-of-week key for a 'YYYY-MM-DD' date (parsed as PKT-local midday to avoid TZ edges). */
export function weekdayKey(dateStr: string): DayKey {
  const d = new Date(`${dateStr}T12:00:00+05:00`);
  return DAY_KEYS[d.getUTCDay() === 0 ? 0 : d.getUTCDay()] ?? 'monday';
}

export interface TodayFlow {
  appointments: number; scheduled: number; checkedIn: number;
  completed: number; cancelled: number; noShows: number;
}

/** Counts today's appts by mapped status. 'requested' is excluded from the day total. */
export function summarizeTodayFlow(appts: Array<{ status: ApptStatus | string }>): TodayFlow {
  const f: TodayFlow = { appointments: 0, scheduled: 0, checkedIn: 0, completed: 0, cancelled: 0, noShows: 0 };
  for (const a of appts) {
    const s = a.status;
    if (s === 'requested') continue;
    f.appointments += 1;
    if (s === 'scheduled' || s === 'confirmed') f.scheduled += 1;
    else if (s === 'in_progress') f.checkedIn += 1;
    else if (s === 'completed') f.completed += 1;
    else if (s === 'cancelled') f.cancelled += 1;
    else if (s === 'no_show' || s === 'missed') f.noShows += 1;
  }
  return f;
}

export interface ChairUtil {
  chairUtilization: number | null;
  chairUtilizationDetail: { bookedMinutes: number; capacityMinutes: number; activeChairs: number; openMinutes: number } | null;
}

export function computeChairUtilization(input: { bookedMinutes: number; activeChairs: number; openMinutes: number }): ChairUtil {
  const { bookedMinutes, activeChairs, openMinutes } = input;
  const capacityMinutes = activeChairs * openMinutes;
  if (activeChairs <= 0 || openMinutes <= 0 || capacityMinutes <= 0) {
    return { chairUtilization: null, chairUtilizationDetail: null };
  }
  const pct = Math.min(100, Math.round((bookedMinutes / capacityMinutes) * 100));
  return { chairUtilization: pct, chairUtilizationDetail: { bookedMinutes, capacityMinutes, activeChairs, openMinutes } };
}

export interface Acceptance { treatmentAcceptance: number | null; accepted: number; proposed: number; total: number; }

export function computeAcceptanceRate(plans: Array<{ status: string }>): Acceptance {
  let accepted = 0, proposed = 0, total = 0;
  for (const p of plans) {
    if (p.status === 'approved' || p.status === 'in_progress' || p.status === 'completed') { accepted += 1; total += 1; }
    else if (p.status === 'proposed') { proposed += 1; total += 1; }
    else if (p.status === 'cancelled') { total += 1; }
  }
  return { treatmentAcceptance: total > 0 ? Math.round((accepted / total) * 100) : null, accepted, proposed, total };
}

/** Sum amounts per category, sort desc, keep top (n-1) and roll the rest into 'Other'. */
export function bucketRevenueByTreatment(rows: Array<{ category: string; amount: number }>, topN = 8): Array<{ category: string; amount: number }> {
  if (!rows.length) return [];
  const byCat = new Map<string, number>();
  for (const r of rows) {
    const key = (r.category && r.category.trim()) || 'Uncategorized';
    byCat.set(key, (byCat.get(key) ?? 0) + (Number(r.amount) || 0));
  }
  const sorted = [...byCat.entries()].map(([category, amount]) => ({ category, amount })).sort((a, b) => b.amount - a.amount);
  if (sorted.length <= topN) return sorted;
  const head = sorted.slice(0, topN - 1);
  const otherAmount = sorted.slice(topN - 1).reduce((s, x) => s + x.amount, 0);
  return [...head, { category: 'Other', amount: otherAmount }];
}
  • Step 4: Run tests to verify they pass
Run: cd server && npx vitest run src/lib/dashboard-metrics.test.ts Expected: PASS (all suites green).
  • Step 5: Commit (only if user’s commit policy allows now)
git add server/src/lib/dashboard-metrics.ts server/src/lib/dashboard-metrics.test.ts
git commit -m "feat(stats): pure dashboard-metrics helpers + tests"

Task 2: Extend the /stats/admin endpoint

Files:
  • Modify: server/src/routes/stats.ts — the stats.get('/admin', …) handler (~lines 588–1103). Append new queries after the existing ones and add new keys to responseData (before the kv.set + c.json).
Context already available in scope: db = getReadDb(), targetClinicId, todayStr (PKT today), monthStart, monthStartStr, today, weekAgoStr. Imports already include eq, and, gte, lte, sql, count, sum, desc, inArray, isNull and schema.
  • Step 1: Add imports for new tables + helpers at the top of stats.ts
Ensure these schema tables are reachable via schema.* (they are, schema is the barrel). Add the helper import near the other lib imports:
import {
  minutesBetween, weekdayKey, summarizeTodayFlow,
  computeChairUtilization, computeAcceptanceRate, bucketRevenueByTreatment,
} from '../lib/dashboard-metrics';
  • Step 2: Inside the /admin handler, after todayAppointments is built (~line 1016) and before “Calculate trends”, add the dental queries
    // ===== Dental owner-dashboard additions =====
    const monthStartDateStr = monthStartStr; // 'YYYY-MM-DD' for date columns
    const sevenDaysAgo = new Date(today); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
    const sevenDaysAgoStr = sevenDaysAgo.toISOString().split('T')[0];
    const todayWeekday = weekdayKey(todayStr);

    const [
      todayApptsAll,            // all today's appts (status only) for flow counts
      collectionsTodayRow,      // receipts today
      pendingRows,              // invoices with outstanding balance
      activeRoomsRow,           // count active rooms
      operatingRows,            // clinic operating hours (all days)
      acceptancePlans,          // treatment plans last 90d
      recallDueRow, recallOverdueRow,
      labDueRow,
      missedRow,
      inventoryLowRow,
      newPatientsRow,
      revenueItemRows,          // invoice_items this month + description
      clinicProcRows,           // procedure name -> category map
      weeklyRows,               // last 7 days appts (date+status) for booked/cxl/noshow
    ] = await Promise.all([
      db.select({ status: schema.appointments.status, durationMinutes: schema.appointments.durationMinutes })
        .from(schema.appointments)
        .where(and(eq(schema.appointments.clinicId, targetClinicId), eq(schema.appointments.appointmentDate, todayStr))),

      db.select({ total: sum(schema.receipts.amount) })
        .from(schema.receipts)
        .where(and(eq(schema.receipts.clinicId, targetClinicId), eq(schema.receipts.receiptDate, todayStr), eq(schema.receipts.status, 'issued'))),

      db.select({ balance: schema.invoices.balance })
        .from(schema.invoices)
        .where(and(eq(schema.invoices.clinicId, targetClinicId), inArray(schema.invoices.status, ['unpaid', 'partial', 'overdue']))),

      db.select({ count: count() }).from(schema.rooms)
        .where(and(eq(schema.rooms.clinicId, targetClinicId), eq(schema.rooms.isActive, true))),

      db.select({ dayOfWeek: schema.clinicOperatingHours.dayOfWeek, openTime: schema.clinicOperatingHours.openTime, closeTime: schema.clinicOperatingHours.closeTime, isClosed: schema.clinicOperatingHours.isClosed })
        .from(schema.clinicOperatingHours)
        .where(eq(schema.clinicOperatingHours.clinicId, targetClinicId)),

      db.select({ status: schema.treatmentPlans.status })
        .from(schema.treatmentPlans)
        .where(and(eq(schema.treatmentPlans.clinicId, targetClinicId), gte(schema.treatmentPlans.createdAt, new Date(`${sevenDaysAgoStr}T00:00:00+05:00`)))), // replaced below with 90d

      db.select({ count: count() }).from(schema.patientRecalls)
        .where(and(eq(schema.patientRecalls.clinicId, targetClinicId), lte(schema.patientRecalls.dueDate, todayStr), inArray(schema.patientRecalls.status, ['pending', 'contacted', 'overdue']))),
      db.select({ count: count() }).from(schema.patientRecalls)
        .where(and(eq(schema.patientRecalls.clinicId, targetClinicId), lte(schema.patientRecalls.dueDate, todayStr), inArray(schema.patientRecalls.status, ['pending', 'contacted', 'overdue']))),

      db.select({ count: count() }).from(schema.labCases)
        .where(and(eq(schema.labCases.clinicId, targetClinicId), lte(schema.labCases.dueDate, todayStr), inArray(schema.labCases.status, ['pending', 'received', 'in_progress', 'delayed']))),

      db.select({ count: count() }).from(schema.appointments)
        .where(and(eq(schema.appointments.clinicId, targetClinicId), inArray(schema.appointments.status, ['no_show', 'missed']), gte(schema.appointments.appointmentDate, sevenDaysAgoStr), lte(schema.appointments.appointmentDate, todayStr))),

      db.select({ count: count() }).from(schema.inventoryItems)
        .where(and(eq(schema.inventoryItems.clinicId, targetClinicId), sql`${schema.inventoryItems.quantity} <= GREATEST(COALESCE(${schema.inventoryItems.reorderPoint}, 0), ${schema.inventoryItems.minStock})`)),

      db.select({ count: count() }).from(schema.patients)
        .where(and(eq(schema.patients.clinicId, targetClinicId), isNull(schema.patients.deletedAt), gte(schema.patients.createdAt, monthStart))),

      db.select({ description: schema.invoiceItems.description, subtotal: schema.invoiceItems.subtotal })
        .from(schema.invoiceItems)
        .innerJoin(schema.invoices, eq(schema.invoiceItems.invoiceId, schema.invoices.id))
        .where(and(eq(schema.invoices.clinicId, targetClinicId), gte(schema.invoices.invoiceDate, monthStartDateStr), sql`${schema.invoices.status} <> 'cancelled'`)),

      db.select({ procedureName: schema.clinicProcedures.procedureName, category: schema.clinicProcedures.category })
        .from(schema.clinicProcedures)
        .where(eq(schema.clinicProcedures.clinicId, targetClinicId)),

      db.select({ date: schema.appointments.appointmentDate, status: schema.appointments.status })
        .from(schema.appointments)
        .where(and(eq(schema.appointments.clinicId, targetClinicId), gte(schema.appointments.appointmentDate, sevenDaysAgoStr), lte(schema.appointments.appointmentDate, todayStr))),
    ]);
NOTE: replace the acceptancePlans query’s date bound with the real 90-day window (it is written with the 7-day var above only to keep the array shape obvious during editing). Use:
const ninetyDaysAgo = new Date(today); ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
// and gate the treatmentPlans query with: gte(schema.treatmentPlans.createdAt, ninetyDaysAgo)
Define ninetyDaysAgo above the Promise.all and use it in that one query.
  • Step 3: Build the derived structures (after the Promise.all)
    // Flow + chair utilization
    const todayFlow = summarizeTodayFlow(todayApptsAll as Array<{ status: string }>);
    const bookedMinutesToday = (todayApptsAll as Array<{ status: string; durationMinutes: number | null }>)
      .filter(a => !['cancelled', 'no_show', 'missed', 'requested'].includes(a.status))
      .reduce((s, a) => s + (a.durationMinutes ?? 0), 0);
    const activeChairs = parseInt(activeRoomsRow[0]?.count?.toString() || '0');
    const todayHours = operatingRows.find(r => r.dayOfWeek === todayWeekday);
    const openMinutesToday = todayHours && !todayHours.isClosed ? minutesBetween(todayHours.openTime, todayHours.closeTime) : 0;
    const chair = computeChairUtilization({ bookedMinutes: bookedMinutesToday, activeChairs, openMinutes: openMinutesToday });

    // Collections / pending
    const collectionsToday = parseFloat(collectionsTodayRow[0]?.total || '0');
    const pendingPaymentsAmount = pendingRows.reduce((s, r) => s + parseFloat(r.balance || '0'), 0);
    const pendingPaymentsCount = pendingRows.length;

    // Acceptance
    const acceptance = computeAcceptanceRate(acceptancePlans as Array<{ status: string }>);

    // Revenue by treatment: resolve category via procedureName map (case-insensitive)
    const catByName = new Map<string, string>();
    for (const p of clinicProcRows) catByName.set((p.procedureName || '').trim().toLowerCase(), (p.category || '').trim());
    const revenueRows = (revenueItemRows as Array<{ description: string; subtotal: string }>).map(r => ({
      category: catByName.get((r.description || '').trim().toLowerCase()) || 'Uncategorized',
      amount: parseFloat(r.subtotal || '0'),
    }));
    const revenueByTreatment = bucketRevenueByTreatment(revenueRows, 8);

    // Weekly load (last 7 days): booked / cancelled / no-shows + capacity from chairs*openMinutes(day)
    const dayOrder: string[] = [];
    const weekMap: Record<string, { booked: number; cancelled: number; noShows: number; capacity: number | null }> = {};
    for (let i = 6; i >= 0; i--) {
      const d = new Date(today); d.setDate(d.getDate() - i);
      const ds = d.toISOString().split('T')[0];
      const label = d.toLocaleString('en-US', { weekday: 'short' });
      dayOrder.push(label);
      const wk = weekdayKey(ds);
      const hrs = operatingRows.find(r => r.dayOfWeek === wk);
      const openMin = hrs && !hrs.isClosed ? minutesBetween(hrs.openTime, hrs.closeTime) : 0;
      const capacity = activeChairs > 0 && openMin > 0 ? Math.floor((activeChairs * openMin) / 30) : null;
      weekMap[ds] = { booked: 0, cancelled: 0, noShows: 0, capacity };
    }
    for (const row of weeklyRows as Array<{ date: string; status: string }>) {
      const bucket = weekMap[row.date]; if (!bucket) continue;
      if (row.status === 'cancelled') bucket.cancelled += 1;
      else if (row.status === 'no_show' || row.status === 'missed') bucket.noShows += 1;
      else if (row.status !== 'requested') bucket.booked += 1;
    }
    const weeklyAppointments = (() => {
      const out: Array<{ day: string; booked: number; cancelled: number; noShows: number; capacity: number | null }> = [];
      for (let i = 6; i >= 0; i--) {
        const d = new Date(today); d.setDate(d.getDate() - i);
        const ds = d.toISOString().split('T')[0];
        const label = d.toLocaleString('en-US', { weekday: 'short' });
        out.push({ day: label, ...weekMap[ds] });
      }
      return out;
    })();
  • Step 4: Build today’s schedule with payment status + provider performance
    // Today's schedule (up to 8) with treatment type + payment status
    const scheduleRaw = await db
      .select({
        id: schema.appointments.id,
        patientFirst: schema.patients.firstName,
        patientLast: schema.patients.lastName,
        doctorId: schema.appointments.doctorId,
        time: schema.appointments.appointmentTime,
        treatmentType: schema.appointments.appointmentType,
        status: schema.appointments.status,
        invStatus: schema.invoices.status,
      })
      .from(schema.appointments)
      .leftJoin(schema.patients, eq(schema.appointments.patientId, schema.patients.id))
      .leftJoin(schema.invoices, eq(schema.invoices.appointmentId, schema.appointments.id))
      .where(and(
        eq(schema.appointments.clinicId, targetClinicId),
        eq(schema.appointments.appointmentDate, todayStr),
        sql`${schema.appointments.status} <> 'requested'`,
      ))
      .orderBy(schema.appointments.appointmentTime)
      .limit(8);

    const schedDoctorIds = scheduleRaw.map(r => r.doctorId).filter(Boolean) as string[];
    const schedDoctors = schedDoctorIds.length
      ? await db.select({ id: schema.users.id, firstName: schema.users.firstName, lastName: schema.users.lastName }).from(schema.users).where(inArray(schema.users.id, schedDoctorIds))
      : [];
    const paymentStatusOf = (invStatus: string | null): 'paid' | 'pending' | 'none' => {
      if (!invStatus || invStatus === 'cancelled') return 'none';
      if (invStatus === 'paid') return 'paid';
      return 'pending'; // unpaid, partial, overdue
    };
    const schedule = scheduleRaw.map(r => {
      const doc = schedDoctors.find(d => d.id === r.doctorId);
      return {
        id: r.id,
        patient: r.patientFirst ? `${r.patientFirst} ${r.patientLast ?? ''}`.trim() : 'Unknown',
        doctor: doc ? `Dr. ${doc.firstName} ${doc.lastName}` : 'TBD',
        time: r.time || '',
        treatmentType: r.treatmentType || '',
        status: r.status,
        paymentStatus: paymentStatusOf(r.invStatus),
      };
    });

    // Provider performance (this month)
    const [completedThisMonth, plansThisMonth, receiptsByDoctor] = await Promise.all([
      db.select({ doctorId: schema.appointments.doctorId, patientId: schema.appointments.patientId })
        .from(schema.appointments)
        .where(and(eq(schema.appointments.clinicId, targetClinicId), eq(schema.appointments.status, 'completed'), gte(schema.appointments.appointmentDate, monthStartDateStr), lte(schema.appointments.appointmentDate, todayStr))),
      db.select({ doctorId: schema.treatmentPlans.doctorId, status: schema.treatmentPlans.status })
        .from(schema.treatmentPlans)
        .where(and(eq(schema.treatmentPlans.clinicId, targetClinicId), gte(schema.treatmentPlans.createdAt, monthStart))),
      db.select({ doctorId: schema.appointments.doctorId, amount: schema.receipts.amount })
        .from(schema.receipts)
        .innerJoin(schema.invoices, eq(schema.receipts.invoiceId, schema.invoices.id))
        .innerJoin(schema.appointments, eq(schema.invoices.appointmentId, schema.appointments.id))
        .where(and(eq(schema.receipts.clinicId, targetClinicId), eq(schema.receipts.status, 'issued'), gte(schema.receipts.receiptDate, monthStartDateStr), lte(schema.receipts.receiptDate, todayStr))),
    ]);

    // Resolve doctor display names by the IDs that actually appear in the aggregates
    // (clinic-scoped already via the queries above). Robust to multi-clinic doctors
    // whose membership lives in primaryClinicId/assignments rather than users.clinicId.
    const provDoctorIds = Array.from(new Set([
      ...completedThisMonth.map(r => r.doctorId),
      ...plansThisMonth.map(r => r.doctorId),
      ...receiptsByDoctor.map(r => r.doctorId),
    ].filter(Boolean))) as string[];
    const clinicDoctors = provDoctorIds.length
      ? await db.select({ id: schema.users.id, firstName: schema.users.firstName, lastName: schema.users.lastName })
          .from(schema.users).where(inArray(schema.users.id, provDoctorIds))
      : [];

    type Prov = { id: string; name: string; patientsSeen: number; appointments: number; revenue: number; plansProposed: number; accepted: number; total: number };
    const provMap = new Map<string, Prov>();
    const ensure = (id: string) => {
      if (!provMap.has(id)) {
        const d = clinicDoctors.find(x => x.id === id);
        provMap.set(id, { id, name: d ? `Dr. ${d.firstName} ${d.lastName}` : 'Unknown', patientsSeen: 0, appointments: 0, revenue: 0, plansProposed: 0, accepted: 0, total: 0 });
      }
      return provMap.get(id)!;
    };
    const seenPatients = new Map<string, Set<string>>();
    for (const r of completedThisMonth) {
      if (!r.doctorId) continue;
      const p = ensure(r.doctorId); p.appointments += 1;
      if (!seenPatients.has(r.doctorId)) seenPatients.set(r.doctorId, new Set());
      if (r.patientId) seenPatients.get(r.doctorId)!.add(r.patientId);
    }
    for (const [id, set] of seenPatients) ensure(id).patientsSeen = set.size;
    for (const r of plansThisMonth) {
      if (!r.doctorId) continue; const p = ensure(r.doctorId);
      if (r.status === 'proposed') { p.plansProposed += 1; p.total += 1; }
      else if (r.status === 'approved' || r.status === 'in_progress' || r.status === 'completed') { p.accepted += 1; p.total += 1; }
      else if (r.status === 'cancelled') { p.total += 1; }
    }
    for (const r of receiptsByDoctor) { if (!r.doctorId) continue; ensure(r.doctorId).revenue += parseFloat(r.amount || '0'); }
    const providers = [...provMap.values()]
      .map(p => ({ id: p.id, name: p.name, patientsSeen: p.patientsSeen, appointments: p.appointments, revenue: p.revenue, plansProposed: p.plansProposed, acceptanceRate: p.total > 0 ? Math.round((p.accepted / p.total) * 100) : null }))
      .sort((a, b) => b.revenue - a.revenue);
  • Step 5: Add the new keys to responseData
Extend the existing const responseData = { … } object (before kv.set) with:
      today: todayFlow,
      collections: { today: collectionsToday, month: revenueAmount, pending: pendingPaymentsAmount },
      operations: {
        chairUtilization: chair.chairUtilization,
        chairUtilizationDetail: chair.chairUtilizationDetail,
        treatmentAcceptance: acceptance.treatmentAcceptance,
        treatmentAcceptanceDetail: { accepted: acceptance.accepted, proposed: acceptance.proposed, total: acceptance.total, windowDays: 90 },
        recallDue: parseInt(recallDueRow[0]?.count?.toString() || '0'),
        newPatientsThisMonth: parseInt(newPatientsRow[0]?.count?.toString() || '0'),
      },
      schedule,
      revenueByTreatment,
      weeklyAppointments,
      actionItems: {
        recallsOverdue: parseInt(recallOverdueRow[0]?.count?.toString() || '0'),
        unacceptedPlans: 0, // set in Step 6
        pendingPayments: pendingPaymentsCount,
        pendingPaymentsAmount,
        labCasesDue: parseInt(labDueRow[0]?.count?.toString() || '0'),
        missedToCallback: parseInt(missedRow[0]?.count?.toString() || '0'),
        inventoryLowStock: parseInt(inventoryLowRow[0]?.count?.toString() || '0'),
      },
      providers,
  • Step 6: Add the unacceptedPlans query (proposed plans older than 7 days)
Add to the first Promise.all array and wire into actionItems.unacceptedPlans:
      db.select({ count: count() }).from(schema.treatmentPlans)
        .where(and(eq(schema.treatmentPlans.clinicId, targetClinicId), eq(schema.treatmentPlans.status, 'proposed'), lte(schema.treatmentPlans.createdAt, sevenDaysAgo))),
Capture as unacceptedPlansRow and set unacceptedPlans: parseInt(unacceptedPlansRow[0]?.count?.toString() || '0').
  • Step 7: Typecheck the server
Run: cd server && npx tsc --noEmit Expected: PASS (0 errors). Fix any column-name mismatches against the schema files (e.g., schema.inventoryItems.reorderPoint, schema.inventoryItems.minStock, schema.users.clinicId). If schema.users.clinicId is not the right column for clinic membership, use schema.users.primaryClinicId — verify in server/src/schema/users.ts.
  • Step 8: Commit
git add server/src/routes/stats.ts
git commit -m "feat(stats): dental owner-dashboard sections on /stats/admin"

Task 3: Extend the AdminStats TypeScript interface (UI)

Files:
  • Modify: ui/src/lib/serverComm.ts:2944-2980 (the AdminStats interface).
  • Step 1: Add the new optional fields to AdminStats (optional so partial/old cached payloads don’t break types):
  // ===== Owner dashboard (dental) — additive =====
  today?: {
    appointments: number; scheduled: number; checkedIn: number;
    completed: number; cancelled: number; noShows: number;
  };
  collections?: { today: number; month: number; pending: number };
  operations?: {
    chairUtilization: number | null;
    chairUtilizationDetail: { bookedMinutes: number; capacityMinutes: number; activeChairs: number; openMinutes: number } | null;
    treatmentAcceptance: number | null;
    treatmentAcceptanceDetail: { accepted: number; proposed: number; total: number; windowDays: number };
    recallDue: number;
    newPatientsThisMonth: number;
  };
  schedule?: Array<{
    id: string; patient: string; doctor: string; time: string;
    treatmentType: string;
    status: string;
    paymentStatus: 'paid' | 'pending' | 'none';
  }>;
  revenueByTreatment?: Array<{ category: string; amount: number }>;
  weeklyAppointments?: Array<{ day: string; booked: number; cancelled: number; noShows: number; capacity: number | null }>;
  actionItems?: {
    recallsOverdue: number; unacceptedPlans: number;
    pendingPayments: number; pendingPaymentsAmount: number;
    labCasesDue: number; missedToCallback: number; inventoryLowStock: number;
  };
  providers?: Array<{
    id: string; name: string; patientsSeen: number; appointments: number;
    revenue: number; plansProposed: number; acceptanceRate: number | null;
  }>;
  • Step 2: Typecheck
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 3: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(ui): extend AdminStats with dental dashboard fields"

Task 4: Query key + shared CSS bar primitives

Files:
  • Modify: ui/src/lib/queryKeys.ts — add an admin-dashboard key under the existing qk factory, clinic-scoped (follow the file’s clinicScope() pattern; read 3–4 neighbouring keys first to match the exact shape).
  • Create: ui/src/components/dashboard-widgets/dental/CssBar.tsx
  • Step 1: Add the query key
In ui/src/lib/queryKeys.ts, clinicScope() takes no arguments (it reads the active clinic from queryClient) and returns a string. Keys are ['resource', clinicScope(), ...sub]. Add this nested key to the qk object, mirroring inventory/receipts:
  adminDashboard: {
    summary: () => ['admin-dashboard', clinicScope()] as const,
  },
  • Step 2: Create the CSS bar primitives
// ui/src/components/dashboard-widgets/dental/CssBar.tsx
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';

/** Horizontal labelled bar (for revenue-by-treatment). Pure CSS, no chart lib. */
export function CssBarRow({ label, valueLabel, ratio, accent = 'primary' }: {
  label: string; valueLabel: string; ratio: number; accent?: 'primary' | 'green' | 'amber';
}) {
  const pct = Math.max(2, Math.min(100, Math.round(ratio * 100)));
  const bg = accent === 'green' ? 'bg-green-500/80' : accent === 'amber' ? 'bg-amber-500/80' : 'bg-primary/80';
  return (
    <div className="space-y-1">
      <div className="flex items-center justify-between text-xs">
        <span className="truncate text-foreground/80">{label}</span>
        <span className="font-medium tabular-nums">{valueLabel}</span>
      </div>
      <div className="h-2 w-full rounded-full bg-muted">
        <div className={cn('h-2 rounded-full transition-[width]', bg)} style={{ width: `${pct}%` }} />
      </div>
    </div>
  );
}

/** Vertical column for weekly-load (booked vs capacity). */
export function CssColumn({ heightPct, capacityPct, label, sub }: {
  heightPct: number; capacityPct?: number | null; label: string; sub?: ReactNode;
}) {
  return (
    <div className="flex flex-1 flex-col items-center gap-1">
      <div className="relative flex h-24 w-full items-end justify-center">
        {capacityPct != null && (
          <div className="absolute bottom-0 w-6 rounded-t bg-muted" style={{ height: `${Math.max(2, Math.min(100, capacityPct))}%` }} />
        )}
        <div className="relative w-6 rounded-t bg-primary/80" style={{ height: `${Math.max(2, Math.min(100, heightPct))}%` }} />
      </div>
      <span className="text-[11px] text-muted-foreground">{label}</span>
      {sub}
    </div>
  );
}

/** Compact inline stat chip for funnel/footer rows. */
export function StatChip({ icon, label, value, tone = 'default' }: {
  icon?: ReactNode; label: string; value: ReactNode; tone?: 'default' | 'green' | 'amber' | 'red';
}) {
  const toneCls = tone === 'green' ? 'text-green-600 dark:text-green-500'
    : tone === 'amber' ? 'text-amber-600 dark:text-amber-500'
    : tone === 'red' ? 'text-red-600 dark:text-red-500' : 'text-muted-foreground';
  return (
    <span className="inline-flex items-center gap-1 text-xs">
      {icon}
      <span className={cn('font-semibold tabular-nums', toneCls)}>{value}</span>
      <span className="text-muted-foreground">{label}</span>
    </span>
  );
}
  • Step 3: Typecheck
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 4: Commit
git add ui/src/lib/queryKeys.ts ui/src/components/dashboard-widgets/dental/CssBar.tsx
git commit -m "feat(ui): dashboard query key + CSS bar primitives"

Tasks 5–14: Dental presentational components

These are independent new files consuming typed slices of AdminStats. They can be built in parallel. Each imports primitives from ../../ui/card, ../../ui/badge, @/lib/utils, @/lib/currency, and per-icon lucide-react. None fetch data. Each handles its empty/null state. After all are written, run cd ui && npx tsc --noEmit.
Common import header for each component:
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { formatCurrency, formatCompactCurrency } from '@/lib/currency';
import { cn } from '@/lib/utils';
import type { AdminStats } from '@/lib/serverComm';

Task 5: TodayFlowCard (KPI #1)

File: Create ui/src/components/dashboard-widgets/dental/TodayFlowCard.tsx
import { Calendar, UserCheck, CheckCircle2, XCircle, Ban } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatChip } from './CssBar';
import type { AdminStats } from '@/lib/serverComm';

export function TodayFlowCard({ today }: { today?: AdminStats['today'] }) {
  const t = today ?? { appointments: 0, scheduled: 0, checkedIn: 0, completed: 0, cancelled: 0, noShows: 0 };
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">Today's Appointments</CardTitle>
        <Calendar className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold leading-tight">{t.appointments}</div>
        <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1">
          <StatChip icon={<UserCheck className="h-3 w-3" />} value={t.checkedIn} label="in chair" />
          <StatChip icon={<CheckCircle2 className="h-3 w-3" />} value={t.completed} label="done" tone="green" />
          <StatChip icon={<XCircle className="h-3 w-3" />} value={t.cancelled} label="cxl" tone="amber" />
          <StatChip icon={<Ban className="h-3 w-3" />} value={t.noShows} label="no-show" tone="red" />
        </div>
      </CardContent>
    </Card>
  );
}

Task 6: CollectionsCard (KPI #2)

File: Create ui/src/components/dashboard-widgets/dental/CollectionsCard.tsx
import { Banknote } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatCurrency } from '@/lib/currency';
import type { AdminStats } from '@/lib/serverComm';

export function CollectionsCard({ collections }: { collections?: AdminStats['collections'] }) {
  const c = collections ?? { today: 0, month: 0, pending: 0 };
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">Today's Collections</CardTitle>
        <Banknote className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold leading-tight">{formatCurrency(c.today, { decimals: 0 })}</div>
        <p className="mt-1 text-xs text-muted-foreground">
          <span className="font-medium text-amber-600 dark:text-amber-500">{formatCurrency(c.pending, { decimals: 0 })}</span> pending
        </p>
      </CardContent>
    </Card>
  );
}

Task 7: ChairUtilizationCard (KPI #3, with progress bar + setup empty state)

File: Create ui/src/components/dashboard-widgets/dental/ChairUtilizationCard.tsx
import { Armchair } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import type { AdminStats } from '@/lib/serverComm';

export function ChairUtilizationCard({ operations }: { operations?: AdminStats['operations'] }) {
  const util = operations?.chairUtilization ?? null;
  const detail = operations?.chairUtilizationDetail ?? null;
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">Chair Utilization</CardTitle>
        <Armchair className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        {util == null ? (
          <>
            <div className="text-2xl font-bold leading-tight text-muted-foreground"></div>
            <p className="mt-1 text-xs text-muted-foreground">Add chairs &amp; opening hours to track</p>
          </>
        ) : (
          <>
            <div className="text-2xl font-bold leading-tight">{util}%</div>
            <div className="mt-2 h-2 w-full rounded-full bg-muted">
              <div className={cn('h-2 rounded-full', util >= 75 ? 'bg-green-500/80' : util >= 40 ? 'bg-primary/80' : 'bg-amber-500/80')} style={{ width: `${util}%` }} />
            </div>
            {detail && (
              <p className="mt-1 text-xs text-muted-foreground">
                {Math.round(detail.bookedMinutes / 60)}h of {Math.round(detail.capacityMinutes / 60)}h chair-time
              </p>
            )}
          </>
        )}
      </CardContent>
    </Card>
  );
}

Task 8: NoShowCard (KPI #4)

File: Create ui/src/components/dashboard-widgets/dental/NoShowCard.tsx
import { Ban } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import type { AdminStats } from '@/lib/serverComm';

export function NoShowCard({ today }: { today?: AdminStats['today'] }) {
  const t = today ?? { appointments: 0, noShows: 0 } as NonNullable<AdminStats['today']>;
  const rate = t.appointments > 0 ? Math.round((t.noShows / t.appointments) * 100) : 0;
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">No-show Rate</CardTitle>
        <Ban className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className={cn('text-2xl font-bold leading-tight', rate > 0 ? 'text-red-600 dark:text-red-500' : '')}>{rate}%</div>
        <p className="mt-1 text-xs text-muted-foreground">{t.noShows} of {t.appointments} today</p>
      </CardContent>
    </Card>
  );
}

Task 9: AcceptanceCard (KPI #5)

File: Create ui/src/components/dashboard-widgets/dental/AcceptanceCard.tsx
import { ClipboardCheck } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { AdminStats } from '@/lib/serverComm';

export function AcceptanceCard({ operations }: { operations?: AdminStats['operations'] }) {
  const rate = operations?.treatmentAcceptance ?? null;
  const d = operations?.treatmentAcceptanceDetail;
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">Treatment Acceptance</CardTitle>
        <ClipboardCheck className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        {rate == null ? (
          <>
            <div className="text-2xl font-bold leading-tight text-muted-foreground"></div>
            <p className="mt-1 text-xs text-muted-foreground">No treatment plans in last 90 days</p>
          </>
        ) : (
          <>
            <div className="text-2xl font-bold leading-tight">{rate}%</div>
            <p className="mt-1 text-xs text-muted-foreground">{d?.accepted ?? 0} of {d?.total ?? 0} plans (90d)</p>
          </>
        )}
      </CardContent>
    </Card>
  );
}

Task 10: TodayScheduleCard

File: Create ui/src/components/dashboard-widgets/dental/TodayScheduleCard.tsx
import { CalendarDays, ArrowUpRight, Clock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AdminStats } from '@/lib/serverComm';

const STATUS_LABEL: Record<string, string> = {
  scheduled: 'Scheduled', confirmed: 'Waiting', in_progress: 'In chair',
  completed: 'Completed', cancelled: 'Cancelled', no_show: 'No-show', missed: 'Missed',
};
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
  scheduled: 'outline', confirmed: 'default', in_progress: 'default',
  completed: 'secondary', cancelled: 'destructive', no_show: 'destructive', missed: 'destructive',
};

export function TodayScheduleCard({ schedule, onViewAll }: { schedule?: AdminStats['schedule']; onViewAll?: () => void }) {
  const rows = schedule ?? [];
  return (
    <Card className="h-full">
      <CardHeader className="flex flex-row items-center justify-between">
        <div className="space-y-1">
          <CardTitle className="text-base font-semibold">Today's Schedule</CardTitle>
          <CardDescription className="text-xs">{rows.length ? `Next ${rows.length} patients` : 'Patient flow'}</CardDescription>
        </div>
        {onViewAll && <Button variant="outline" size="sm" className="gap-1.5" onClick={onViewAll}>View all <ArrowUpRight className="h-3.5 w-3.5" /></Button>}
      </CardHeader>
      <CardContent>
        {rows.length === 0 ? (
          <div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
            <CalendarDays className="mb-3 h-9 w-9 text-muted-foreground/40" />
            <p className="text-sm font-medium">No appointments today</p>
            <p className="text-xs">Book an appointment to get started.</p>
          </div>
        ) : (
          <ul className="divide-y divide-border">
            {rows.map(r => (
              <li key={r.id} className="flex items-center gap-3 py-2.5">
                <div className="flex w-14 shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
                  <Clock className="h-3 w-3" />{r.time?.slice(0, 5)}
                </div>
                <div className="min-w-0 flex-1">
                  <div className="truncate text-sm font-medium">{r.patient}</div>
                  <div className="truncate text-xs text-muted-foreground">{r.treatmentType || '—'} · {r.doctor}</div>
                </div>
                <span className={cn('h-2 w-2 shrink-0 rounded-full',
                  r.paymentStatus === 'paid' ? 'bg-green-500' : r.paymentStatus === 'pending' ? 'bg-amber-500' : 'bg-muted-foreground/30')}
                  title={`Payment: ${r.paymentStatus}`} />
                <Badge variant={STATUS_VARIANT[r.status] ?? 'outline'} className="shrink-0 font-normal">{STATUS_LABEL[r.status] ?? r.status}</Badge>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}

Task 11: RevenueByTreatmentCard

File: Create ui/src/components/dashboard-widgets/dental/RevenueByTreatmentCard.tsx
import { Banknote } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { CssBarRow } from './CssBar';
import { formatCurrency } from '@/lib/currency';
import type { AdminStats } from '@/lib/serverComm';

export function RevenueByTreatmentCard({ data }: { data?: AdminStats['revenueByTreatment'] }) {
  const rows = data ?? [];
  const max = rows.reduce((m, r) => Math.max(m, r.amount), 0);
  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle className="text-base font-semibold">Revenue by Treatment</CardTitle>
        <CardDescription className="text-xs">Billed this month, by category</CardDescription>
      </CardHeader>
      <CardContent>
        {rows.length === 0 ? (
          <div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
            <Banknote className="mb-3 h-9 w-9 text-muted-foreground/40" />
            <p className="text-sm font-medium">No payments recorded yet this month.</p>
          </div>
        ) : (
          <div className="space-y-3">
            {rows.map(r => (
              <CssBarRow key={r.category} label={r.category} valueLabel={formatCurrency(r.amount, { decimals: 0 })} ratio={max > 0 ? r.amount / max : 0} />
            ))}
          </div>
        )}
      </CardContent>
    </Card>
  );
}

Task 12: WeeklyLoadCard

File: Create ui/src/components/dashboard-widgets/dental/WeeklyLoadCard.tsx
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { CssColumn } from './CssBar';
import type { AdminStats } from '@/lib/serverComm';

export function WeeklyLoadCard({ data }: { data?: AdminStats['weeklyAppointments'] }) {
  const rows = data ?? [];
  const max = rows.reduce((m, r) => Math.max(m, r.booked, r.capacity ?? 0), 0) || 1;
  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle className="text-base font-semibold">Weekly Load</CardTitle>
        <CardDescription className="text-xs">Booked vs capacity · last 7 days</CardDescription>
      </CardHeader>
      <CardContent>
        <div className="flex items-end gap-1">
          {rows.map(r => (
            <CssColumn
              key={r.day}
              label={r.day}
              heightPct={(r.booked / max) * 100}
              capacityPct={r.capacity != null ? (r.capacity / max) * 100 : null}
              sub={<span className="text-[10px] text-muted-foreground">{r.booked}{(r.cancelled + r.noShows) > 0 ? ` · ${r.cancelled + r.noShows}✕` : ''}</span>}
            />
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

Task 13: ActionRequiredCard

File: Create ui/src/components/dashboard-widgets/dental/ActionRequiredCard.tsx
import { ReactNode } from 'react';
import { Bell, ClipboardList, Banknote, FlaskConical, PhoneMissed, PackageX, CheckCircle2, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatCurrency } from '@/lib/currency';
import { cn } from '@/lib/utils';
import type { AdminStats } from '@/lib/serverComm';

type Tone = 'red' | 'amber' | 'primary';
function Row({ icon, label, count, sub, tone, onClick }: { icon: ReactNode; label: string; count: number; sub?: string; tone: Tone; onClick?: () => void }) {
  if (count <= 0) return null;
  const dot = tone === 'red' ? 'bg-red-500' : tone === 'amber' ? 'bg-amber-500' : 'bg-primary';
  return (
    <button onClick={onClick} className="flex w-full items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 text-left transition-colors hover:bg-muted/40">
      <span className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-white', dot)}>{icon}</span>
      <div className="min-w-0 flex-1">
        <div className="text-sm font-medium">{label}</div>
        {sub && <div className="text-xs text-muted-foreground">{sub}</div>}
      </div>
      <span className="shrink-0 text-sm font-semibold tabular-nums">{count}</span>
      <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
    </button>
  );
}

export function ActionRequiredCard({ items, nav }: { items?: AdminStats['actionItems']; nav?: (view: string) => void }) {
  const a = items;
  const total = a ? a.recallsOverdue + a.unacceptedPlans + a.pendingPayments + a.labCasesDue + a.missedToCallback + a.inventoryLowStock : 0;
  return (
    <Card className="h-full">
      <CardHeader className="flex flex-row items-center gap-2 space-y-0">
        <Bell className="h-4 w-4 text-primary" />
        <CardTitle className="text-base font-semibold">Action Required</CardTitle>
      </CardHeader>
      <CardContent>
        {total === 0 ? (
          <div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
            <CheckCircle2 className="mb-3 h-9 w-9 text-green-500/60" />
            <p className="text-sm font-medium">You're all caught up.</p>
          </div>
        ) : (
          <div className="space-y-2">
            <Row icon={<PhoneMissed className="h-3.5 w-3.5" />} label="Missed appointments to call back" count={a!.missedToCallback} tone="red" onClick={() => nav?.('appointments')} />
            <Row icon={<Banknote className="h-3.5 w-3.5" />} label="Pending payments" sub={formatCurrency(a!.pendingPaymentsAmount, { decimals: 0 }) + ' outstanding'} count={a!.pendingPayments} tone="amber" onClick={() => nav?.('finance-invoices')} />
            <Row icon={<Bell className="h-3.5 w-3.5" />} label="Recalls overdue" count={a!.recallsOverdue} tone="amber" onClick={() => nav?.('patients')} />
            <Row icon={<ClipboardList className="h-3.5 w-3.5" />} label="Unaccepted treatment plans" count={a!.unacceptedPlans} tone="primary" onClick={() => nav?.('treatment-plans')} />
            <Row icon={<FlaskConical className="h-3.5 w-3.5" />} label="Lab cases due" count={a!.labCasesDue} tone="primary" onClick={() => nav?.('lab-work')} />
            <Row icon={<PackageX className="h-3.5 w-3.5" />} label="Low-stock inventory" count={a!.inventoryLowStock} tone="amber" onClick={() => nav?.('inventory')} />
          </div>
        )}
      </CardContent>
    </Card>
  );
}

Task 14: ProviderPerformanceCard

File: Create ui/src/components/dashboard-widgets/dental/ProviderPerformanceCard.tsx
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { formatCurrency } from '@/lib/currency';
import type { AdminStats } from '@/lib/serverComm';

export function ProviderPerformanceCard({ providers }: { providers?: AdminStats['providers'] }) {
  const rows = (providers ?? []).filter(p => p.appointments > 0 || p.revenue > 0 || p.plansProposed > 0);
  if (rows.length <= 1) return null; // only meaningful with multiple active providers
  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle className="text-base font-semibold">Provider Performance</CardTitle>
        <CardDescription className="text-xs">This month</CardDescription>
      </CardHeader>
      <CardContent>
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Dentist</TableHead>
              <TableHead className="text-right">Seen</TableHead>
              <TableHead className="text-right">Revenue</TableHead>
              <TableHead className="text-right">Accept.</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {rows.map(p => (
              <TableRow key={p.id}>
                <TableCell className="font-medium">{p.name}</TableCell>
                <TableCell className="text-right tabular-nums">{p.patientsSeen}</TableCell>
                <TableCell className="text-right tabular-nums">{formatCurrency(p.revenue, { decimals: 0 })}</TableCell>
                <TableCell className="text-right tabular-nums">{p.acceptanceRate == null ? '—' : `${p.acceptanceRate}%`}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </CardContent>
    </Card>
  );
}
After Tasks 5–14: cd ui && npx tsc --noEmit → PASS. Commit:
git add ui/src/components/dashboard-widgets/dental/
git commit -m "feat(ui): dental dashboard presentational components"

Task 15: Owner quick actions + lazy revenue trend chart

Files:
  • Create ui/src/components/dashboard-widgets/dental/OwnerQuickActions.tsx
  • Create ui/src/components/dashboard-widgets/dental/RevenueTrendChart.tsx
  • Step 1: Quick actions
// OwnerQuickActions.tsx
import { CalendarPlus, UserPlus, Banknote, ClipboardList, BellRing } from 'lucide-react';
import { Button } from '@/components/ui/button';

export function OwnerQuickActions({ onBook, onAddPatient, onRecordPayment, onCreatePlan, onSendRecall }: {
  onBook: () => void; onAddPatient: () => void; onRecordPayment: () => void; onCreatePlan: () => void; onSendRecall: () => void;
}) {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button size="sm" variant="outline" className="gap-1.5" onClick={onAddPatient}><UserPlus className="h-4 w-4" />Add Patient</Button>
      <Button size="sm" variant="outline" className="gap-1.5" onClick={onRecordPayment}><Banknote className="h-4 w-4" />Record Payment</Button>
      <Button size="sm" variant="outline" className="gap-1.5" onClick={onCreatePlan}><ClipboardList className="h-4 w-4" />Treatment Plan</Button>
      <Button size="sm" variant="outline" className="gap-1.5" onClick={onSendRecall}><BellRing className="h-4 w-4" />Send Recall</Button>
    </div>
  );
}
(The primary Book Appointment button stays in the header via onBook; included in props for completeness — wire it in AdminOverview’s headerAction.)
  • Step 2: Lazy revenue trend chart (extracts the existing AreaChart so Recharts is code-split)
// RevenueTrendChart.tsx — default export so it can be React.lazy()'d
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from 'recharts';
import { ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { ChartWrapper } from '../ChartWrapper';
import { formatCompactCurrency } from '@/lib/currency';
import type { AdminStats } from '@/lib/serverComm';

const cfg = { revenue: { label: 'Revenue', color: 'var(--primary)' } } satisfies ChartConfig;

export default function RevenueTrendChart({ data, trend }: { data: AdminStats['revenueData']; trend: number }) {
  return (
    <ChartWrapper title="Revenue Overview" description="Monthly revenue, last 6 months" chartConfig={cfg} loading={false}>
      <AreaChart accessibilityLayer data={data || []} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
        <CartesianGrid strokeDasharray="3 3" className="stroke-muted/30" vertical={false} />
        <XAxis dataKey="month" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
        <YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} width={60}
          tickFormatter={(v) => formatCompactCurrency(v, { currency: 'PKR' }).replace('Rs ', '')} />
        <ChartTooltip content={<ChartTooltipContent />} />
        <Area type="monotone" dataKey="revenue" stroke="var(--color-revenue)" strokeWidth={3} fillOpacity={0.1} fill="var(--color-revenue)" />
      </AreaChart>
    </ChartWrapper>
  );
}
  • Step 3: cd ui && npx tsc --noEmit → PASS. Commit:
git add ui/src/components/dashboard-widgets/dental/OwnerQuickActions.tsx ui/src/components/dashboard-widgets/dental/RevenueTrendChart.tsx
git commit -m "feat(ui): owner quick actions + lazy revenue trend chart"

Task 16: Rewrite AdminOverview to compose the new dashboard

Files:
  • Modify: ui/src/components/admin/AdminOverview.tsx (full rewrite of the render; keep InvitePatientModal, PendingBookingsCard, PendingAppointments, AI RequireModule block, and RevenueRecoveryCard).
  • Step 1: Replace the component body with a useQuery-driven version composing the new cards.
import { lazy, Suspense, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { CalendarPlus, ArrowUpRight, Users, Activity } from 'lucide-react';
import OdontoXAIIcon from '../icons/OdontoXAIIcon';
import { DashboardShell } from '../dashboard-widgets/DashboardShell';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { getAdminStats, type AdminStats } from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';
import { getTimeGreetingLabel } from '@/lib/dashboardUtils';
import { InvitePatientModal } from './InvitePatientModal';
import { DailyBriefCard } from '../ai/DailyBriefCard';
import { RevenueForecastWidget } from '../ai/RevenueForecastWidget';
import { RequireModule } from '../providers/ModuleProvider';
import PendingAppointments from '../appointments/PendingAppointments';
import PendingBookingsCard from '../dashboard/PendingBookingsCard';
import { RevenueRecoveryCard } from '../dashboard-widgets/RevenueRecoveryCard';
import { StatChip } from '../dashboard-widgets/dental/CssBar';
import { TodayFlowCard } from '../dashboard-widgets/dental/TodayFlowCard';
import { CollectionsCard } from '../dashboard-widgets/dental/CollectionsCard';
import { ChairUtilizationCard } from '../dashboard-widgets/dental/ChairUtilizationCard';
import { NoShowCard } from '../dashboard-widgets/dental/NoShowCard';
import { AcceptanceCard } from '../dashboard-widgets/dental/AcceptanceCard';
import { TodayScheduleCard } from '../dashboard-widgets/dental/TodayScheduleCard';
import { RevenueByTreatmentCard } from '../dashboard-widgets/dental/RevenueByTreatmentCard';
import { WeeklyLoadCard } from '../dashboard-widgets/dental/WeeklyLoadCard';
import { ActionRequiredCard } from '../dashboard-widgets/dental/ActionRequiredCard';
import { ProviderPerformanceCard } from '../dashboard-widgets/dental/ProviderPerformanceCard';
import { OwnerQuickActions } from '../dashboard-widgets/dental/OwnerQuickActions';
import { MetricSkeleton } from '../dashboard-widgets/MetricSkeleton';

const RevenueTrendChart = lazy(() => import('../dashboard-widgets/dental/RevenueTrendChart'));

interface AdminOverviewProps { user?: { name: string; email: string; role: string; clinicId?: string }; }

export default function AdminOverview({ user }: AdminOverviewProps = {}) {
  const navigate = useNavigate();
  const nameParts = user?.name?.split(' ') || [];
  const firstSegment = nameParts[0] || '';
  const firstName = /^dr\.?$/i.test(firstSegment) ? (nameParts[1] || 'Admin') : (firstSegment || 'Admin');
  const greetingLabel = getTimeGreetingLabel();
  const [invitePatientModalOpen, setInvitePatientModalOpen] = useState(false);
  const go = (view: string, extra = '') => navigate(`/dashboard?view=${view}${extra}`);

  const { data: stats, isLoading, refetch } = useQuery<AdminStats>({
    queryKey: qk.adminDashboard.summary(),
    queryFn: () => getAdminStats(user?.clinicId),
    staleTime: 60_000,
  });

  return (
    <DashboardShell
      title={<span className="text-foreground">{greetingLabel}, <span className="bg-gradient-to-r from-primary via-sky-400 to-indigo-400 bg-clip-text text-transparent">{firstName}</span></span>}
      description="Your clinic at a glance — today's flow, money, and what needs action."
      headerAction={
        <Button onClick={() => go('appointments', '&new=1')} className="bg-primary hover:bg-primary/90 gap-2">
          <CalendarPlus className="h-4 w-4" /> Book Appointment
        </Button>
      }
    >
      <OwnerQuickActions
        onBook={() => go('appointments', '&new=1')}
        onAddPatient={() => setInvitePatientModalOpen(true)}
        onRecordPayment={() => go('finance-receipts')}
        onCreatePlan={() => go('treatment-plans')}
        onSendRecall={() => go('patients')}
      />

      {/* Top KPI row */}
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
        {isLoading ? (
          <><MetricSkeleton /><MetricSkeleton /><MetricSkeleton /><MetricSkeleton /><MetricSkeleton /></>
        ) : (
          <>
            <TodayFlowCard today={stats?.today} />
            <CollectionsCard collections={stats?.collections} />
            <ChairUtilizationCard operations={stats?.operations} />
            <NoShowCard today={stats?.today} />
            <AcceptanceCard operations={stats?.operations} />
          </>
        )}
      </div>

      {/* Secondary stat band: recall due + new patients */}
      <div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border bg-card px-4 py-3">
        <StatChip icon={<Users className="h-3.5 w-3.5" />} value={stats?.operations?.newPatientsThisMonth ?? 0} label="new patients this month" />
        <StatChip icon={<Activity className="h-3.5 w-3.5" />} value={stats?.operations?.recallDue ?? 0} label="recalls due" tone="amber" />
      </div>

      {/* Second row */}
      <div className="grid gap-4 lg:grid-cols-12">
        <div className="lg:col-span-5"><TodayScheduleCard schedule={stats?.schedule} onViewAll={() => navigate('/dashboard/appointments')} /></div>
        <div className="lg:col-span-4"><RevenueByTreatmentCard data={stats?.revenueByTreatment} /></div>
        <div className="lg:col-span-3"><WeeklyLoadCard data={stats?.weeklyAppointments} /></div>
      </div>

      {/* Third row */}
      <div className="grid gap-4 lg:grid-cols-12">
        <div className="lg:col-span-5"><ActionRequiredCard items={stats?.actionItems} nav={(v) => go(v)} /></div>
        <div className="lg:col-span-7"><ProviderPerformanceCard providers={stats?.providers} /></div>
      </div>

      <PendingBookingsCard />
      <PendingAppointments clinicId={user?.clinicId} onUpdate={() => refetch()} />

      {/* Below the fold: lazy revenue trend */}
      {!isLoading && (
        <Suspense fallback={<div className="h-[350px] animate-pulse rounded-xl bg-muted/10" />}>
          <RevenueTrendChart data={stats?.revenueData} trend={stats?.metrics?.revenueTrend ?? 0} />
        </Suspense>
      )}

      <RequireModule module="ai_insights" fallback={null}>
        <div className="space-y-3">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-2"><OdontoXAIIcon className="h-5 w-5" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Ruby Insights</h3></div>
            <Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-muted-foreground" onClick={() => go('ai-insights')}>Full AI Dashboard <ArrowUpRight className="h-3 w-3" /></Button>
          </div>
          <div className="grid gap-4 md:grid-cols-2"><DailyBriefCard /><RevenueForecastWidget /></div>
        </div>
      </RequireModule>

      {user?.role === 'admin' && <RevenueRecoveryCard />}

      <InvitePatientModal open={invitePatientModalOpen} onOpenChange={setInvitePatientModalOpen} onSuccess={() => refetch()} />
    </DashboardShell>
  );
}
Note: the AI section header is labelled “Ruby Insights” (memory rule: AI surfaces are branded “Ruby”, never “AI”/“Action Items”). Keep OdontoXAIIcon (the Ruby logo) — confirm it is the Ruby mark; if a dedicated Ruby icon exists, use it.
  • Step 2: Verify formatCurrency import path — AdminOverview previously imported from @/lib/currency. Confirm formatCurrency/formatCompactCurrency are exported there (they are, per the original imports). If a decimals option isn’t supported, fall back to formatCompactCurrency or formatCurrency(n).
  • Step 3: Typecheck + build
Run: cd ui && npx tsc --noEmit && npm run build Expected: tsc 0 errors; vite build succeeds. Confirm Recharts is in a separate chunk (RevenueTrendChart is lazy) — check the build output chunk list.
  • Step 4: Commit
git add ui/src/components/admin/AdminOverview.tsx
git commit -m "feat(ui): redesign owner dashboard — dental KPIs, schedule, actions, providers"

Task 17: Full verification

  • Step 1: Server unit tests
Run: cd server && npx vitest run src/lib/dashboard-metrics.test.ts Expected: PASS.
  • Step 2: Server typecheck
Run: cd server && npx tsc --noEmit Expected: 0 errors.
  • Step 3: UI typecheck + build
Run: cd ui && npx tsc --noEmit && npm run build Expected: 0 errors; build succeeds.
  • Step 4: Endpoint smoke (NON-LIVE / local or with explicit user OK)
Do NOT hit a live tenant without per-action confirmation. If running locally (cd server && npm run dev), call GET /api/v1/protected/stats/admin with a dev token and assert the new keys (today, collections, operations, schedule, revenueByTreatment, weeklyAppointments, actionItems, providers) are present and well-typed. Otherwise, present the diff and ask before any live verification against ssh & Associates (clinic b6d3a3f3-…).
  • Step 5: Visual sanity
Run the UI (cd ui && npm run dev), open /dashboard as an admin. Confirm: shell + skeletons paint instantly; KPI row shows 5 cards; empty states appear for zero-data sections; no empty charts; Recharts trend loads below the fold; no console 403 spam.

Self-Review (spec coverage)

  • Today’s Appointments / flow → Task 5 + endpoint today. ✔
  • Today’s Collections + pending → Task 6 + collections. ✔
  • Chair Utilization (chairs×hours) → Task 7 + computeChairUtilization. ✔
  • No-show Rate → Task 8. ✔
  • Treatment Acceptance (90d) → Task 9 + computeAcceptanceRate. ✔
  • Today’s Schedule (time/patient/treatment/dentist/status/payment) → Task 10 + schedule. ✔
  • Revenue by Treatment (clinic categories, billed, empty state) → Task 11 + bucketRevenueByTreatment. ✔
  • Weekly Load (booked/capacity/cancelled/no-show) → Task 12 + weeklyAppointments. ✔
  • Action Required (recalls/plans/payments/lab/missed/inventory) → Task 13 + actionItems. ✔
  • Provider Performance → Task 14 + providers. ✔
  • Quick actions (Book primary; Add Patient/Record Payment/Treatment Plan/Send Recall) → Task 15/16. ✔
  • New Patients + Recall Due chips → Task 16 secondary band. ✔
  • Single cached endpoint, lazy chart, CSS bars, per-icon imports, Banknote, indigo accent → Tasks 2/4/11/12/15/16. ✔
  • Owner-only scope; no schema change; Ruby branding for AI → Task 16 + endpoint. ✔