Skip to main content

Network Operations Console — Foundation + Financial 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: Ship the first vertical slice of the multi-branch Head-of-Operations console — a network Financial view (billed / collected / outstanding / AR aging / margin, network total + per-branch + drill-down) — plus the reusable foundation (scope resolution, new branch_manager role, targets table) that the No-Shows / Accounting / Command Center plans build on. Architecture: A NEW, additive metrics engine (server/src/lib/metrics/) computes financials from invoices for an arbitrary set of clinicIds. It is consumed ONLY by new /api/v1/org/insights/* routes — the existing per-clinic analytics.ts/reports.ts are NOT touched (they ship to the main app; prime directive: never destabilize it). Scope (which branches a caller may see) is resolved server-side from role: org_admin → all org branches, branch_manager → one branch. The UI adds a Financial tab to the existing Network Hub. Tech Stack: Hono (server), Drizzle ORM, Postgres (Neon, isolated DentoCorrect DB), Vitest, React + TanStack Query + shadcn/ui + Recharts. Why this slice first: It needs only invoices / payments / clinics / organization_clinics — all confirmed present in the isolated DB and already seeded (60 patients / 198 paid invoices). No schema-sync dependency; that arrives with No-Shows/Accounting (which need doctor_schedules / receipts / expenses). Conventions to follow:
  • PKT dates via formatDatePKT from server/src/lib/pkt.
  • Currency icon is Banknote from lucide-react — NEVER DollarSign.
  • Numeric DB columns come back as strings → parse with parseFloat.
  • Run server tests: cd server && npx vitest run <path>.

File Structure

Create (server):
  • server/src/lib/metrics/types.ts — shared metric types (MetricScope, InvoiceRow, FinancialTotals, BranchFinancialRow, ARaging).
  • server/src/lib/metrics/financial.ts — pure computeFinancial / bucketAR / daysBetween + IO getFinancialMetrics.
  • server/src/lib/metrics/scope.ts — pure resolveInsightsScope.
  • server/src/lib/metrics/__tests__/financial.test.ts — pure-function tests.
  • server/src/lib/metrics/__tests__/scope.test.ts — scope-resolution tests.
  • server/src/middleware/insights-scope.tsrequireInsightsScope (wraps the pure resolver with DB lookups).
  • server/src/schema/ops_targets.ts — targets table.
  • server/src/routes/org-insights.ts/financial, /targets endpoints.
Modify (server):
  • server/src/schema/index.ts — export ops_targets.
  • server/src/lib/schema-ensure.ts — add ensureOpsTargets (idempotent CREATE TABLE).
  • server/src/api.ts:~89 and :~1533 — import + mount org-insights under /org/insights.
Create (ui):
  • ui/src/components/network/NetworkFinancial.tsx — Financial tab.
Modify (ui):
  • ui/src/App.tsx:~55,~1196 — lazy import + nested route path="financial".
  • ui/src/components/network/NetworkLayout.tsx:16-24 — add Financial NAV item.

Task 1: Metric types

Files:
  • Create: server/src/lib/metrics/types.ts
  • Step 1: Create the types file
// server/src/lib/metrics/types.ts
export interface MetricScope {
  clinicIds: string[];
  from: string; // 'YYYY-MM-DD' PKT inclusive
  to: string;   // 'YYYY-MM-DD' PKT inclusive
  groupBy?: 'clinic' | 'doctor';
}

// Shape selected from the invoices table.
export interface InvoiceRow {
  clinicId: string;
  totalAmount: string;
  amountPaid: string;
  balance: string;
  totalCost: string | null;
  status: string;
  invoiceDate: string; // 'YYYY-MM-DD'
}

export interface ARaging {
  d0_30: number;
  d31_60: number;
  d61_90: number;
  d90p: number;
}

export interface FinancialTotals {
  billed: number;
  collected: number;
  outstanding: number;
  cost: number;
  grossProfit: number;
  margin: number;          // %
  collectionRate: number;  // collected / billed, %
  invoiceCount: number;
  aging: ARaging;
}

export interface BranchFinancialRow extends FinancialTotals {
  clinicId: string;
}
  • Step 2: Type-check
Run: cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep metrics/types || echo "types OK" Expected: types OK
  • Step 3: Commit
git add server/src/lib/metrics/types.ts
git commit -m "feat(ops): metric types for the network operations console"

Task 2: Pure financial computation (TDD)

Files:
  • Create: server/src/lib/metrics/financial.ts
  • Test: server/src/lib/metrics/__tests__/financial.test.ts
  • Step 1: Write the failing test
// server/src/lib/metrics/__tests__/financial.test.ts
import { describe, it, expect } from 'vitest';
import { computeFinancial, bucketAR, daysBetween } from '../financial';
import type { InvoiceRow } from '../types';

const inv = (o: Partial<InvoiceRow>): InvoiceRow => ({
  clinicId: 'c1', totalAmount: '0', amountPaid: '0', balance: '0',
  totalCost: '0', status: 'unpaid', invoiceDate: '2026-06-01', ...o,
});

describe('daysBetween', () => {
  it('counts whole days', () => {
    expect(daysBetween('2026-06-01', '2026-06-30')).toBe(29);
  });
});

describe('computeFinancial', () => {
  it('sums billed/collected/outstanding/cost and derives margin + collection rate', () => {
    const rows = [
      inv({ totalAmount: '1000', amountPaid: '1000', balance: '0', totalCost: '400' }),
      inv({ totalAmount: '500', amountPaid: '200', balance: '300', totalCost: '100' }),
    ];
    const r = computeFinancial(rows, '2026-06-12');
    expect(r.billed).toBe(1500);
    expect(r.collected).toBe(1200);
    expect(r.outstanding).toBe(300);
    expect(r.cost).toBe(500);
    expect(r.grossProfit).toBe(1000);
    expect(r.margin).toBeCloseTo((1000 / 1500) * 100);
    expect(r.collectionRate).toBeCloseTo((1200 / 1500) * 100);
    expect(r.invoiceCount).toBe(2);
  });

  it('returns zeros (not NaN) for an empty set', () => {
    const r = computeFinancial([], '2026-06-12');
    expect(r.billed).toBe(0);
    expect(r.margin).toBe(0);
    expect(r.collectionRate).toBe(0);
  });
});

describe('bucketAR', () => {
  it('buckets outstanding balance by invoice age and ignores zero balances', () => {
    const today = '2026-06-30';
    const rows = [
      inv({ balance: '100', invoiceDate: '2026-06-20' }), // 10d → 0-30
      inv({ balance: '200', invoiceDate: '2026-05-20' }), // 41d → 31-60
      inv({ balance: '300', invoiceDate: '2026-04-15' }), // 76d → 61-90
      inv({ balance: '400', invoiceDate: '2026-01-01' }), // >90
      inv({ balance: '0',   invoiceDate: '2026-01-01' }), // ignored
    ];
    const a = bucketAR(rows, today);
    expect(a.d0_30).toBe(100);
    expect(a.d31_60).toBe(200);
    expect(a.d61_90).toBe(300);
    expect(a.d90p).toBe(400);
  });
});
  • Step 2: Run the test to verify it fails
Run: cd server && npx vitest run src/lib/metrics/__tests__/financial.test.ts Expected: FAIL — Failed to resolve import "../financial".
  • Step 3: Implement the pure functions
// server/src/lib/metrics/financial.ts
import type { InvoiceRow, FinancialTotals, ARaging } from './types';

const num = (v: string | null | undefined): number => (v == null ? 0 : parseFloat(v) || 0);

/** Whole days from `a` to `b` (both 'YYYY-MM-DD'), b - a. */
export function daysBetween(a: string, b: string): number {
  const da = Date.parse(`${a}T00:00:00Z`);
  const db = Date.parse(`${b}T00:00:00Z`);
  return Math.floor((db - da) / 86_400_000);
}

