Skip to main content

Network Operations Console — Design Spec

Date: 2026-06-12 Status: Design (Phase 0 + Phase 1) Instance: DentoCorrect (isolated) first; pattern applies to any future enterprise org instance.

Problem

The Network Hub (/network) today is a vanity rollup — today’s appointments, revenue, no-show rate, staff count. A multi-branch Head of Operations cannot run a network on that. They need to see, across all branches at once: where the money is (billed vs collected vs outstanding), where it’s leaking (no-shows, overdue AR, low collection), and which branch/doctor is underperforming — with drill-down to the why, and targets they’re measured against. The bar is “better than the generic CRM they’re using,” and the CRM knows nothing about a chair, a no-show, or a dentist’s daily production.

Core insight (why this is mostly reuse)

The per-clinic analytics already compute the hard parts — they’re just filtered to a single clinicId:
  • routes/analytics.ts → /financial-summary: revenue billed / realized / outstanding (AR), paid/unpaid/overdue invoice counts, gross profit + margin + cost, quotation conversion, monthly trend. Filter is literally clinicId ? eq(invoices.clinicId, clinicId) : undefined.
  • routes/reports.ts → /financial-statement: cash-basis P&L (receipts − direct cost − operating expenses − payroll).
  • routes/expenses.ts, routes/payroll.ts, routes/inventory.ts: all clinic-scoped CRUD + summaries.
  • appointments + doctor_schedules + rooms: no-shows + utilization.
The console is not new analytics. It is the same queries run with clinicId IN (branchIds) and GROUP BY clinic (or doctor), plus presentation. ~90% reuse. The only genuinely net-new subsystem (doctor commissions) is out of scope for this spec (Phase 3).

Scope

In scope (Phase 0 + Phase 1):
  • Phase 0 — Foundation: shared metrics engine, scoped roles, isolated-DB schema sync, targets + alerts.
  • Phase 1 — Money modules: Financial Control, No-Show Bleed, Accounting/P&L, Command Center.
Out of scope (later phases): Inventory rollup (P2), Payroll/salary rollup (P2), Productivity scorecards (P2), Doctor Commissions (P3, net-new).

Roles & access

  • org_adminnetwork scope: all branches in their organization.
  • NEW branch_managerbranch scope: exactly one branch (their user_clinic_assignments clinic). Same console UI, server-enforced single-branch scope; no cross-branch comparison table.
  • Branch staff (admin/doctor/receptionist) keep their existing single-clinic dashboard — unchanged.
Scope is resolved server-side and never trusted from the client (mirrors the existing clinicId-from-JWT-only rule in financial-summary).

Architecture

1. Shared metrics engine (server/src/lib/metrics/)

Pure functions reading from getAnalyticsDb(), each taking a common options object:
interface MetricScope {
  clinicIds: string[];          // 1 = a single branch; N = whole network
  from: string; to: string;     // PKT date strings 'YYYY-MM-DD'
  groupBy?: 'clinic' | 'doctor';
}
Functions (each returns { totals, groups }):
  • getFinancialMetrics(scope) — billed, collected, outstanding(AR), AR aging buckets (0–30/31–60/61–90/90+ from invoices.balance + invoiceDate), gross profit, margin, cost, payment-method mix (payments.paymentMethod), invoice-status counts, daily/monthly trend.
  • getAppointmentMetrics(scope) — booked/completed/cancelled/no_show/missed counts + rates; no-show PKR bleed = no_show count × avg completed-ticket; new vs returning patients; utilization = booked minutes ÷ capacity (doctor_schedules × working days, rooms for chairs).
  • getAccountingPnL(scope) — cash-basis revenue (receipts), direct cost, operating expenses by category (expenses), payroll total, net profit.
Additive-only in v1 — do NOT refactor the existing per-clinic routes. analytics.ts / reports.ts are shared code that also ships to the main app; touching them risks destabilizing it (prime directive: never mess with the main app). The engine is NEW code consumed ONLY by the org-insights routes. It replicates the existing per-clinic formulas exactly, and a parity test asserts engine-single-branch == existing-route output. Converging the old routes onto the engine is an explicit, separately-verified later step — not part of this build. All time filtering uses AT TIME ZONE 'Asia/Karachi' per the established PKT convention.

2. API surface (server/src/routes/org-insights.ts, mounted /api/v1/org/insights)