export function bucketAR(rows: InvoiceRow[], today: string): ARaging {
  const aging: ARaging = { d0_30: 0, d31_60: 0, d61_90: 0, d90p: 0 };
  for (const r of rows) {
    const bal = num(r.balance);
    if (bal <= 0) continue;
    const age = daysBetween(r.invoiceDate, today);
    if (age <= 30) aging.d0_30 += bal;
    else if (age <= 60) aging.d31_60 += bal;
    else if (age <= 90) aging.d61_90 += bal;
    else aging.d90p += bal;
  }
  return aging;
}

export function computeFinancial(rows: InvoiceRow[], today: string): FinancialTotals {
  let billed = 0, collected = 0, outstanding = 0, cost = 0;
  for (const r of rows) {
    billed += num(r.totalAmount);
    collected += num(r.amountPaid);
    outstanding += num(r.balance);
    cost += num(r.totalCost);
  }
  const grossProfit = billed - cost;
  return {
    billed, collected, outstanding, cost, grossProfit,
    margin: billed > 0 ? (grossProfit / billed) * 100 : 0,
    collectionRate: billed > 0 ? (collected / billed) * 100 : 0,
    invoiceCount: rows.length,
    aging: bucketAR(rows, today),
  };
}
  • Step 4: Run the test to verify it passes
Run: cd server && npx vitest run src/lib/metrics/__tests__/financial.test.ts Expected: PASS (3 passed).
  • Step 5: Commit
git add server/src/lib/metrics/financial.ts server/src/lib/metrics/__tests__/financial.test.ts
git commit -m "feat(ops): pure financial metric computation + AR aging (TDD)"

Task 3: Financial fetch + per-branch grouping

Files:
  • Modify: server/src/lib/metrics/financial.ts (append IO function)
  • Step 1: Append getFinancialMetrics
// append to server/src/lib/metrics/financial.ts
import { and, inArray, gte, lte } from 'drizzle-orm';
import * as schema from '../../schema';
import type { MetricScope, BranchFinancialRow } from './types';

/**
 * Fetch invoices for the scoped clinics + date range, return network totals
 * and one row per branch. `db` is a Drizzle client (read replica preferred).
 */
export async function getFinancialMetrics(
  db: any,
  scope: MetricScope,
  today: string,
): Promise<{ totals: FinancialTotals; perBranch: BranchFinancialRow[] }> {
  if (!scope.clinicIds.length) {
    return { totals: computeFinancial([], today), perBranch: [] };
  }
  const rows: InvoiceRow[] = await db
    .select({
      clinicId: schema.invoices.clinicId,
      totalAmount: schema.invoices.totalAmount,
      amountPaid: schema.invoices.amountPaid,
      balance: schema.invoices.balance,
      totalCost: schema.invoices.totalCost,
      status: schema.invoices.status,
      invoiceDate: schema.invoices.invoiceDate,
    })
    .from(schema.invoices)
    .where(and(
      inArray(schema.invoices.clinicId, scope.clinicIds),
      gte(schema.invoices.invoiceDate, scope.from),
      lte(schema.invoices.invoiceDate, scope.to),
    ));

  const byBranch = new Map<string, InvoiceRow[]>();
  for (const r of rows) {
    const arr = byBranch.get(r.clinicId) ?? [];
    arr.push(r);
    byBranch.set(r.clinicId, arr);
  }

  return {
    totals: computeFinancial(rows, today),
    perBranch: scope.clinicIds.map((cid) => ({
      clinicId: cid,
      ...computeFinancial(byBranch.get(cid) ?? [], today),
    })),
  };
}
  • Step 2: Type-check
Run: cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "metrics/financial" || echo "financial OK" Expected: financial OK
  • Step 3: Commit
git add server/src/lib/metrics/financial.ts
git commit -m "feat(ops): getFinancialMetrics — fan invoices across branch set"

Task 4: Scope resolution (TDD)

Files:
  • Create: server/src/lib/metrics/scope.ts
  • Test: server/src/lib/metrics/__tests__/scope.test.ts
  • Step 1: Write the failing test
// server/src/lib/metrics/__tests__/scope.test.ts
import { describe, it, expect } from 'vitest';
import { resolveInsightsScope } from '../scope';

const branches = ['b1', 'b2', 'b3'];

describe('resolveInsightsScope', () => {
  it('org_admin sees the whole network', () => {
    const r = resolveInsightsScope({ role: 'org_admin', orgBranchIds: branches, ownClinicId: null });
    expect(r).toEqual({ ok: true, clinicIds: branches, level: 'network' });
  });

  it('org_admin can focus one valid branch', () => {
    const r = resolveInsightsScope({ role: 'org_admin', orgBranchIds: branches, ownClinicId: null, requestedBranch: 'b2' });
    expect(r).toEqual({ ok: true, clinicIds: ['b2'], level: 'branch' });
  });

  it('org_admin is blocked from a foreign branch', () => {
    const r = resolveInsightsScope({ role: 'org_admin', orgBranchIds: branches, ownClinicId: null, requestedBranch: 'x9' });
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.status).toBe(403);
  });

  it('branch_manager is scoped to its own clinic', () => {
    const r = resolveInsightsScope({ role: 'branch_manager', orgBranchIds: [], ownClinicId: 'b2' });
    expect(r).toEqual({ ok: true, clinicIds: ['b2'], level: 'branch' });
  });

  it('branch_manager cannot widen scope via ?branch=', () => {
    const r = resolveInsightsScope({ role: 'branch_manager', orgBranchIds: [], ownClinicId: 'b2', requestedBranch: 'b1' });
    expect(r.ok).toBe(false);
  });

  it('branch_manager with no clinic is rejected', () => {
    const r = resolveInsightsScope({ role: 'branch_manager', orgBranchIds: [], ownClinicId: null });
    expect(r.ok).toBe(false);
  });

  it('other roles are rejected', () => {
    const r = resolveInsightsScope({ role: 'doctor', orgBranchIds: [], ownClinicId: 'b2' });
    expect(r.ok).toBe(false);
  });
});
  • Step 2: Run to verify it fails