Middleware requireInsightsScope resolves c.get('insightsScope') = { clinicIds, level }:
  • org_admin → all organization_clinics for their org → level:'network'.
  • branch_manager → [their assigned clinicId]level:'branch'.
Endpoints (all accept ?from&to, optional ?branch=<clinicId> to focus one branch within scope):
  • GET /financialgetFinancialMetrics totals + per-branch rows.
  • GET /appointmentsgetAppointmentMetrics (bleed, funnel, utilization).
  • GET /accountinggetAccountingPnL.
  • GET /command → KPI rollup + branch leaderboard + alerts (targets evaluated) + live feed.
  • GET /targets, PUT /targets → per-branch / network targets (org_admin only).
A branch= outside the caller’s clinicIds returns 403. Branch managers cannot widen scope by any parameter.

3. Targets + alerts

New table app.ops_targets:
id, organization_id, clinic_id (nullable → network-level), metric, value, period, created_at, updated_at
metricrevenue_monthly | noshow_rate_max | collection_rate_min | utilization_min. Created via idempotent CREATE TABLE IF NOT EXISTS ensure-step (matches the sidebar-order precedent), plus a drizzle schema file for type-safety. Alerts evaluator compares current-period actuals (from the engine) to targets → { metric, clinicId, actual, target, severity }[], surfaced in Command Center and as per-branch badges.

4. Schema sync (prerequisite — gated)

The isolated DentoCorrect DB drifts behind code (missing columns/enum values — confirmed during branch seeding). Before Phase 1 queries rely on receipts, quotations, expenses, payroll, inventory, doctor_schedules, introspect + sync that DB to current (drizzle-kit push). This touches a live tenant DB → requires explicit per-action confirmation at execution time (no-live-tenant-execution rule). Treated as Phase 0 step 1.

UI (Network Hub tabs)

Premium, drill-down — not plain shadcn wraps (KPI cards with sparklines + vs-target deltas, branch leaderboard, charts). Currency icon = Banknote (never DollarSign). No raw clinic IDs surfaced.
  • Command Center (redesign of Overview): network KPIs with actual-vs-target deltas, alert strip, branch leaderboard (rank by revenue / flag worst no-show & AR), network live feed.
  • Financial: revenue billed/collected/outstanding, AR aging, profit & margin, payment mix; per-branch table + trend; click a row → that branch’s clinic dashboard (?clinicId=).
  • No-Shows: PKR bleed headline, booked→completed funnel, no-show-rate trend, chair/doctor utilization, cancellation reasons.
  • Accounting: network P&L (revenue → direct cost → gross → expenses/payroll → net), expense breakdown by category, per-branch comparison.
  • Settings → Targets: per-branch + network target editor (org_admin only).
Branch managers see the same tabs scoped to their branch (leaderboard collapses to “your branch vs network average”).

Data flow

UI tab → GET /api/v1/org/insights/<module>?from&to
       → requireInsightsScope (role → clinicIds, level)
       → metrics engine (clinicIds, range, groupBy)
       → getAnalyticsDb() [isolated DB; replica if configured]
       → { totals, perBranch[] } → cards + table + charts → drill-down to clinic dashboard

Error handling

  • Empty branch set → zeroed totals + empty table (never throw), mirrors current /org/overview.
  • Out-of-scope branch= → 403.
  • Missing reused table/column (pre-sync) → endpoint returns a typed degraded: true flag + which module is unavailable, so the UI shows “needs schema sync” instead of a 500.
  • All queries wrapped in handleError per existing route convention.

Testing

  • Unit: metrics engine functions with a fixture of 2 clinics × known invoices/appointments → assert totals + per-branch split + AR aging boundaries + no-show bleed math.
  • Scope: branch_manager cannot read another branch (branch= 403; absent param → only own clinic).
  • Parity: per-clinic dashboard numbers == network console single-branch numbers (proves the shared engine).
  • The seeded DentoCorrect demo data (60 patients / 450 appts / 198 payments) is the live smoke target.

Phasing recap

  • Phase 0 — engine + scoped roles + schema sync + targets/alerts.
  • Phase 1 — Financial + No-Shows + Accounting + Command Center.
  • Phase 2 — Inventory + Payroll + Productivity (reuse).
  • Phase 3 — Doctor Commissions (net-new) + automation.