Run: cd server && npx vitest run src/lib/metrics/__tests__/scope.test.ts Expected: FAIL — Failed to resolve import "../scope".
  • Step 3: Implement
// server/src/lib/metrics/scope.ts
export type InsightsScopeResult =
  | { ok: true; clinicIds: string[]; level: 'network' | 'branch' }
  | { ok: false; status: number; error: string };

export function resolveInsightsScope(input: {
  role: string;
  orgBranchIds: string[];
  ownClinicId: string | null;
  requestedBranch?: string;
}): InsightsScopeResult {
  const { role, orgBranchIds, ownClinicId, requestedBranch } = input;

  if (role === 'org_admin') {
    if (requestedBranch) {
      if (!orgBranchIds.includes(requestedBranch)) {
        return { ok: false, status: 403, error: 'Branch not in your organization' };
      }
      return { ok: true, clinicIds: [requestedBranch], level: 'branch' };
    }
    return { ok: true, clinicIds: orgBranchIds, level: 'network' };
  }

  if (role === 'branch_manager') {
    if (!ownClinicId) return { ok: false, status: 403, error: 'No branch assigned' };
    if (requestedBranch && requestedBranch !== ownClinicId) {
      return { ok: false, status: 403, error: 'Out of scope' };
    }
    return { ok: true, clinicIds: [ownClinicId], level: 'branch' };
  }

  return { ok: false, status: 403, error: 'Insufficient role for operations insights' };
}
  • Step 4: Run to verify it passes
Run: cd server && npx vitest run src/lib/metrics/__tests__/scope.test.ts Expected: PASS (7 passed).
  • Step 5: Commit
git add server/src/lib/metrics/scope.ts server/src/lib/metrics/__tests__/scope.test.ts
git commit -m "feat(ops): server-side insights scope resolution (TDD)"

Task 5: ops_targets table + ensure

Files:
  • Create: server/src/schema/ops_targets.ts
  • Modify: server/src/schema/index.ts
  • Modify: server/src/lib/schema-ensure.ts
  • Step 1: Create the table
// server/src/schema/ops_targets.ts
import { text, numeric, timestamp, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const opsTargets = appSchema.table('ops_targets', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  organizationId: text('organization_id').notNull(),
  clinicId: text('clinic_id'), // null = network-level target
  metric: text('metric').notNull(), // revenue_monthly | noshow_rate_max | collection_rate_min | utilization_min
  value: numeric('value', { precision: 14, scale: 2 }).notNull(),
  period: text('period').default('monthly').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (t) => ({
  orgIdx: index('ops_targets_org_idx').on(t.organizationId),
}));

export type OpsTarget = typeof opsTargets.$inferSelect;
export type NewOpsTarget = typeof opsTargets.$inferInsert;
  • Step 2: Export it — add to server/src/schema/index.ts (alongside the other export * from './...'; lines):
export * from './ops_targets';
  • Step 3: Add an idempotent ensure — append to server/src/lib/schema-ensure.ts:
export async function ensureOpsTargets(db: any): Promise<void> {
  await db.execute(sql`
    CREATE TABLE IF NOT EXISTS app.ops_targets (
      id text PRIMARY KEY,
      organization_id text NOT NULL,
      clinic_id text,
      metric text NOT NULL,
      value numeric(14,2) NOT NULL,
      period text NOT NULL DEFAULT 'monthly',
      created_at timestamp NOT NULL DEFAULT now(),
      updated_at timestamp NOT NULL DEFAULT now()
    );
    CREATE INDEX IF NOT EXISTS ops_targets_org_idx ON app.ops_targets (organization_id);
  `);
}
If schema-ensure.ts does not already import sql, add import { sql } from 'drizzle-orm'; at the top.
  • Step 4: Type-check
Run: cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "ops_targets|schema-ensure" || echo "schema OK" Expected: schema OK
  • Step 5: Commit
git add server/src/schema/ops_targets.ts server/src/schema/index.ts server/src/lib/schema-ensure.ts
git commit -m "feat(ops): ops_targets table + idempotent ensure"

Task 6: insights-scope middleware

Files:
  • Create: server/src/middleware/insights-scope.ts
  • Step 1: Create the middleware
// server/src/middleware/insights-scope.ts
import { MiddlewareHandler } from 'hono';
import { eq } from 'drizzle-orm';
import { getReadDb } from '../lib/db';
import * as schema from '../schema';
import { resolveInsightsScope, InsightsScopeResult } from '../lib/metrics/scope';

declare module 'hono' {
  interface ContextVariableMap {
    insightsScope: { clinicIds: string[]; level: 'network' | 'branch' };
  }
}

export const requireInsightsScope: MiddlewareHandler = async (c, next) => {
  const user = c.get('user');
  if (!user) return c.json({ error: 'Unauthenticated' }, 401);
  const db = getReadDb();

  // All branches in the caller's organization (empty unless they have an org assignment).
  const orgRows = await db
    .select({ clinicId: schema.organizationClinics.clinicId })
    .from(schema.userOrganizationAssignments)
    .innerJoin(
      schema.organizationClinics,
      eq(schema.organizationClinics.organizationId, schema.userOrganizationAssignments.organizationId),
    )
    .where(eq(schema.userOrganizationAssignments.userId, user.id));
  const orgBranchIds = orgRows.map((r) => r.clinicId);

  // The branch_manager's single clinic (first assignment).
  const [own] = await db
    .select({ clinicId: schema.userClinicAssignments.clinicId })
    .from(schema.userClinicAssignments)
    .where(eq(schema.userClinicAssignments.userId, user.id))
    .limit(1);

  const requestedBranch = c.req.query('branch') || undefined;
  const res: InsightsScopeResult = resolveInsightsScope({
    role: user.role,
    orgBranchIds,
    ownClinicId: own?.clinicId ?? null,
    requestedBranch,
  });
  if (!res.ok) return c.json({ error: res.error }, res.status as 401 | 403);

  c.set('insightsScope', { clinicIds: res.clinicIds, level: res.level });
  await next();
};
  • Step 2: Type-check
Run: cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "insights-scope" || echo "middleware OK" Expected: middleware OK
  • Step 3: Commit
git add server/src/middleware/insights-scope.ts
git commit -m "feat(ops): requireInsightsScope middleware (role → branch set)"

Task 7: org-insights routes + mount

Files:
  • Create: server/src/routes/org-insights.ts
  • Modify: server/src/api.ts (import near line 89; mount near line 1533)
  • Step 1: Create the route
// server/src/routes/org-insights.ts
import { Hono } from 'hono';
import { eq, and, isNull } from 'drizzle-orm';
import { requireInsightsScope } from '../middleware/insights-scope';
import { getReadDb, getWriteDb } from '../lib/db';
import { handleError } from '../lib/errors';
import { formatDatePKT } from '../lib/pkt';
import { ensureOpsTargets } from '../lib/schema-ensure';
import * as schema from '../schema';
import { getFinancialMetrics } from '../lib/metrics/financial';

const insights = new Hono();

insights.use('*', requireInsightsScope);
insights.use('*', async (c, next) => {
  await ensureOpsTargets(getReadDb()).catch(() => {});
  await next();
});

function todayPKT(): string {
  return formatDatePKT(new Date());
}
function resolveRange(c: any): { from: string; to: string } {
  const to = c.req.query('to') || todayPKT();
  let from = c.req.query('from');
  if (!from) {
    const d = new Date();
    d.setDate(d.getDate() - 29);
    from = formatDatePKT(d);
  }
  return { from, to };
}

// GET /api/v1/org/insights/financial
insights.get('/financial', async (c) => {
  try {
    const scope = c.get('insightsScope');
    const { from, to } = resolveRange(c);
    const data = await getFinancialMetrics(getReadDb(), { clinicIds: scope.clinicIds, from, to }, todayPKT());
    return c.json({ level: scope.level, range: { from, to }, ...data });
  } catch (err) {
    return handleError(err, c);
  }
});

// GET /api/v1/org/insights/targets
insights.get('/targets', async (c) => {
  try {
    const user = c.get('user');
    if (user.role !== 'org_admin') return c.json({ error: 'Forbidden' }, 403);
    // org id from the first org assignment
    const [assign] = await getReadDb()
      .select({ organizationId: schema.userOrganizationAssignments.organizationId })
      .from(schema.userOrganizationAssignments)
      .where(eq(schema.userOrganizationAssignments.userId, user.id))
      .limit(1);
    if (!assign) return c.json([]);
    const rows = await getReadDb()
      .select()
      .from(schema.opsTargets)
      .where(eq(schema.opsTargets.organizationId, assign.organizationId));
    return c.json(rows);
  } catch (err) {
    return handleError(err, c);
  }
});

// PUT /api/v1/org/insights/targets  — upsert one target (org_admin only)
insights.put('/targets', async (c) => {
  try {
    const user = c.get('user');
    if (user.role !== 'org_admin') return c.json({ error: 'Forbidden' }, 403);
    const body = await c.req.json<{ clinicId: string | null; metric: string; value: number; period?: string }>();
    if (!body.metric?.trim()) return c.json({ error: 'metric is required' }, 400);
    if (typeof body.value !== 'number') return c.json({ error: 'value must be a number' }, 400);

    const [assign] = await getReadDb()
      .select({ organizationId: schema.userOrganizationAssignments.organizationId })
      .from(schema.userOrganizationAssignments)
      .where(eq(schema.userOrganizationAssignments.userId, user.id))
      .limit(1);
    if (!assign) return c.json({ error: 'No organization' }, 403);

    const db = getWriteDb();
    const clinicCond = body.clinicId
      ? eq(schema.opsTargets.clinicId, body.clinicId)
      : isNull(schema.opsTargets.clinicId);
    const [existing] = await db
      .select({ id: schema.opsTargets.id })
      .from(schema.opsTargets)
      .where(and(
        eq(schema.opsTargets.organizationId, assign.organizationId),
        eq(schema.opsTargets.metric, body.metric),
        clinicCond,
      ))
      .limit(1);

    if (existing) {
      await db.update(schema.opsTargets)
        .set({ value: String(body.value), period: body.period ?? 'monthly', updatedAt: new Date() })
        .where(eq(schema.opsTargets.id, existing.id));
      return c.json({ ok: true, id: existing.id });
    }
    const [row] = await db.insert(schema.opsTargets).values({
      organizationId: assign.organizationId,
      clinicId: body.clinicId ?? null,
      metric: body.metric,
      value: String(body.value),
      period: body.period ?? 'monthly',
    }).returning();
    return c.json(row, 201);
  } catch (err) {
    return handleError(err, c);
  }
});

export default insights;
  • Step 2: Mount it — in server/src/api.ts, add the import beside the other route imports (~line 89):
import orgInsightsRoutes from './routes/org-insights';
Then mount it on orgRouter BEFORE the catch-all '/' mount (around line 1533), so the order is:
orgRouter.route('/insights', orgInsightsRoutes);
orgRouter.route('/', orgRoutes);
api.route('/org', orgRouter);
  • Step 3: Build the worker
Run: cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "org-insights|api.ts" || echo "route OK" Expected: route OK
  • Step 4: Smoke test against the seeded isolated DB
Run the worker locally pointed at the DentoCorrect DB, log in as [email protected], then: Run: curl -s -b "$DC_COOKIE" "http://localhost:8787/api/v1/org/insights/financial?from=2026-05-13&to=2026-06-12" | npx json | head -40 Expected: JSON with level:"network", totals.billed > 0 (seeded ≈ 198 invoices), and perBranch length 3.
If you cannot run the worker locally against the isolated DB, defer this to the deploy smoke step and mark it done after verifying on dc.odontox.io.
  • Step 5: Commit
git add server/src/routes/org-insights.ts server/src/api.ts
git commit -m "feat(ops): /org/insights/financial + targets endpoints"

Task 8: Financial UI tab

Files:
  • Create: ui/src/components/network/NetworkFinancial.tsx
  • Modify: ui/src/App.tsx (lazy import ~line 55; route ~line 1196)
  • Modify: ui/src/components/network/NetworkLayout.tsx (NAV_ITEMS, 16-24)
  • Step 1: Create the component
// ui/src/components/network/NetworkFinancial.tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Banknote, TrendingUp, AlertTriangle, Wallet, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE_URL } from '@/lib/api-url';

interface ARaging { d0_30: number; d31_60: number; d61_90: number; d90p: number }
interface Totals {
  billed: number; collected: number; outstanding: number; cost: number;
  grossProfit: number; margin: number; collectionRate: number; invoiceCount: number; aging: ARaging;
}
interface BranchRow extends Totals { clinicId: string }
interface FinancialResponse { level: string; range: { from: string; to: string }; totals: Totals; perBranch: BranchRow[] }

const RANGES = [
  { key: '30d', label: '30 days', days: 29 },
  { key: '90d', label: '90 days', days: 89 },
] as const;

function fmtPKR(n: number): string {
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
  if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
  return n.toLocaleString();
}
function isoDaysAgo(days: number): string {
  const d = new Date();
  d.setDate(d.getDate() - days);
  return d.toISOString().slice(0, 10);
}

export function NetworkFinancial() {
  const navigate = useNavigate();
  const [range, setRange] = useState<typeof RANGES[number]>(RANGES[0]);
  const from = isoDaysAgo(range.days);
  const to = new Date().toISOString().slice(0, 10);

  const { data, isLoading } = useQuery<FinancialResponse>({
    queryKey: ['org', 'insights', 'financial', from, to],
    queryFn: async () => {
      const res = await fetch(`${API_BASE_URL}/api/v1/org/insights/financial?from=${from}&to=${to}`, { credentials: 'include' });
      if (!res.ok) throw new Error('Failed to load financials');
      return res.json();
    },
    staleTime: 60_000,
  });

  const t = data?.totals;
  const aging = t?.aging;
  const agingTotal = aging ? aging.d0_30 + aging.d31_60 + aging.d61_90 + aging.d90p : 0;

  const KPI = ({ icon: Icon, label, value, sub, tone }: { icon: any; label: string; value: string; sub?: string; tone?: string }) => (
    <Card>
      <CardContent className="p-4">
        <div className="flex items-center justify-between">
          <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</span>
          <Icon className={`h-4 w-4 ${tone ?? 'text-muted-foreground'}`} />
        </div>
        <div className="mt-2 text-2xl font-bold tracking-tight">{value}</div>
        {sub && <div className="text-xs text-muted-foreground mt-0.5">{sub}</div>}
      </CardContent>
    </Card>
  );

  return (
    <div className="p-8 space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold tracking-tight">Financial</h1>
          <p className="text-muted-foreground text-sm mt-0.5">Network revenue, collections & receivables</p>
        </div>
        <div className="flex gap-1 rounded-lg border p-0.5">
          {RANGES.map((r) => (
            <Button key={r.key} size="sm" variant={range.key === r.key ? 'default' : 'ghost'} className="h-7 text-xs" onClick={() => setRange(r)}>
              {r.label}
            </Button>
          ))}
        </div>
      </div>

      {isLoading ? (
        <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
          {Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
        </div>
      ) : (
        <>
          <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
            <KPI icon={Banknote} label="Billed" value={`PKR ${fmtPKR(t?.billed ?? 0)}`} sub={`${t?.invoiceCount ?? 0} invoices`} />
            <KPI icon={Wallet} label="Collected" value={`PKR ${fmtPKR(t?.collected ?? 0)}`} sub={`${(t?.collectionRate ?? 0).toFixed(0)}% collection rate`} tone="text-emerald-500" />
            <KPI icon={AlertTriangle} label="Outstanding" value={`PKR ${fmtPKR(t?.outstanding ?? 0)}`} sub="Receivable" tone="text-amber-500" />
            <KPI icon={TrendingUp} label="Margin" value={`${(t?.margin ?? 0).toFixed(1)}%`} sub={`PKR ${fmtPKR(t?.grossProfit ?? 0)} gross`} tone="text-primary" />
          </div>

          {/* AR aging */}
          <Card>
            <CardHeader className="pb-2"><CardTitle className="text-sm">Receivables aging</CardTitle></CardHeader>
            <CardContent>
              <div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
                {aging && agingTotal > 0 && (['d0_30', 'd31_60', 'd61_90', 'd90p'] as const).map((k, i) => (
                  <div key={k} className={['bg-emerald-500', 'bg-yellow-500', 'bg-orange-500', 'bg-red-500'][i]} style={{ width: `${(aging[k] / agingTotal) * 100}%` }} />
                ))}
              </div>
              <div className="mt-3 grid grid-cols-4 gap-2 text-xs">
                {[['0–30', aging?.d0_30], ['31–60', aging?.d31_60], ['61–90', aging?.d61_90], ['90+', aging?.d90p]].map(([l, v], i) => (
                  <div key={i}>
                    <span className={`inline-block h-2 w-2 rounded-full mr-1 ${['bg-emerald-500', 'bg-yellow-500', 'bg-orange-500', 'bg-red-500'][i]}`} />
                    <span className="text-muted-foreground">{l as string} days</span>
                    <div className="font-semibold">PKR {fmtPKR((v as number) ?? 0)}</div>
                  </div>
                ))}
              </div>
            </CardContent>
          </Card>

          {/* Per-branch table */}
          <Card>
            <CardHeader className="pb-2"><CardTitle className="text-sm">By branch</CardTitle></CardHeader>
            <CardContent className="px-0">
              <div className="divide-y">
                <div className="grid grid-cols-12 gap-2 px-4 py-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
                  <div className="col-span-4">Branch</div>
                  <div className="col-span-2 text-right">Billed</div>
                  <div className="col-span-2 text-right">Collected</div>
                  <div className="col-span-2 text-right">Outstanding</div>
                  <div className="col-span-2 text-right">Margin</div>
                </div>
                {(data?.perBranch ?? []).map((b) => (
                  <button
                    key={b.clinicId}
                    onClick={() => navigate(`/dashboard?clinicId=${b.clinicId}`)}
                    className="w-full grid grid-cols-12 gap-2 px-4 py-3 text-sm hover:bg-accent/50 transition-colors text-left items-center group"
                  >
                    <div className="col-span-4 font-medium flex items-center gap-1.5 truncate">
                      <span className="truncate">{b.clinicId}</span>
                      <ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
                    </div>
                    <div className="col-span-2 text-right tabular-nums">{fmtPKR(b.billed)}</div>
                    <div className="col-span-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">{fmtPKR(b.collected)}</div>
                    <div className="col-span-2 text-right tabular-nums text-amber-600 dark:text-amber-400">{fmtPKR(b.outstanding)}</div>
                    <div className="col-span-2 text-right tabular-nums">{b.margin.toFixed(0)}%</div>
                  </button>
                ))}
              </div>
            </CardContent>
          </Card>
        </>
      )}
    </div>
  );
}
Note: the per-branch table shows clinicId as the label for now. A follow-up wires branch display names by joining /org/branches; not required for this slice but DO NOT ship raw UUIDs to production — add the name join before deploy (see follow-ups).
  • Step 2: Add the lazy import — in ui/src/App.tsx near line 55, beside the other Network* lazies:
const NetworkFinancial = lazy(() => import('@/components/network/NetworkFinancial').then(m => ({ default: m.NetworkFinancial })));
  • Step 3: Add the nested route — in ui/src/App.tsx near line 1196, beside the other network routes:
<Route path="financial" element={<NetworkFinancial />} />
  • Step 4: Add the NAV item — in ui/src/components/network/NetworkLayout.tsx, import Banknote (line 3-6 lucide import) and add to NAV_ITEMS (after analytics):
{ to: '/network/financial', label: 'Financial', icon: Banknote },
  • Step 5: Build the UI
Run: cd ui && npx tsc --noEmit && npx vite build 2>&1 | tail -5 Expected: build succeeds, no type errors in NetworkFinancial.
  • Step 6: Visual verification (required — UI is not “done” on a green build)
Run the app, log in as the DentoCorrect org admin, open /network/financial. Confirm: 4 KPI cards populated from seeded data, AR aging bar renders, 3 branch rows, clicking a row drills into that branch dashboard. Capture a screenshot (Playwright or manual) before claiming done.
  • Step 7: Commit
git add ui/src/components/network/NetworkFinancial.tsx ui/src/App.tsx ui/src/components/network/NetworkLayout.tsx
git commit -m "feat(ops): Financial tab in Network Hub (KPIs + AR aging + per-branch drill-down)"

Self-Review notes (addressed)

  • Spec coverage: This plan covers Phase 0 (metrics engine foundation, scope/roles, targets table) + the Financial slice of Phase 1. No-Shows, Accounting, Command Center, and the schema-sync of the isolated DB are explicitly deferred to their own plans.
  • Branch display names: flagged — the table currently renders clinicId; the name-join must land before production deploy (honors the “no raw IDs in customer UI” rule).
  • branch_manager role: this plan makes the console honor the role; actually creating branch_manager users (seeding/assignment + adding it anywhere role lists are enumerated) is a one-line data task done when a branch manager is onboarded — not blocking the build.
  • Type consistency: MetricScope, FinancialTotals, BranchFinancialRow, InsightsScopeResult are defined once (Tasks 1, 4) and reused verbatim in Tasks 3, 6, 7, 8.

Follow-on plans (not in this one)

  1. No-Show Bleed (needs appointments ✓ + doctor_schedules → schema-sync prerequisite).
  2. Accounting / P&L (needs receipts + expenses → schema-sync prerequisite).
  3. Command Center + targets editor UI + alert evaluation.
  4. Inventory, Payroll, Productivity (Phase 2); Doctor Commissions (Phase 3, net-new).