You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-13-live-preview.md

130 KiB

Live Document Preview — Implementation Plan (HQ console)

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. Server tasks (17) are full TDD loops: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → npm run typecheck → commit. Frontend tasks (813) cannot be unit-tested (they are iframe/layout/matchMedia/upload UI) — each ends with an explicit browser-verification step against the live app instead of a vitest step, still committing on completion. No task depends on code a later task writes.

Goal: Ship the live document preview from docs/superpowers/specs/2026-07-13-live-preview-design.md on the completed HQ-1/HQ-2/HQ-3a apps/hq + apps/hq-web codebase. Staff drafting a quotation watch the letterhead build itself under ~400ms per edit, with GST (including the IGST split for out-of-state clients) always computed by the server; the on-screen paper is byte-identical to the PDF that later prints; there are no blank flashes; save stays strict so bad drafts never persist while preview is permissive so staff can keep typing. The same mechanism previews the company letterhead, module quote-content bullets, and reminder emails.

Architecture: The one idea that makes this safe — the preview shows the output of the same documentHtml() template and the same computeBill() tax engine the PDF uses. There is no second renderer and no client-side money math. We extract a pure prepareDraft(db, input) from createDraft (compute-only, no persist); createDraft then calls prepareDraft + persists, so save and preview cannot diverge by construction. A new POST /api/documents/preview builds a synthetic unsaved Doc, runs prepareDraft + documentHtml, and returns { ok, html, totals, warnings } — persisting nothing, writing no audit. The browser renders that HTML directly in a sandboxed srcdoc iframe; puppeteer only rasterizes the identical HTML for the actual PDF. Sibling surfaces reuse the one renderer: the letterhead and quote-content previews go through documentHtmlSample() + a single thin POST /api/previews/sample route (unsaved edits posted in the request body); the reminder-email preview goes through the pure reminderEmail(kind, ctx) via GET /api/reminders/:id/preview, with the ctx-builder extracted so the previewed email equals the sent one.

Editable template data (spec §4, founder addition): all letterhead text data becomes DB-editable through settings — one fixed, well-designed layout, not a visual layout editor (that is the Store product's Print Templates surface — a separate product/repo — out of scope). GST split logic stays automatic (derived from company.state_code vs the client state). Beyond the existing company.* settings, templates.ts renders new optional template.* keys (template.terms, template.declaration, template.jurisdiction, template.footer_note, template.signatory_label, template.logo as a size-capped data: URI, and per-type template.title_<DOCTYPE>) each with a built-in default so an absent key renders exactly today's output — nothing breaks pre-configuration. These live in the same setting table (getSetting/setSetting), so an edit is a DB write the next PDF render and the live preview reflect immediately, no release. A new owner-gated Document Template page (DocumentTemplate.tsx) groups the company.* block + the template.* fields + a logo upload with the live letterhead preview beside the form — it absorbs the earlier company-profile-letterhead idea into one page. New routes GET/PUT /api/settings/template (both groups, owner-gated, each field audited via setSetting) and POST /api/settings/template/logo (validates content-type + size cap, stores the data URI) model on the existing GET/PUT /api/settings/company.

Tech Stack: unchanged — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (never launched in tests), vitest ^3, React 19 + Vite 6 + react-router 7, @sims/ui. No new dependencies. The preview iframe uses native srcdoc + AbortController; no rasterization anywhere in the test suite.

Global Constraints

Everything from the HQ-1/HQ-2/HQ-3a plans still holds; the load-bearing ones for the live preview:

  • Money is integer paise, server-computed. computeBill is the only tax authority. The client-side subtotal reducer at NewDocument.tsx:91 is DELETED — its removal is the single biggest trust win (the IGST split now appears live for out-of-state clients). No arithmetic on amounts ever runs in apps/hq-web; the totals strip renders response.totals verbatim via formatINR.
  • Preview persists nothing. POST /api/documents/preview and POST /api/previews/sample write no document, no audit_log, no document_event row — they are reads. prepareDraft is pure (SQLite reads + string templating, single-digit ms); no puppeteer, no browser contention.
  • Permissive preview vs strict save. The preview path (prepareDraft(..., { permissive: true })) computes a line with no price-book row and no manual rate at ₹0 and reports it in warnings[], never throwing where save would. POST /api/documents (save) and createDraft stay strict and still reject the same bad document with the unchanged error. Empty composer (zero lines) previews as a real letterhead with an empty table and ₹0 — never a mock.
  • Fidelity is architectural, guarded by one golden test. A string-identity test asserts the preview route's html equals the exact string renderPdf would receive for a fixture draft; a prepareDraftcreateDraft parity test asserts totals/payload deep-equal. No puppeteer in either test — compare the HTML strings, do not rasterize.
  • All ids remain UUIDv7; the preview synthetic Doc uses the throwaway id: 'preview' (never persisted). docNo: null → the template prints "DRAFT"; docDate = today; fy via fyOf.
  • SQL stays portable (D12). This feature adds no schema, no migration, no new table — it is compute + templating + routes + UI only.
  • Screen/print deltas are template-owned. The ~5-line @media screen paper styles live inside templates.ts (with a page.pdf renders print media comment) so the paper itself is template-owned and the PDF is unaffected (print media ignores @media screen); only preview chrome (shimmer, paused banner, totals pulse, "split pending client" tag) is client overlay.
  • Server port 5182; hq-web Vite dev server 5183, proxying /apihttp://localhost:5182 (see apps/hq-web/vite.config.ts).
  • Tests are offline and deterministic — vitest under apps/hq/test/ (already in the vitest include); the express+listen+fetch harness from settings-company.test.ts; puppeteer never launches. No test performs a network call.
  • Per-app typecheck is part of the root gate: npm run typecheck runs tsc -p tsconfig.json && npm run typecheck --workspaces --if-present. Both apps/hq and apps/hq-web must typecheck clean at every commit, and npm run build -w @sims/hq-web (Vite) must stay clean.
  • Commit after every task. Each commit message ends with the trailer — use the two--m form:
    git commit -m "feat(hq): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
    

Scope (locked — do not reopen)

In: prepareDraft extraction + parity/permissive; POST /api/documents/preview; the string-identity fidelity test; templates.ts rendering the new template.* fields with default-fallback; @media screen paper styles + documentHtmlSample + POST /api/previews/sample; GET/PUT /api/settings/template + POST /api/settings/template/logo; reminderContext extraction + GET /api/reminders/:id/preview; LivePreview.tsx (debounced/immediate fetch + double-buffer iframe); NewDocument.tsx split layout + buildDraftBody + server-truth totals strip (reducer deleted) + responsive narrow layout; the owner-gated Document Template page (company block + template.* + logo upload + live letterhead preview, absorbing the company-profile letterhead surface); module quote-content live print preview; reminder-email preview in the queue; final browser walk.

Out (do not plan): a visual layout editor (drag/reposition/restyle — that is the Store product's Print Templates surface, doc 09 in the Store repo); credit-note & receipt preview; the other Store-product surfaces (Label Printing, Price-list bulk revise, Schemes editor, WhatsApp/message-catalog, POS printer-settings receipt preview) — documented handoffs; template.* rendering on receiptHtml (receipts aren't previewed this round — a small follow-up); the "Exact PDF" on-demand puppeteer button and the approximate page-break line (deferred UX polish); generalizing LivePreview beyond the surfaces here (YAGNI). Do not re-litigate these.

File Structure

apps/hq/
  src/repos-documents.ts  — MODIFY: extract prepareDraft (pure); createDraft calls it; buildLines gains permissive warnings
  src/templates.ts        — MODIFY: render template.* fields (default-fallback); @media screen paper styles; documentHtmlSample
  src/send-reminder.ts    — MODIFY: extract reminderContext; sendReminder calls it (behavior unchanged)
  src/api.ts              — MODIFY: companySettings() → company.* + template.*; POST /documents/preview, POST /previews/sample,
                            GET/PUT /settings/template, POST /settings/template/logo, GET /reminders/:id/preview
  test/prepare-draft.test.ts     — NEW (Task 1)
  test/preview-route.test.ts     — NEW (Task 2)
  test/preview-fidelity.test.ts  — NEW (Task 3)
  test/template-render.test.ts   — NEW (Task 4)
  test/preview-sample.test.ts    — NEW (Task 5)
  test/settings-template.test.ts — NEW (Task 6)
  test/reminder-preview.test.ts  — NEW (Task 7)
apps/hq-web/
  src/api.ts                     — MODIFY: preview fetchers + template-settings/logo calls + types
  src/components/LivePreview.tsx  — NEW (Task 8): debounced/immediate fetch + double-buffer srcdoc iframe
  src/pages/NewDocument.tsx      — MODIFY (Task 9): split layout, buildDraftBody, server-truth totals strip, reducer deleted, responsive
  src/pages/DocumentTemplate.tsx — NEW (Task 10): owner page — company block + template.* + logo upload + live letterhead preview
  src/pages/CompanyProfile.tsx   — REMOVE (Task 10): folded into DocumentTemplate; drop its route + nav item + import
  src/main.tsx, src/Layout.tsx   — MODIFY (Task 10): /settings/template route + "Document Template" owner nav (replaces Company)
  src/pages/Modules.tsx          — MODIFY (Task 11): live quote-content print preview
  src/pages/Dashboard.tsx        — MODIFY (Task 12): reminder-email preview in the queue

Task 1: Extract prepareDraft from createDraft (pure, no persist) + permissive warnings

Files:

  • Modify: apps/hq/src/repos-documents.ts
  • Test: apps/hq/test/prepare-draft.test.ts
  • Regression: apps/hq/test/documents.test.ts must stay green (createDraft behaviour unchanged).

Interfaces:

  • Produces:

    • PreparedDraft = { docType: DocType; clientId: string; docDate: string; totals: BillTotals; payload: DocPayload; warnings: string[] }
    • prepareDraft(db: DB, input: DraftInput, opts?: { permissive?: boolean }): PreparedDraft — the compute block of the old createDraft, persisting nothing. Strict by default (missing price throws the unchanged message; unknown client throws Client not found). Permissive: a missing price computes at ₹0 with a warnings[] entry; a null/unknown client defaults placeOfSupply to our own state and adds a pending-client warning; zero lines returns zero totals + empty payload (never calls computeBill, which throws on an empty bill).
  • Modifies:

    • createDraft becomes prepareDraft + insertDocRow inside the existing db.transaction. Byte-for-byte the same persisted result.
    • buildLines gains an optional warnings?: string[] param — when passed (permissive), a missing price pushes a warning and resolves to 0; when omitted (strict), it throws the original message verbatim. createCreditNote still calls buildLines without warnings → unchanged strict behaviour.
  • Consumes: existing computeBill, buildLines, supplyStateCode, taxRates, getClient, getModule, todayIso, fyOf — all already in the file.

  • Step 1: Write the failing testapps/hq/test/prepare-draft.test.ts

// apps/hq/test/prepare-draft.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { prepareDraft, createDraft, type DraftInput } from '../src/repos-documents'

function setup() {
  const db = openDb(':memory:')
  db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
  db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
  const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
  const kar = createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' })
  const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS'] })
  setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
  const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row
  return { db, c, kar, m, noPrice }
}

describe('prepareDraft ↔ createDraft parity', () => {
  it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', () => {
    const { db, c, m } = setup()
    const input: DraftInput = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
      lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
    const prepared = prepareDraft(db, input)
    const saved = createDraft(db, 'u1', input)
    expect(saved.payload).toEqual(prepared.payload)
    expect({
      taxable: saved.taxablePaise, cgst: saved.cgstPaise, sgst: saved.sgstPaise,
      igst: saved.igstPaise, roundOff: saved.roundOffPaise, payable: saved.payablePaise,
    }).toEqual({
      taxable: prepared.totals.taxablePaise, cgst: prepared.totals.cgstPaise, sgst: prepared.totals.sgstPaise,
      igst: prepared.totals.igstPaise, roundOff: prepared.totals.roundOffPaise, payable: prepared.totals.payablePaise,
    })
    expect(prepared.warnings).toEqual([])
  })
  it('resolves IGST for an out-of-state client, exactly as createDraft would', () => {
    const { db, kar, m } = setup()
    const input: DraftInput = { docType: 'INVOICE', clientId: kar.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }
    expect(prepareDraft(db, input).totals.igstPaise).toBe(1_800_00)
  })
})

describe('prepareDraft strict vs permissive', () => {
  it('STRICT: a line with no price throws (save must refuse the same document)', () => {
    const { db, c, noPrice } = setup()
    expect(() => prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
      lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }))
      .toThrow(/No price for module ANALYTICS/)
  })
  it('PERMISSIVE: the same line computes at ₹0 and is reported in warnings', () => {
    const { db, c, noPrice } = setup()
    const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
      lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
    expect(out.totals.payablePaise).toBe(0)
    expect(out.warnings.some((w) => /ANALYTICS/.test(w))).toBe(true)
  })
  it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', () => {
    const { db, c } = setup()
    const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true })
    expect(out.payload.lines).toEqual([])
    expect(out.totals.payablePaise).toBe(0)
  })
  it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', () => {
    const { db, m } = setup()
    const out = prepareDraft(db, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
    expect(out.totals.cgstPaise).toBe(900_00) // intra-state default (our state 32)
    expect(out.totals.igstPaise).toBe(0)
    expect(out.warnings.some((w) => /client/i.test(w))).toBe(true)
  })
})
  • Step 2: Run to verify FAILnpx vitest run apps/hq/test/prepare-draft.test.ts → FAIL (prepareDraft not exported).

  • Step 3: Implement. In apps/hq/src/repos-documents.ts:

Change buildLines to accept optional warnings:

function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): LineInput[] {
  return inputs.map((line) => {
    const mod = getModule(db, line.moduleId)
    if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
    const edition = line.edition ?? 'standard'
    let unitPricePaise = line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate)
    if (unitPricePaise === null) {
      // Strict (no warnings sink): refuse — same message save has always thrown.
      if (warnings === undefined) {
        throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`)
      }
      // Permissive preview: keep the staffer typing — ₹0 now, reported so the cell can flag.
      warnings.push(`No price for ${mod.code} (${line.kind}/${edition}) — enter Unit ₹`)
      unitPricePaise = 0
    }
    const packSuffix = edition !== 'standard' ? ` — ${edition}` : '' // pack name prints on the line
    return {
      itemId: mod.id,
      name: mod.name
        + (line.description !== undefined && line.description !== '' ? ` — ${line.description}` : '')
        + packSuffix,
      hsn: mod.sac,
      qty: line.qty,
      unitCode: 'NOS',
      unitPricePaise,
      priceIncludesTax: false,
      taxClassCode: TAX_CLASS,
    }
  })
}

Add prepareDraft (and a zeroTotals helper) directly above createDraft, and rewrite createDraft to call it:

/** Zero totals for the empty-composer preview (computeBill throws on an empty bill). */
function zeroTotals(): BillTotals {
  return {
    grossPaise: 0, discountPaise: 0, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0,
    igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 0, savingsVsMrpPaise: 0,
  }
}

export interface PreparedDraft {
  docType: DocType; clientId: string; docDate: string
  totals: BillTotals; payload: DocPayload; warnings: string[]
}

/**
 * The compute half of a draft — pure, persists nothing. `createDraft` calls this
 * then inserts; `POST /documents/preview` calls it with { permissive: true }. So
 * save and preview run the ONE tax path and cannot drift. Permissive forgives a
 * price gap (₹0 + warning) and a not-yet-picked client (intra-state default);
 * strict (the default, used by save) throws exactly as before.
 */
export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): PreparedDraft {
  const permissive = opts.permissive === true
  if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) {
    throw new Error(`Cannot draft doc type: ${input.docType}`)
  }
  const client = getClient(db, input.clientId)
  if (client === null && !permissive) throw new Error('Client not found')
  const today = todayIso()
  const lineInputs = Array.isArray(input.lines) ? input.lines : []
  const warnings: string[] = []
  if (client === null && permissive) warnings.push('No client selected — GST split shown as pending')
  // Empty composer under preview: real letterhead, empty table, ₹0 (design §3).
  if (permissive && lineInputs.length === 0) {
    const zero = zeroTotals()
    return {
      docType: input.docType, clientId: input.clientId, docDate: today,
      totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings,
    }
  }
  const supply = supplyStateCode(db)
  const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
    businessDate: today,
    supplyStateCode: supply,
    placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default
    roundToRupee: true,
  }, taxRates(db))
  // Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists).
  const lineContents = lineInputs.map((line) =>
    line.contentLines ?? getModule(db, line.moduleId)!.quoteContent)
  const payload: DocPayload = {
    lines: computed.lines, totals: computed.totals, lineContents,
    ...(input.terms !== undefined ? { terms: input.terms } : {}),
  }
  return {
    docType: input.docType, clientId: input.clientId, docDate: today,
    totals: computed.totals, payload, warnings,
  }
}

export function createDraft(db: DB, userId: string, input: DraftInput): Doc {
  const prepared = prepareDraft(db, input) // strict — save refuses a bad document
  return db.transaction(() => insertDocRow(db, userId, {
    docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
    docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
  }))()
}

(BillTotals and fyOf are already imported at the top of the file; no import change is needed for this task.)

  • Step 4: Run testsnpx vitest run apps/hq/test/prepare-draft.test.ts apps/hq/test/documents.test.ts → PASS (parity green and the existing document suite unchanged). npm run typecheck → clean.

  • Step 5: Commit

git add apps/hq/src/repos-documents.ts apps/hq/test/prepare-draft.test.ts
git commit -m "feat(hq): extract pure prepareDraft from createDraft (permissive preview path)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 2: POST /api/documents/preview — permissive, persists nothing

Files:

  • Modify: apps/hq/src/api.ts
  • Test: apps/hq/test/preview-route.test.ts

Interfaces:

  • Route: POST /api/documents/preview (requireAuth). Body = the exact DraftInput shape POST /documents takes. Builds a synthetic unsaved Doc (id: 'preview', docNo: null → template prints "DRAFT", docDate: today, fy via fyOf), runs prepareDraft(..., { permissive: true }) + documentHtml, and returns { ok: true, html: string, totals: BillTotals, warnings: string[] }. When no client is picked (clientId empty/unknown) it renders a placeholder "— pick a client —" letterhead at our own state (GST total still authoritative). Persists nothing; writes no audit; no puppeteer.

  • Imports added to api.ts: fyOf (from @sims/domain, join the existing validateGstin import), prepareDraft + type Doc (from ./repos-documents), type Client (from ./repos-clients).

  • Step 1: Write the failing testapps/hq/test/preview-route.test.ts

// apps/hq/test/preview-route.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { apiRouter } from '../src/api'

function appWith() {
  const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18
  createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
  const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
  const kar = createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' })
  const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
  setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
  const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' })
  const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
  const server = app.listen(0)
  const base = `http://localhost:${(server.address() as { port: number }).port}/api`
  return { db, server, base, c, kar, m, noPrice }
}

describe('POST /documents/preview', () => {
  const ctx = appWith()
  afterAll(() => ctx.server.close())
  const login = async () =>
    (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
  const preview = async (token: string | null, body: unknown) => {
    const res = await fetch(`${ctx.base}/documents/preview`, {
      method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
      body: JSON.stringify(body),
    })
    return { status: res.status, json: await res.json() as any }
  }
  const save = async (token: string, body: unknown) => {
    const res = await fetch(`${ctx.base}/documents`, {
      method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body),
    })
    return { status: res.status, json: await res.json() as any }
  }

  it('requires auth', async () => {
    expect((await preview(null, { docType: 'QUOTATION', clientId: ctx.c.id, lines: [] })).status).toBe(401)
  })

  it('returns real letterhead HTML + server totals for a priced line', async () => {
    const token = await login()
    const out = await preview(token, { docType: 'INVOICE', clientId: ctx.c.id, lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] })
    expect(out.status).toBe(200)
    expect(out.json.ok).toBe(true)
    expect(out.json.html).toContain('TAX INVOICE')
    expect(out.json.html).toContain('DRAFT')          // unsaved → docNo prints DRAFT
    expect(out.json.totals.payablePaise).toBe(11_800_00)
    expect(out.json.totals.cgstPaise).toBe(900_00)
    expect(out.json.warnings).toEqual([])
    // Persisted nothing:
    expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM document`).get() as any).n).toBe(0)
    expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='document'`).get() as any).n).toBe(0)
  })

  it('shows the IGST split live for an out-of-state client', async () => {
    const token = await login()
    const out = await preview(token, { docType: 'INVOICE', clientId: ctx.kar.id, lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] })
    expect(out.json.totals.igstPaise).toBe(1_800_00)
    expect(out.json.totals.cgstPaise).toBe(0)
  })

  it('PERMISSIVE preview vs STRICT save diverge on a price gap', async () => {
    const token = await login()
    const body = { docType: 'QUOTATION', clientId: ctx.c.id, lines: [{ moduleId: ctx.noPrice.id, qty: 1, kind: 'yearly' }] }
    const pv = await preview(token, body)
    expect(pv.status).toBe(200)
    expect(pv.json.totals.payablePaise).toBe(0)                 // forgiven at ₹0
    expect(pv.json.warnings.some((w: string) => /ANALYTICS/.test(w))).toBe(true)
    const sv = await save(token, body)
    expect(sv.status).toBe(400)                                 // save refuses
    expect(sv.json.error).toMatch(/No price for module ANALYTICS/)
  })

  it('previews with no client picked — placeholder letterhead, GST total still authoritative', async () => {
    const token = await login()
    const out = await preview(token, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] })
    expect(out.status).toBe(200)
    expect(out.json.html).toContain('— pick a client —')
    expect(out.json.totals.payablePaise).toBe(11_800_00)       // 18% invariant
    expect(out.json.warnings.some((w: string) => /client/i.test(w))).toBe(true)
  })

  it('empty composer previews an empty table at ₹0', async () => {
    const token = await login()
    const out = await preview(token, { docType: 'QUOTATION', clientId: ctx.c.id, lines: [] })
    expect(out.status).toBe(200)
    expect(out.json.totals.payablePaise).toBe(0)
  })
})
  • Step 2: Run to verify FAILnpx vitest run apps/hq/test/preview-route.test.ts → FAIL (route 404 / not found).

  • Step 3: Implement. In apps/hq/src/api.ts:

Extend the domain import and the documents/clients imports:

import { fyOf, validateGstin } from '@sims/domain'
import {
  cancelDocument, convertDocument, createCreditNote, createDraft, getDocument,
  issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft,
  type Doc, type DocumentFilter, type DraftInput, type DraftLineInput,
} from './repos-documents'
import {
  createClient, getClient, listClients, updateClient,
  type Client, type ClientInput, type ClientPatch,
} from './repos-clients'

Add the route in the // ---------- documents ---------- block, immediately after POST /documents (so it sits beside the route whose body it mirrors):

  // Live preview: the SAME prepareDraft + documentHtml the PDF uses — persists
  // nothing, writes no audit (it is a read). Permissive: a price gap computes at
  // ₹0 + a warning; no client picked renders a placeholder letterhead at our own
  // state. renderPdf(html) rasterizes this exact string for the actual PDF.
  r.post('/documents/preview', requireAuth, (req, res) => {
    try {
      const input = req.body as DraftInput
      const prepared = prepareDraft(db, input, { permissive: true })
      const company = companySettings()
      const picked = typeof input.clientId === 'string' && input.clientId !== ''
        ? getClient(db, input.clientId) : null
      const client: Client = picked ?? {
        id: '', code: '—', name: '— pick a client —',
        stateCode: company['company.state_code'] ?? '32',
        address: '', contacts: [], status: 'lead', notes: '',
      }
      const t = prepared.totals
      const doc: Doc = {
        id: 'preview', docType: input.docType, docNo: null, fy: fyOf(prepared.docDate),
        clientId: client.id, docDate: prepared.docDate, status: 'draft', refDocId: null,
        taxablePaise: t.taxablePaise, cgstPaise: t.cgstPaise, sgstPaise: t.sgstPaise,
        igstPaise: t.igstPaise, roundOffPaise: t.roundOffPaise, payablePaise: t.payablePaise,
        payload: prepared.payload, source: 'preview', createdBy: staffId(res), createdAt: '',
      }
      res.json({ ok: true, html: documentHtml(doc, client, company), totals: prepared.totals, warnings: prepared.warnings })
    } catch (err) {
      res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
    }
  })
  • Step 4: Run testsnpx vitest run apps/hq/test/preview-route.test.ts → PASS. npm run typecheck → clean.

  • Step 5: Commit

git add apps/hq/src/api.ts apps/hq/test/preview-route.test.ts
git commit -m "feat(hq): POST /documents/preview — permissive live preview, persists nothing" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 3: Fidelity golden test — preview HTML is byte-identical to the PDF's HTML

Files:

  • Test only: apps/hq/test/preview-fidelity.test.ts (no implementation — this guards the architecture Tasks 12 established; anyone who ever forks the template breaks it).

Interfaces: none produced. The test constructs a fixture draft with createDraft (the persisted, un-issued draft the /documents/:id/pdf route would render), computes expected = documentHtml(fixtureDoc, client, company)the exact string renderPdf receives in api.ts (renderPdf(documentHtml(document, client, company))) — and asserts the preview route's html equals it. No puppeteer; strings only.

  • Step 1: Write the failing testapps/hq/test/preview-fidelity.test.ts
// apps/hq/test/preview-fidelity.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient, getClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft } from '../src/repos-documents'
import { documentHtml } from '../src/templates'
import { apiRouter } from '../src/api'

function companyMap(db: any): Record<string, string> {
  const rows = db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`).all() as { key: string; value: string }[]
  return Object.fromEntries(rows.map((r) => [r.key, r.value]))
}

describe('preview fidelity (string identity with the PDF path)', () => {
  const db = openDb(':memory:'); seedIfEmpty(db)
  createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
  const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', address: 'Kochi', gstin: '32ABCDE1234F1Z5' })
  const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS', 'GST filing'] })
  setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
  const app = express(); app.use(express.json()); app.use('/api', apiRouter(db))
  const server = app.listen(0)
  const base = `http://localhost:${(server.address() as { port: number }).port}/api`
  afterAll(() => server.close())

  it('preview.html === documentHtml the /pdf route would feed renderPdf', async () => {
    const input = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
      lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
    // The fixture draft: exactly what /documents/:id/pdf renders (un-issued draft, docNo=null).
    const fixture = createDraft(db, 'u1', input)
    const expected = documentHtml(fixture, getClient(db, c.id)!, companyMap(db))

    const token = (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
    const res = await fetch(`${base}/documents/preview`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(input) })
    const out = await res.json() as any

    expect(out.html).toBe(expected) // one renderer — no drift possible
  })
})
  • Step 2: Runnpx vitest run apps/hq/test/preview-fidelity.test.ts. It should PASS immediately on top of Tasks 12 (the whole point: fidelity is already structural). If it fails, the divergence is a real bug — diff out.html vs expected and fix the preview route's synthetic Doc (usually a fy/docDate/totals field) until identical; do not weaken the assertion. npm run typecheck → clean.

  • Step 3: Commit

git add apps/hq/test/preview-fidelity.test.ts
git commit -m "test(hq): golden fidelity — preview HTML byte-identical to the PDF's HTML" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 4: templates.ts — render the template.* fields with default-fallback

Files:

  • Modify: apps/hq/src/templates.ts
  • Modify: apps/hq/src/api.ts (broaden companySettings() to carry template.*)
  • Test: apps/hq/test/template-render.test.ts
  • Regression: apps/hq/test/templates.test.ts, preview-route.test.ts, preview-fidelity.test.ts stay green (they assert company.*/structural substrings, not template.*).

Interfaces:

  • documentHtml renders the new optional template.* keys from its settings map, each defaulting to today's output when the key is absent (spec §4 — nothing breaks pre-config). A local tpl(key) = company['template.' + key] ?? '' helper mirrors the existing get:

    • Title: template.title_<DOCTYPE> overrides TITLE[doc.docType] (default = the built-in map).
    • Logo: template.logo (a data: URI) renders as <img class="logo"> above the company name (default = none — just the <h1>).
    • Terms: the Terms box now shows doc.payload.terms ?? tpl('terms') (a per-doc terms still wins; template.terms is the editable default; default = today's behaviour of showing nothing when both are empty).
    • Declaration / Jurisdiction / Footer note: new blocks rendered only when the key is non-empty (default = absent, exactly today).
    • Signatory label: replaces the hardcoded Authorised Signatory with tpl('signatory_label') when set (default = Authorised Signatory).
    • This round touches documentHtml only; receiptHtml keeps today's letterhead (receipts aren't previewed — a documented small follow-up).
  • api.ts: companySettings() broadens its query to WHERE key LIKE 'company.%' OR key LIKE 'template.%' so the one settings map documentHtml reads (in the pdf/send/preview/sample routes) now carries template.*. The name stays companySettings to avoid touching call sites; a comment notes it is the full letterhead settings map.

  • Step 1: Write the failing testapps/hq/test/template-render.test.ts

// apps/hq/test/template-render.test.ts
import { describe, it, expect } from 'vitest'
import { documentHtml } from '../src/templates'

const invoice = {
  id: 'd1', docType: 'INVOICE', docNo: 'INV/26-27-0001', fy: '2026-27', clientId: 'c1',
  docDate: '2026-07-13', status: 'draft', refDocId: null, taxablePaise: 10_000_00,
  cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00,
  payload: { lines: [{ itemId: 'm1', name: 'POS', hsn: '998313', qty: 1, unitCode: 'NOS',
    unitPricePaise: 10_000_00, grossPaise: 10_000_00, discountPaise: 0, taxablePaise: 10_000_00,
    taxRateBp: 1800, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, cessPaise: 0, lineTotalPaise: 11_800_00 }], totals: {} },
} as never
const client = { id: 'c1', code: 'CL1', name: 'Acme', stateCode: '32', address: 'Kochi', contacts: [], status: 'active', notes: '' } as never

describe('template.* rendering (default-fallback)', () => {
  it('renders built-in defaults when template.* is unset (nothing breaks pre-config)', () => {
    const html = documentHtml(invoice, client, { 'company.name': 'Tecnostac' })
    expect(html).toContain('TAX INVOICE')            // built-in title
    expect(html).toContain('Authorised Signatory')   // built-in signatory label
    expect(html).not.toContain('class="declaration"')
    expect(html).not.toContain('class="jurisdiction"')
    expect(html).not.toContain('class="footer-note"')
    expect(html).not.toContain('class="logo"')
  })
  it('applies each template.* override where set', () => {
    const html = documentHtml(invoice, client, {
      'company.name': 'Tecnostac',
      'template.title_INVOICE': 'GST INVOICE',
      'template.declaration': 'We declare the particulars are true.',
      'template.jurisdiction': 'Subject to Kochi jurisdiction',
      'template.footer_note': 'Thank you for your business.',
      'template.signatory_label': 'Proprietor',
      'template.logo': 'data:image/png;base64,AAAABBBB',
    })
    expect(html).toContain('GST INVOICE')
    expect(html).not.toContain('TAX INVOICE')
    expect(html).toContain('We declare the particulars are true.')
    expect(html).toContain('Subject to Kochi jurisdiction')
    expect(html).toContain('Thank you for your business.')
    expect(html).toContain('Proprietor')
    expect(html).not.toContain('Authorised Signatory')
    expect(html).toContain('data:image/png;base64,AAAABBBB')
  })
  it('template.terms is the editable default; a per-doc terms overrides it', () => {
    const def = documentHtml(invoice, client, { 'company.name': 'X', 'template.terms': 'Net 30 default' })
    expect(def).toContain('Net 30 default')
    const perDoc = { ...(invoice as object), payload: { ...(invoice as never as { payload: object }).payload, terms: 'Per-doc terms' } } as never
    const html = documentHtml(perDoc, client, { 'company.name': 'X', 'template.terms': 'Net 30 default' })
    expect(html).toContain('Per-doc terms')
    expect(html).not.toContain('Net 30 default')
  })
})
  • Step 2: Run to verify FAILnpx vitest run apps/hq/test/template-render.test.ts → FAIL.

  • Step 3a: Implement documentHtml in templates.ts. Add the tpl helper beside get, and resolve the title/logo/terms:

  const get = (key: string): string => company[`company.${key}`] ?? ''
  const tpl = (key: string): string => company[`template.${key}`] ?? '' // editable letterhead text
  const title = tpl(`title_${doc.docType}`) !== '' ? tpl(`title_${doc.docType}`) : TITLE[doc.docType]
  const logo = tpl('logo')
  const termsText = doc.payload.terms ?? tpl('terms')

In the <style> block add the new element styles (near the letterhead/terms rules):

  .letterhead .logo { max-height: 56px; margin-bottom: 4px; display: block; }
  .declaration { margin-top: 10px; font-size: 10px; color: #555; }
  .jurisdiction { margin-top: 6px; font-size: 10px; color: #666; }
  .footer-note { margin-top: 16px; padding-top: 6px; border-top: 1px solid #ddd; text-align: center; font-size: 10px; color: #666; }

Render the logo at the top of the header:

  <header class="letterhead">
    ${logo !== '' ? `<img class="logo" src="${esc(logo)}" alt="">` : ''}
    <h1>${esc(get('name'))}</h1>
    ${companyMeta.map((line) => `<p>${esc(line)}</p>`).join('\n    ')}
  </header>

Use the resolved title:

  <div class="doc-title">${esc(title)}</div>

Replace the terms block, and add the declaration after it (declaration sits with the legal boilerplate, after the bank/terms blocks and before the signature):

  ${termsText !== '' ? `<div class="terms"><h3>Terms</h3><p>${esc(termsText)}</p></div>` : ''}

  ${tpl('declaration') !== '' ? `<div class="declaration">${esc(tpl('declaration'))}</div>` : ''}
  ${tpl('jurisdiction') !== '' ? `<p class="jurisdiction">${esc(tpl('jurisdiction'))}</p>` : ''}

Use the signatory label, and add the footer note at the very end of <body>:

  <div class="sign">
    <p>For <strong>${esc(get('name'))}</strong></p>
    <p style="margin-top: 36px;">${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}</p>
  </div>

  ${tpl('footer_note') !== '' ? `<div class="footer-note">${esc(tpl('footer_note'))}</div>` : ''}

(The @media screen paper style is added in Task 5, not here.)

  • Step 3b: Broaden companySettings() in api.ts so every documentHtml route carries template.*:
  const companySettings = (): Record<string, string> => {
    // The full letterhead settings map documentHtml reads — company.* identity + template.* text.
    const rows = db.prepare(
      `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`,
    ).all() as { key: string; value: string }[]
    return Object.fromEntries(rows.map((row) => [row.key, row.value]))
  }
  • Step 4: Run testsnpx vitest run apps/hq/test/template-render.test.ts apps/hq/test/templates.test.ts apps/hq/test/preview-route.test.ts apps/hq/test/preview-fidelity.test.ts → all PASS (new suite green; the existing suites unaffected — they assert company.*/structural substrings). npm run typecheck → clean.

  • Step 5: Commit

git add apps/hq/src/templates.ts apps/hq/src/api.ts apps/hq/test/template-render.test.ts
git commit -m "feat(hq): render editable template.* letterhead fields with default fallback" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 5: templates.ts@media screen paper styles + documentHtmlSample; POST /api/previews/sample

Files:

  • Modify: apps/hq/src/templates.ts
  • Modify: apps/hq/src/api.ts
  • Test: apps/hq/test/preview-sample.test.ts

Interfaces:

  • templates.ts:

    • Adds a ~5-line @media screen block inside documentHtml's <style> (mirrored into receiptHtml's <style> for consistency): on screen puppeteer is not involved, so @page margins do not apply — mimic the 14mm print margin and a white page. page.pdf renders the print media, so these screen-only rules never touch the actual PDF. Existing templates.test.ts uses .toContain substring assertions, so the added lines cannot regress it; the fidelity test (Task 3) compares documentHtmldocumentHtml, so both sides gain the lines identically.
    • documentHtmlSample(company: Record<string, string>, opts?: { contentLines?: string[] }): string — a canned Doc + Client (a TAX INVOICE so header + GSTIN + bank box + signatory all render) passed through the ONE documentHtml. Because the map it receives may carry template.*, the sample automatically showcases the Task-4 fields (logo/title/declaration/footer/…). The sample client's stateCode = the company's own state (intra-state → CGST/SGST). opts.contentLines overrides the single line's "what's included" bullets. Used by the Document Template page and the quote-content editor; there is no second renderer.
  • api.ts:

    • POST /api/previews/sample (requireAuth) — body { company?: Record<string, string>; template?: Record<string, string>; titles?: Record<string, string>; logo?: string; contentLines?: string[] }, where company/template use the same field keys as PUT /settings/template. It merges the posted (unsaved) fields over the saved settings map (companySettings(), now company.* + template.*) and returns { ok, html } = documentHtmlSample(merged, { contentLines }). So the letterhead preview shows precisely what Save would produce. This task defines the shared COMPANY_FIELDS/TEMPLATE_FIELDS/DOC_TYPES maps near the top of apiRouter (Task 6's PUT /settings/template consumes the same definitions).
    • Imports added: documentHtmlSample (join the existing documentHtml import from ./templates).
  • Step 1: Write the failing testapps/hq/test/preview-sample.test.ts

// apps/hq/test/preview-sample.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { documentHtml, documentHtmlSample } from '../src/templates'
import { apiRouter } from '../src/api'

const sampleDoc = {
  id: 'd1', docType: 'INVOICE', docNo: 'INV/26-27-0001', fy: '2026-27', clientId: 'c1',
  docDate: '2026-07-13', status: 'draft', refDocId: null, taxablePaise: 10_000_00,
  cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00,
  payload: { lines: [], totals: {} },
} as never
const sampleClient = { id: 'c1', code: 'X', name: 'X', stateCode: '32', address: '', contacts: [], status: 'active', notes: '' } as never

describe('templates: screen paper styles + documentHtmlSample', () => {
  it('documentHtml carries screen-only paper styles (PDF unaffected — print media)', () => {
    expect(documentHtml(sampleDoc, sampleClient, { 'company.name': 'Tecnostac' })).toContain('@media screen')
  })
  it('documentHtmlSample renders letterhead, bank box and given bullets through the one renderer', () => {
    const html = documentHtmlSample(
      { 'company.name': 'Acme HQ', 'company.bank': 'HDFC ****9', 'company.state_code': '32' },
      { contentLines: ['Cloud POS', 'GST filing'] },
    )
    expect(html).toContain('Acme HQ')
    expect(html).toContain('HDFC ****9')  // bank box shows (sample is a TAX INVOICE)
    expect(html).toContain('Cloud POS')
    expect(html).toContain('GST filing')
  })
})

function appWith() {
  const db = openDb(':memory:'); seedIfEmpty(db)
  createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
  const app = express(); app.use(express.json()); app.use('/api', apiRouter(db))
  const server = app.listen(0)
  const base = `http://localhost:${(server.address() as { port: number }).port}/api`
  return { server, base }
}

describe('POST /previews/sample', () => {
  const { server, base } = appWith()
  afterAll(() => server.close())
  const login = async () =>
    (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
  const call = async (token: string | null, body: unknown) => {
    const res = await fetch(`${base}/previews/sample`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) })
    return { status: res.status, json: await res.json() as any }
  }
  it('requires auth', async () => { expect((await call(null, {})).status).toBe(401) })
  it('renders unsaved company edits (field keys, merged over saved settings)', async () => {
    const token = await login()
    const r = await call(token, { company: { name: 'Edited Co', bank: 'ICICI ****7' } })
    expect(r.status).toBe(200)
    expect(r.json.html).toContain('Edited Co')
    expect(r.json.html).toContain('ICICI ****7')
  })
  it('renders unsaved template.* edits, per-type title, and a picked logo', async () => {
    const token = await login()
    const r = await call(token, {
      template: { footerNote: 'Live footer note', declaration: 'Live declaration' },
      titles: { INVOICE: 'GST INVOICE' },
      logo: 'data:image/png;base64,LIVELOGO',
    })
    expect(r.json.html).toContain('Live footer note')
    expect(r.json.html).toContain('Live declaration')
    expect(r.json.html).toContain('GST INVOICE')
    expect(r.json.html).toContain('data:image/png;base64,LIVELOGO')
  })
  it('renders unsaved quote-content bullets', async () => {
    const token = await login()
    const r = await call(token, { contentLines: ['Only this bullet'] })
    expect(r.json.html).toContain('Only this bullet')
  })
})
  • Step 2: Run to verify FAILnpx vitest run apps/hq/test/preview-sample.test.ts → FAIL (documentHtmlSample / route missing, @media screen absent).

  • Step 3a: Implement templates.ts. Add BillTotals to the domain import:

import { amountInWordsINR, formatINR, type BillLine, type BillTotals, type Paise } from '@sims/domain'

Inside documentHtml's <style>, immediately after the @page { size: A4; margin: 14mm; } line, add:

  /* On screen (live preview) puppeteer is not involved, so @page margins do not
     apply — mimic the 14mm print margin and a white page. page.pdf renders the
     print media, so these screen-only rules never affect the actual PDF. */
  @media screen { body { padding: 14mm; background: #fff; } }

Add the identical block after the @page line in receiptHtml's <style> too (keeps the two templates consistent; receipts are out of live-preview scope but the paper look should match).

At the end of the file, add the sample helper:

/**
 * Canned sample doc for previewing letterhead + quote-content edits (design §4).
 * Reuses documentHtml — no second renderer — so what the owner sees is exactly
 * how a real bill prints. A TAX INVOICE so the header, GSTIN and bank box all
 * render; the state code follows the company so the split is intra-state.
 */
export function documentHtmlSample(company: Record<string, string>, opts: { contentLines?: string[] } = {}): string {
  const stateCode = company['company.state_code'] ?? '32'
  const client: Client = {
    id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5',
    stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '',
  }
  const line: BillLine = {
    itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS',
    unitPricePaise: 10_000_00, grossPaise: 10_000_00, discountPaise: 0, taxablePaise: 10_000_00,
    taxRateBp: 1800, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, cessPaise: 0, lineTotalPaise: 11_800_00,
  }
  const totals: BillTotals = {
    grossPaise: 10_000_00, discountPaise: 0, taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00,
    igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00, savingsVsMrpPaise: 0,
  }
  const doc: Doc = {
    id: 'sample', docType: 'INVOICE', docNo: null, fy: '2026-27', clientId: 'sample',
    docDate: new Date().toISOString().slice(0, 10), status: 'draft', refDocId: null,
    taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0,
    roundOffPaise: 0, payablePaise: 11_800_00,
    payload: {
      lines: [line], totals,
      lineContents: [opts.contentLines ?? ['Cloud POS billing', 'GST invoicing', 'Priority support']],
    },
    source: 'sample', createdBy: 'system', createdAt: '',
  }
  return documentHtml(doc, client, company)
}
  • Step 3b: Implement the route in api.ts. Add documentHtmlSample to the templates import:
import { documentHtml, documentHtmlSample } from './templates'

Hoist the shared field maps so they precede every route that uses them: move the existing const COMPANY_FIELDS = { … } out of the // ---------- company profile ---------- block up to near the top of apiRouter (so the existing PUT /settings/company still sees it), and add TEMPLATE_FIELDS + DOC_TYPES beside it. Do not duplicate COMPANY_FIELDS — there must be exactly one definition:

  const COMPANY_FIELDS: Record<string, string> = {
    name: 'company.name', address: 'company.address', gstin: 'company.gstin',
    stateCode: 'company.state_code', phone: 'company.phone', email: 'company.email', bank: 'company.bank',
  }
  const TEMPLATE_FIELDS: Record<string, string> = {
    terms: 'template.terms', declaration: 'template.declaration', jurisdiction: 'template.jurisdiction',
    footerNote: 'template.footer_note', signatoryLabel: 'template.signatory_label',
  }
  const DOC_TYPES = ['QUOTATION', 'PROFORMA', 'INVOICE', 'CREDIT_NOTE', 'RECEIPT'] as const

Then add the previews route right after POST /documents/preview (merging unsaved company + template + per-type titles + a just-picked logo over the saved map):

  // Sibling preview: sample letterhead / quote-content through the ONE renderer.
  // Body uses the same field keys as PUT /settings/template (unsaved edits), merged
  // over the saved settings — so the preview shows exactly what Save would print.
  r.post('/previews/sample', requireAuth, (req, res) => {
    const body = req.body as {
      company?: Record<string, unknown>; template?: Record<string, unknown>
      titles?: Record<string, unknown>; logo?: unknown; contentLines?: unknown
    }
    const merged = companySettings() // company.* + template.*
    const applyGroup = (group: Record<string, unknown> | undefined, fields: Record<string, string>): void => {
      if (group === undefined || group === null) return
      for (const [field, key] of Object.entries(fields)) {
        const val = group[field]
        if (typeof val === 'string') merged[key] = val
      }
    }
    applyGroup(body.company, COMPANY_FIELDS)
    applyGroup(body.template, TEMPLATE_FIELDS)
    if (body.titles !== undefined && body.titles !== null) {
      for (const t of DOC_TYPES) {
        const val = body.titles[t]
        if (typeof val === 'string') merged[`template.title_${t}`] = val
      }
    }
    if (typeof body.logo === 'string') merged['template.logo'] = body.logo
    const opts = Array.isArray(body.contentLines)
      ? { contentLines: body.contentLines.filter((x): x is string => typeof x === 'string') }
      : {}
    res.json({ ok: true, html: documentHtmlSample(merged, opts) })
  })
  • Step 4: Run testsnpx vitest run apps/hq/test/preview-sample.test.ts apps/hq/test/templates.test.ts → PASS (new suite green, existing templates suite unaffected). npm run typecheck → clean.

  • Step 5: Commit

git add apps/hq/src/templates.ts apps/hq/src/api.ts apps/hq/test/preview-sample.test.ts
git commit -m "feat(hq): screen paper styles + documentHtmlSample + POST /previews/sample" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Files:

  • Modify: apps/hq/src/api.ts
  • Test: apps/hq/test/settings-template.test.ts

Interfaces:

  • GET /api/settings/template (requireAuth) → { ok, settings } where settings is the full letterhead map (company.* + template.*) from companySettings().

  • PUT /api/settings/template (requireAuth, requireOwner) — body { company?, template?, titles? } using the field keys of COMPANY_FIELDS/TEMPLATE_FIELDS + a titles object keyed by DocType. Validates GSTIN + two-digit state code exactly like the existing PUT /settings/company; writes each provided field via setSetting (owner-gated, each field audited); returns the refreshed { ok, settings }. The existing GET/PUT /settings/company routes stay (backward-compatible; settings-company.test.ts keeps passing) — the new route is the superset the Document Template page uses.

  • POST /api/settings/template/logo (requireAuth, requireOwner) — body { dataUri: string }. Validates it is a data:image/(png|jpeg|jpg|gif|webp|svg+xml);base64,… URI and that the decoded payload is ≤ 200 KB (data URIs bloat every PDF row); stores via setSetting('template.logo', dataUri) (audited). dataUri: '' clears the logo. Returns { ok, logo }.

  • Consumes COMPANY_FIELDS/TEMPLATE_FIELDS/DOC_TYPES (defined in Task 5); setSetting (already imported), validateGstin (already imported).

  • Step 1: Write the failing testapps/hq/test/settings-template.test.ts

// apps/hq/test/settings-template.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { getSetting } from '../src/repos-reminders'
import { apiRouter } from '../src/api'

function appWith() {
  const db = openDb(':memory:'); seedIfEmpty(db)
  createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
  createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
  const app = express(); app.use(express.json({ limit: '2mb' })); app.use('/api', apiRouter(db))
  const server = app.listen(0)
  const base = `http://localhost:${(server.address() as { port: number }).port}/api`
  return { db, server, base }
}

describe('template settings', () => {
  const { db, server, base } = appWith()
  afterAll(() => server.close())
  const login = async (email: string, password: string) =>
    (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token
  const call = async (token: string, method: string, path: string, body?: unknown) => {
    const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
    return { status: res.status, json: await res.json() as any }
  }

  it('owner GET returns company.* + template.*; PUT writes template fields + a title, each audited', async () => {
    const token = await login('owner@test.in', 'owner-password')
    const before = await call(token, 'GET', '/settings/template')
    expect(before.json.settings['company.name']).toBe('Tecnostac')
    const put = await call(token, 'PUT', '/settings/template', {
      template: { footerNote: 'Thank you!', declaration: 'We declare…', signatoryLabel: 'Proprietor' },
      titles: { INVOICE: 'GST INVOICE' },
    })
    expect(put.status).toBe(200)
    expect(getSetting(db, 'template.footer_note')).toBe('Thank you!')
    expect(getSetting(db, 'template.signatory_label')).toBe('Proprietor')
    expect(getSetting(db, 'template.title_INVOICE')).toBe('GST INVOICE')
    const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'template.%'`).get() as { n: number }
    expect(audits.n).toBeGreaterThanOrEqual(4)
  })
  it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => {
    const staff = await login('staff@test.in', 'staff-password')
    expect((await call(staff, 'PUT', '/settings/template', { template: { terms: 'x' } })).status).toBe(403)
    const owner = await login('owner@test.in', 'owner-password')
    expect((await call(owner, 'PUT', '/settings/template', { company: { gstin: 'NOTAGSTIN' } })).status).toBe(400)
  })

  it('logo: accepts a small image data URI (owner, audited), rejects non-image, oversize, and staff', async () => {
    const owner = await login('owner@test.in', 'owner-password')
    const tiny = 'data:image/png;base64,' + 'A'.repeat(200)
    expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(200)
    expect(getSetting(db, 'template.logo')).toBe(tiny)
    expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: 'data:text/html;base64,AAAA' })).status).toBe(400)
    const huge = 'data:image/png;base64,' + 'A'.repeat(400_000) // ~300 KB decoded > 200 KB cap
    expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: huge })).status).toBe(400)
    const staff = await login('staff@test.in', 'staff-password')
    expect((await call(staff, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(403)
    // clearing is allowed
    expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: '' })).status).toBe(200)
    expect(getSetting(db, 'template.logo')).toBe('')
  })
})
  • Step 2: Run to verify FAILnpx vitest run apps/hq/test/settings-template.test.ts → FAIL (routes missing).

  • Step 3: Implement in api.ts. Add the routes in the // ---------- company profile ---------- block (after the existing PUT /settings/company), reusing the Task-5 field maps:

  // ---------- document template (company.* + template.* letterhead data) ----------
  r.get('/settings/template', requireAuth, (_req, res) => {
    res.json({ ok: true, settings: companySettings() })
  })
  r.put('/settings/template', requireAuth, requireOwner, (req, res) => {
    const body = req.body as { company?: Record<string, unknown>; template?: Record<string, unknown>; titles?: Record<string, unknown> }
    try {
      const company = body.company ?? {}
      const gstin = company['gstin']
      if (typeof gstin === 'string' && gstin !== '') {
        const v = validateGstin(gstin)
        if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'})`)
      }
      const stateCode = company['state_code'] ?? company['stateCode']
      if (typeof stateCode === 'string' && !/^\d{2}$/.test(stateCode)) {
        throw new Error('State code must be two digits')
      }
      for (const [field, key] of Object.entries(COMPANY_FIELDS)) {
        const val = company[field]
        if (typeof val === 'string') setSetting(db, staffId(res), key, val)
      }
      for (const [field, key] of Object.entries(TEMPLATE_FIELDS)) {
        const val = (body.template ?? {})[field]
        if (typeof val === 'string') setSetting(db, staffId(res), key, val)
      }
      if (body.titles !== undefined && body.titles !== null) {
        for (const t of DOC_TYPES) {
          const val = body.titles[t]
          if (typeof val === 'string') setSetting(db, staffId(res), `template.title_${t}`, val)
        }
      }
      res.json({ ok: true, settings: companySettings() })
    } catch (err) {
      res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
    }
  })
  r.post('/settings/template/logo', requireAuth, requireOwner, (req, res) => {
    const { dataUri } = req.body as { dataUri?: unknown }
    try {
      if (typeof dataUri !== 'string') throw new Error('dataUri (string) is required')
      if (dataUri !== '') {
        const m = /^data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(dataUri)
        if (m === null) throw new Error('Logo must be a base64 PNG/JPEG/GIF/WebP/SVG data URI')
        const bytes = Math.floor((m[2]!.length * 3) / 4) // approximate decoded size
        if (bytes > 200 * 1024) throw new Error('Logo too large — keep it under 200 KB')
      }
      setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it
      res.json({ ok: true, logo: dataUri })
    } catch (err) {
      res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
    }
  })

Note: the incoming logo data URI can be large — set express.json({ limit: '2mb' }) where the app is constructed (server.ts) if it currently uses the default 100 KB limit, so a legitimate ≤200 KB logo isn't rejected by the body parser before the route's own cap runs. Confirm/adjust in server.ts during this task.

  • Step 4: Run testsnpx vitest run apps/hq/test/settings-template.test.ts apps/hq/test/settings-company.test.ts → PASS (new suite green; the existing company-settings suite unchanged). npm run typecheck → clean.

  • Step 5: Commit

git add apps/hq/src/api.ts apps/hq/src/server.ts apps/hq/test/settings-template.test.ts
git commit -m "feat(hq): GET/PUT /settings/template + logo upload (owner-gated, audited)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 7: GET /api/reminders/:id/preview — the exact outgoing email (extract reminderContext)

Files:

  • Modify: apps/hq/src/send-reminder.ts (extract the ctx-builder; sendReminder calls it — behaviour unchanged)
  • Modify: apps/hq/src/api.ts
  • Test: apps/hq/test/reminder-preview.test.ts
  • Regression: apps/hq/test/send-reminder.test.ts must stay green.

Interfaces:

  • send-reminder.ts produces reminderContext(db, reminder, companyName): { ctx: ReminderContext; doc: Doc | null; client: Client } — the per-rule context the send path builds, resolved once (client + optional doc). sendReminder consumes it (single getClient, same ctx), so the previewed subject/body equal the sent ones by construction. The internal-kind guard (follow_up/email_bounced → throw) stays at the top of sendReminder, before reminderContext.

  • api.ts: GET /api/reminders/:id/preview (requireAuth) → { ok, subject, body } from reminderEmail(reminder.ruleKind, ctx). 404 for an unknown reminder; 400 for an internal kind (mirrors sendReminder). Imports added: reminderContext (from ./send-reminder), reminderEmail (from ./reminder-templates).

  • Step 1: Write the failing testapps/hq/test/reminder-preview.test.ts

// apps/hq/test/reminder-preview.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument } from '../src/repos-documents'
import { upsertReminder } from '../src/repos-reminders'
import { apiRouter } from '../src/api'

function appWith() {
  const db = openDb(':memory:'); seedIfEmpty(db) // company.name='Tecnostac'
  createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
  const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
  const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
  setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
  const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
  const overdue = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id })
  const internal = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id })
  const app = express(); app.use(express.json()); app.use('/api', apiRouter(db))
  const server = app.listen(0)
  const base = `http://localhost:${(server.address() as { port: number }).port}/api`
  return { server, base, inv, overdueId: overdue.id, internalId: internal.id }
}

describe('GET /reminders/:id/preview', () => {
  const ctx = appWith()
  afterAll(() => ctx.server.close())
  const login = async () =>
    (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
  const get = async (token: string | null, id: string) => {
    const res = await fetch(`${ctx.base}/reminders/${id}/preview`, { headers: token ? { authorization: `Bearer ${token}` } : {} })
    return { status: res.status, json: await res.json() as any }
  }
  it('requires auth', async () => { expect((await get(null, ctx.overdueId)).status).toBe(401) })
  it('previews the overdue email — subject + body match what would be sent', async () => {
    const token = await login()
    const r = await get(token, ctx.overdueId)
    expect(r.status).toBe(200)
    expect(r.json.subject).toContain(ctx.inv.docNo)  // 'Payment reminder — Invoice INV/26-27-0001 (Tecnostac)'
    expect(r.json.subject).toContain('Tecnostac')
    expect(r.json.body).toContain('Acme')            // clientName
  })
  it('404s an unknown reminder and 400s an internal follow_up', async () => {
    const token = await login()
    expect((await get(token, 'nope')).status).toBe(404)
    expect((await get(token, ctx.internalId)).status).toBe(400)
  })
})
  • Step 2: Run to verify FAILnpx vitest run apps/hq/test/reminder-preview.test.ts → FAIL (route missing).

  • Step 3a: Extract reminderContext in send-reminder.ts. Add the exported helper and rewrite the body of sendReminder to consume it (no behaviour change — client resolved once, same ctx, same guard order):

import type { Client } from './repos-clients'
import type { Doc } from './repos-documents'

/** The per-rule email context + the resolved client/doc, shared by the send path
 *  and GET /reminders/:id/preview so the previewed email equals the sent one. */
export interface ReminderRender { ctx: ReminderContext; doc: Doc | null; client: Client }

export function reminderContext(db: DB, reminder: Reminder, companyName: string): ReminderRender {
  const client = getClient(db, reminder.clientId)
  if (client === null) throw new Error('Client not found')
  const ctx: ReminderContext = { clientName: client.name, companyName }
  let doc: Doc | null = null
  if (reminder.docId !== null) {
    doc = getDocument(db, reminder.docId)
    if (doc === null) throw new Error('Reminder document not found')
    ctx.docNo = doc.docNo ?? undefined
    ctx.amountPaise = doc.payablePaise
    ctx.period = reminder.duePeriod
  } else if (reminder.ruleKind === 'renewal_due') {
    const cm = getClientModule(db, reminder.subjectId)
    if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal
  } else if (reminder.ruleKind === 'amc_expiring') {
    const amc = getAmc(db, reminder.subjectId)
    if (amc !== null) { ctx.coverage = amc.coverage; ctx.dueDate = amc.periodTo }
  }
  return { ctx, doc, client }
}

Reminder is already available via getReminder's return type; import the type if tsc needs it: add type Reminder to the existing import { getReminder, setReminderStatus } from './repos-reminders'. Then rewrite the middle of sendReminder (keep the internal-kind guard exactly where it is, before this):

  const company = deps.company()
  const companyName = company['company.name'] ?? ''
  const { ctx, doc, client } = reminderContext(db, reminder, companyName)

  let attachment: { filename: string; data: Buffer } | undefined
  let documentId: string | undefined
  if (doc !== null) {
    documentId = doc.id
    const pdf = await deps.renderPdf(documentHtml(doc, client, company))
    attachment = { filename: `${(doc.docNo ?? 'draft').replaceAll('/', '-')}.pdf`, data: pdf }
  }

  const to = client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
  if (to === undefined) throw new Error('No recipient: add a contact email to the client')

  const mail = reminderEmail(reminder.ruleKind, ctx)

The remaining lines of sendReminder (the sendReminderEmail call, status flip) are unchanged. Delete the now-duplicated inline ctx/client/doc resolution the extraction replaced.

  • Step 3b: Implement the route in api.ts. Add imports:
import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder'
import { reminderEmail } from './reminder-templates'

Add the route in the // ---------- reminders (manual queue) ---------- block, after GET /reminders:

  r.get('/reminders/:id/preview', requireAuth, (req, res) => {
    const id = String(req.params['id'] ?? '')
    const reminder = getReminder(db, id)
    if (reminder === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
    try {
      if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
        throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`)
      }
      const { ctx } = reminderContext(db, reminder, companySettings()['company.name'] ?? '')
      const mail = reminderEmail(reminder.ruleKind, ctx)
      res.json({ ok: true, subject: mail.subject, body: mail.bodyText })
    } catch (err) {
      res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
    }
  })
  • Step 4: Run testsnpx vitest run apps/hq/test/reminder-preview.test.ts apps/hq/test/send-reminder.test.ts → PASS (new suite green and the send suite unchanged). npm run typecheck → clean.

  • Step 5: Commit

git add apps/hq/src/send-reminder.ts apps/hq/src/api.ts apps/hq/test/reminder-preview.test.ts
git commit -m "feat(hq): GET /reminders/:id/preview — exact email via shared reminderContext" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 8: LivePreview.tsx — debounced/immediate fetch + double-buffer srcdoc iframe

Frontend task — no vitest; ends with a browser-verification step. Build the reusable mechanism and wire a minimal mount into NewDocument; Task 9 completes the composer layout.

Files:

  • Modify: apps/hq-web/src/api.ts (preview fetchers + types)
  • Create: apps/hq-web/src/components/LivePreview.tsx
  • Modify: apps/hq-web/src/pages/NewDocument.tsx (minimal right-pane mount, replaced in Task 9)

Interfaces:

  • api.ts:

    • DocTotals = { taxablePaise; cgstPaise; sgstPaise; igstPaise; roundOffPaise; payablePaise: number } (the subset the strip renders — extra server fields ignored).
    • DocPreview = { html: string; totals: DocTotals; warnings: string[] }.
    • postPreview<T>(path, body, signal?) — a dedicated fetcher mirroring fetchPdfBlob (carries the bearer token, passes the AbortSignal, and lets an abort reject as AbortError rather than the generic apiFetch "unreachable" so LivePreview can ignore superseded calls).
    • previewDocument(body, signal?) → Promise<DocPreview>; previewSample(body, signal?) → Promise<{ html: string }>; getReminderPreview(id) → Promise<{ subject; body: string }>.
  • LivePreview.tsx:

    • Props { body: string; commitNonce: number; fetchPreview: (body, signal) => Promise<PreviewFetchResult>; onResult?; onStatus? }. body = serialized request identity (skip the POST when unchanged). commitNonce bumps to force an immediate (0ms) refire; a body-only change debounces 400ms. One in-flight max (AbortController + a sequence guard drops stale responses). Never blanks: two stacked sandboxed iframes; the next render writes into the hidden one, restores scrollTop on load, then cross-fades (120ms). A 2px shimmer shows while in-flight; a slim "Preview paused — retrying" banner shows on error while the last good paper stays.
    • PreviewFetchResult = { html: string; totals?: DocTotals; warnings?: string[] }.
    • Deliberately modest — a composer helper reused by the composer + the two sample surfaces, not a general widget (YAGNI).
  • Step 1: Add the preview fetchers to apps/hq-web/src/api.ts (after fetchPdfBlob):

export interface DocTotals {
  taxablePaise: number; cgstPaise: number; sgstPaise: number
  igstPaise: number; roundOffPaise: number; payablePaise: number
}
export interface DocPreview { html: string; totals: DocTotals; warnings: string[] }

/**
 * Dedicated preview POST (mirrors fetchPdfBlob): carries the bearer token and the
 * AbortSignal, and — unlike apiFetch — lets an aborted fetch reject as AbortError
 * so LivePreview can silently drop superseded requests instead of surfacing a
 * spurious "server unreachable".
 */
async function postPreview<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
  const token = localStorage.getItem(TOKEN_KEY) ?? ''
  const res = await fetch(`/api${path}`, {
    method: 'POST', signal,
    headers: { 'content-type': 'application/json', ...(token !== '' ? { authorization: `Bearer ${token}` } : {}) },
    body: JSON.stringify(body),
  })
  const json = (await res.json().catch(() => ({}))) as T & { error?: string }
  if (!res.ok) throw new Error((json as { error?: string }).error ?? `Preview failed: HTTP ${res.status}`)
  return json
}

export const previewDocument = (body: unknown, signal?: AbortSignal): Promise<DocPreview> =>
  postPreview<DocPreview>('/documents/preview', body, signal)
export const previewSample = (body: unknown, signal?: AbortSignal): Promise<{ html: string }> =>
  postPreview<{ html: string }>('/previews/sample', body, signal)
export const getReminderPreview = (id: string): Promise<{ subject: string; body: string }> =>
  apiFetch<{ subject: string; body: string }>(`/reminders/${id}/preview`)

TOKEN_KEY is the module-level constant already defined at the top of api.ts.

  • Step 2: Create apps/hq-web/src/components/LivePreview.tsx
import { type CSSProperties, useEffect, useRef, useState } from 'react'
import type { DocTotals } from '../api'

export interface PreviewFetchResult { html: string; totals?: DocTotals; warnings?: string[] }

interface LivePreviewProps {
  /** Serialized request body — identity for skip-if-unchanged. */
  body: string
  /** Bump to force an immediate (0ms) refire; body-only changes debounce 400ms. */
  commitNonce: number
  /** Performs the POST; must honour the AbortSignal. */
  fetchPreview: (body: string, signal: AbortSignal) => Promise<PreviewFetchResult>
  /** After each successful fetch — totals/warnings for the parent's strip. */
  onResult?: (r: PreviewFetchResult) => void
  /** In-flight/error changes — parent dims totals + shows the paused state. */
  onStatus?: (s: { busy: boolean; error: string | null }) => void
}

const DEBOUNCE_MS = 400

/**
 * Live preview pane (composer + company + quote-content). Debounced/immediate
 * POST → double-buffered iframe of the server's real documentHtml, so the paper
 * on screen is the same HTML puppeteer rasterizes for the PDF. Never blanks: the
 * previous paper stays under a 2px shimmer, the next render is written into a
 * hidden stacked iframe, then cross-faded in with scrollTop kept. Modest by
 * design — a composer helper, not a general widget (YAGNI).
 */
export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatus }: LivePreviewProps) {
  const aRef = useRef<HTMLIFrameElement>(null)
  const bRef = useRef<HTMLIFrameElement>(null)
  const [aShown, setAShown] = useState(true) // which buffer is visible
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const seq = useRef(0)                 // sequence guard: only the latest response wins
  const lastBody = useRef<string | null>(null) // skip-if-unchanged
  const prevCommit = useRef(commitNonce)
  const shownRef = useRef(aShown)
  shownRef.current = aShown

  useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus])

  useEffect(() => {
    const immediate = commitNonce !== prevCommit.current
    prevCommit.current = commitNonce
    if (body === lastBody.current) return // identical body — no POST (design §3)

    const controller = new AbortController()
    const mySeq = ++seq.current
    const timer = setTimeout(() => {
      setBusy(true)
      fetchPreview(body, controller.signal)
        .then((r) => {
          if (mySeq !== seq.current) return // superseded
          lastBody.current = body
          setError(null)
          swapIn(r.html)
          onResult?.(r)
        })
        .catch((e: unknown) => {
          if (controller.signal.aborted || mySeq !== seq.current) return
          setError(e instanceof Error ? e.message : String(e)) // keep the last good paper
        })
        .finally(() => { if (mySeq === seq.current) setBusy(false) })
    }, immediate ? 0 : DEBOUNCE_MS)

    return () => { controller.abort(); clearTimeout(timer) }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [body, commitNonce])

  /** Write html into the hidden buffer; on load restore scroll then cross-fade. */
  function swapIn(html: string): void {
    const a = aRef.current, b = bRef.current
    if (a === null || b === null) return
    const visible = shownRef.current ? a : b
    const hidden = shownRef.current ? b : a
    const keepScroll = visible.contentWindow?.scrollY ?? 0
    const onLoad = (): void => {
      hidden.removeEventListener('load', onLoad)
      try { hidden.contentWindow?.scrollTo(0, keepScroll) } catch { /* srcdoc is same-origin — never throws */ }
      setAShown((s) => !s) // opacity transition = the 120ms cross-fade
    }
    hidden.addEventListener('load', onLoad)
    hidden.srcdoc = html
  }

  const frame = (shown: boolean): CSSProperties => ({
    position: 'absolute', inset: 0, width: '100%', height: '100%', border: 'none',
    background: '#fff', opacity: shown ? 1 : 0, transition: 'opacity 120ms ease',
  })

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
      {busy && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--accent, #4b8bf5)', opacity: 0.85, zIndex: 3 }} />}
      {error !== null && (
        <div style={{ position: 'absolute', top: 8, left: 8, zIndex: 3, background: 'var(--bg-raised, #fff)', border: '1px solid var(--border, #ccc)', borderRadius: 6, padding: '2px 8px', fontSize: 12 }}>
          Preview paused  retrying
        </div>
      )}
      {/* sandbox="" strips scripts — the template is pure HTML/CSS. */}
      <iframe ref={aRef} title="preview-a" sandbox="" style={frame(aShown)} />
      <iframe ref={bRef} title="preview-b" sandbox="" style={frame(!aShown)} />
    </div>
  )
}
  • Step 3: Minimal mount in NewDocument.tsx (temporary — Task 9 replaces the whole layout). Wrap the existing return (...) body in a two-column flex, keeping the current form on the left and adding the preview on the right, fed by the existing lines/client/docType state:
import { LivePreview } from '../components/LivePreview'
import { previewDocument } from '../api'
// ... inside NewDocument(), before `return (`:
const [commitNonce, setCommitNonce] = useState(0)
const previewBody = JSON.stringify({
  docType, clientId: client?.id ?? '',
  lines: lines.filter((l) => l.moduleId !== '' && l.kind !== '').map((l) => ({
    moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
    ...(l.edition !== 'standard' ? { edition: l.edition } : {}),
    ...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
    ...(l.description !== '' ? { description: l.description } : {}),
    contentLines: splitContent(l.contentText),
  })),
  ...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})

Then the JSX (left = the existing <div className="wf-page">…</div>, right = the paper):

  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
      <div style={{ flex: '0 0 600px', minWidth: 0 }}>
        {/* …the existing composer form JSX, unchanged… */}
      </div>
      <div style={{ flex: 1, minWidth: 0, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, height: 'calc(100vh - 140px)', position: 'sticky', top: 12, padding: 12 }}>
        <div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
          <LivePreview
            body={previewBody}
            commitNonce={commitNonce}
            fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
          />
        </div>
      </div>
    </div>
  )

Bump commitNonce on the client pick handler (onClick={() => { setClient(c); setHits([]); setCommitNonce((n) => n + 1) }}) so the first render is immediate. (Task 9 wires the remaining commit events + totals.)

  • Step 4: Browser-verify. Start the API: npm run start -w @sims/hq (port 5182; note the printed owner password on first boot, or reuse an existing DB). In a second shell start the web dev server: npm run dev -w @sims/hq-web (port 5183). Open http://localhost:5183, log in as owner, go to New Document. Confirm:

    • Pick a client, add a module + kind → the A4 paper renders the real letterhead within ~400ms; typing in Qty/Unit updates it after a short debounce; picking the client/kind updates it immediately.
    • Editing rapidly never blanks the pane (the previous paper stays; a 2px bar flickers at the top; the new paper cross-fades in) and does not jump scroll.
    • Kill the API server, edit a field → the "Preview paused — retrying" banner appears and the last paper stays; restart the API and edit again → it recovers.
    • Browser devtools Network shows at most one in-flight /api/documents/preview at a time (superseded ones show as canceled).
  • Step 5: Typecheck + build + commitnpm run typecheck clean; npm run build -w @sims/hq-web clean.

git add apps/hq-web/src/api.ts apps/hq-web/src/components/LivePreview.tsx apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): LivePreview — debounced double-buffer iframe + preview fetchers" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 9: NewDocument.tsx — split layout, buildDraftBody, server-truth totals strip (reducer deleted), responsive

Frontend task — browser-verified. This replaces the Task-8 minimal mount with the real composer.

Files:

  • Modify: apps/hq-web/src/pages/NewDocument.tsx

Interfaces:

  • buildDraftBody() — the ONE body builder; both save() and the live preview send byte-identical bodies (JSON.stringify(buildDraftBody())).

  • The client-side subtotal reducer (subtotalPaise, old line 91) and its Subtotal … + GST badge are DELETED. The sticky totals strip renders response.totals (Taxable / CGST+SGST or IGST / Round-off / Payable) via formatINR, dimmed to ~60% while a preview is in flight, greyed on error, with the IGST split appearing live for out-of-state clients. A "split pending client" tag shows when no client is picked; a changed cell pulses briefly.

  • Commit-shaped events (client pick/change, doctype radio, add/remove line, module/kind/pack select, field blur) bump commitNonce → immediate refire; keystrokes (qty/unit/description/content/terms) → 400ms debounce.

  • Responsive: ≥800px = form ~600px left (own scroll) + A4 paper card filling the rest (sticky). <800px = single-column form + a pinned bottom bar (always shows Payable + GST) with a Preview button that opens the paper as a full-screen sheet.

  • Step 1: Replace apps/hq-web/src/pages/NewDocument.tsx with the full split-view composer:

import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
  createDocument, getClients, getModules, getPrices, previewDocument,
  KIND_LABEL, type Client, type DocTotals, type Kind, type ModulePrice,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'

const today = () => new Date().toISOString().slice(0, 10)

interface LineDraft {
  moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string
  edition: string; contentText: string
}
const EMPTY_LINE: LineDraft = {
  moduleId: '', kind: '', qty: '1', unitRs: '', description: '', edition: 'standard', contentText: '',
}

const editionsOf = (prices: ModulePrice[]): string[] => [...new Set(prices.map((p) => p.edition))]
const splitContent = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '')

function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): number | null {
  const hit = prices
    .filter((p) => p.kind === kind && p.edition === edition && p.effectiveFrom <= today())
    .sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
  return hit !== undefined ? hit.pricePaise : null
}
function editionPrice(prices: ModulePrice[], edition: string, kind: Kind | ''): number | null {
  if (kind !== '') return latestPrice(prices, kind, edition)
  const hit = prices
    .filter((p) => p.edition === edition && p.effectiveFrom <= today())
    .sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
  return hit !== undefined ? hit.pricePaise : null
}

/** matchMedia hook — the composer collapses to one column on the owner's phone. */
function useNarrow(): boolean {
  const [narrow, setNarrow] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 800px)').matches)
  useEffect(() => {
    const mq = window.matchMedia('(max-width: 800px)')
    const on = () => setNarrow(mq.matches)
    mq.addEventListener('change', on)
    return () => mq.removeEventListener('change', on)
  }, [])
  return narrow
}

/** Value that flashes a highlight for 250ms when it changes (cause→effect visible). */
function Pulse({ children, on }: { children: React.ReactNode; on: string | number }) {
  const [pulse, setPulse] = useState(false)
  useEffect(() => { setPulse(true); const t = setTimeout(() => setPulse(false), 250); return () => clearTimeout(t) }, [on])
  return <span style={{ transition: 'background 250ms ease', background: pulse ? 'var(--accent-soft, #dce8ff)' : 'transparent', borderRadius: 4, padding: '0 3px' }}>{children}</span>
}

/** The quotation-in-minutes composer: client → lines → live preview → Save Draft. */
export function NewDocument() {
  const nav = useNavigate()
  const narrow = useNarrow()
  const modules = useData(getModules, [])
  const [docType, setDocType] = useState<'QUOTATION' | 'PROFORMA' | 'INVOICE'>('QUOTATION')
  const [lines, setLines] = useState<LineDraft[]>([{ ...EMPTY_LINE }])
  const [terms, setTerms] = useState('')
  const [error, setError] = useState<string | undefined>()
  const [saving, setSaving] = useState(false)

  // -- live preview wiring: server totals + warnings replace all client money math --
  const [commitNonce, setCommitNonce] = useState(0)
  const commit = () => setCommitNonce((n) => n + 1)
  const [totals, setTotals] = useState<DocTotals | undefined>()
  const [warnings, setWarnings] = useState<string[]>([])
  const [previewBusy, setPreviewBusy] = useState(false)
  const [previewErr, setPreviewErr] = useState<string | null>(null)
  const [sheetOpen, setSheetOpen] = useState(false) // narrow: full-screen paper sheet

  // -- client type-ahead (debounced) --
  const [q, setQ] = useState('')
  const [hits, setHits] = useState<Client[]>([])
  const [client, setClient] = useState<Client | undefined>()
  useEffect(() => {
    if (client !== undefined || q.trim() === '') { setHits([]); return }
    const t = setTimeout(() => { getClients(q).then(setHits).catch(() => setHits([])) }, 250)
    return () => clearTimeout(t)
  }, [q, client])

  const [pricesByModule, setPricesByModule] = useState<Record<string, ModulePrice[]>>({})
  const loadPrices = (moduleId: string): Promise<ModulePrice[]> => {
    const cached = pricesByModule[moduleId]
    if (cached !== undefined) return Promise.resolve(cached)
    return getPrices(moduleId).then((prices) => { setPricesByModule((prev) => ({ ...prev, [moduleId]: prices })); return prices })
  }
  const prefill = (index: number, moduleId: string, kind: Kind, edition: string) => {
    loadPrices(moduleId).then((prices) => {
      const paise = latestPrice(prices, kind, edition)
      setLines((prev) => prev.map((l, i) => (i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l)))
    }).catch(() => { /* no prefill — the unit price stays editable */ })
  }
  const setLine = (index: number, patch: Partial<LineDraft>) =>
    setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)))

  /** The ONE body builder — save() and the live preview post byte-identical bodies. */
  const buildDraftBody = () => ({
    docType,
    clientId: client?.id ?? '',
    lines: lines.filter((l) => l.moduleId !== '' && l.kind !== '').map((l) => ({
      moduleId: l.moduleId, kind: l.kind as Kind, qty: Number(l.qty),
      ...(l.edition !== 'standard' ? { edition: l.edition } : {}),
      ...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
      ...(l.description !== '' ? { description: l.description } : {}),
      contentLines: splitContent(l.contentText),
    })),
    ...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
  })
  const previewBody = useMemo(() => JSON.stringify(buildDraftBody()), [docType, client, lines, terms])

  const save = () => {
    if (client === undefined) { setError('Pick a client first'); return }
    const ready = lines.filter((l) => l.moduleId !== '' && l.kind !== '')
    if (ready.length === 0) { setError('Add at least one line (module + kind)'); return }
    for (const l of ready) {
      if (!Number.isFinite(Number(l.qty)) || Number(l.qty) <= 0) { setError('Every line needs a positive quantity'); return }
      if (l.unitRs !== '' && !Number.isFinite(Number(l.unitRs))) { setError('Unit price must be a rupee amount'); return }
    }
    setError(undefined); setSaving(true)
    createDocument(buildDraftBody())
      .then((d) => nav(`/documents/${d.id}`))
      .catch((e: Error) => { setError(e.message); setSaving(false) })
  }

  const pendingClient = client === undefined
  const stripDim = previewBusy || previewErr !== null

  const totalsStrip = (
    <div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'baseline', padding: '8px 12px', borderTop: '1px solid var(--border)', background: 'var(--bg-raised)', opacity: stripDim ? 0.6 : 1, transition: 'opacity 120ms ease' }}>
      <Cell label="Taxable" value={totals ? formatINR(totals.taxablePaise) : '—'} />
      {totals && totals.igstPaise > 0
        ? <Cell label="IGST" value={formatINR(totals.igstPaise)} />
        : <><Cell label="CGST" value={totals ? formatINR(totals.cgstPaise) : '—'} /><Cell label="SGST" value={totals ? formatINR(totals.sgstPaise) : '—'} /></>}
      {totals && totals.roundOffPaise !== 0 && <Cell label="Round-off" value={formatINR(totals.roundOffPaise)} />}
      <Cell label="Payable" value={totals ? formatINR(totals.payablePaise) : '—'} strong />
      {pendingClient && <Badge tone="warn">split pending client</Badge>}
      {warnings.length > 0 && <Badge tone="warn">{warnings.length} price gap(s)</Badge>}
    </div>
  )

  const paper = (
    <LivePreview
      body={previewBody}
      commitNonce={commitNonce}
      fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
      onResult={(r) => { setTotals(r.totals); setWarnings(r.warnings ?? []) }}
      onStatus={(s) => { setPreviewBusy(s.busy); setPreviewErr(s.error) }}
    />
  )

  const form = (
    <div className="wf-page" style={{ overflow: 'auto', height: narrow ? 'auto' : 'calc(100vh - 200px)', paddingBottom: narrow ? 72 : 0 }}>
      <PageHeader title="New Document" desc="Compose a quotation, proforma or invoice. GST is computed and previewed live by the server." />

      <Field label="Client">
        {client !== undefined ? (
          <Toolbar>
            <Badge tone="accent">{client.code} · {client.name}</Badge>
            <Button onClick={() => { setClient(undefined); setQ(''); commit() }}>Change</Button>
          </Toolbar>
        ) : (
          <div style={{ position: 'relative', maxWidth: 340 }}>
            <input className="wf" placeholder="Type to search clients…" value={q} autoFocus onChange={(e) => setQ(e.target.value)} />
            {hits.length > 0 && (
              <div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 8 }}>
                {hits.slice(0, 8).map((c) => (
                  <button key={c.id} type="button" className="wf" style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
                    onClick={() => { setClient(c); setHits([]); commit() }}>
                    {c.code} · {c.name}
                  </button>
                ))}
              </div>
            )}
          </div>
        )}
      </Field>

      <Field label="Document type">
        <Toolbar>
          {(['QUOTATION', 'PROFORMA', 'INVOICE'] as const).map((t) => (
            <label key={t} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
              <input type="radio" name="doctype" checked={docType === t} onChange={() => { setDocType(t); commit() }} />
              {t === 'QUOTATION' ? 'Quotation' : t === 'PROFORMA' ? 'Proforma' : 'Invoice'}
            </label>
          ))}
        </Toolbar>
      </Field>

      <h3>Lines</h3>
      {lines.map((l, i) => {
        const mod = modules.data?.find((m) => m.id === l.moduleId)
        const prices = pricesByModule[l.moduleId] ?? []
        const editions = editionsOf(prices)
        const priceGap = warnings.some((w) => mod !== undefined && w.includes(mod.code))
        return (
          <div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
            <Field label="Module">
              <select className="wf" value={l.moduleId}
                onChange={(e) => {
                  const moduleId = e.target.value
                  const picked = modules.data?.find((m) => m.id === moduleId)
                  setLine(i, { moduleId, kind: '', unitRs: '', edition: 'standard', contentText: (picked?.quoteContent ?? []).join('\n') })
                  if (moduleId !== '') {
                    loadPrices(moduleId).then((ps) => {
                      const eds = editionsOf(ps); const def = eds.includes('standard') ? 'standard' : (eds[0] ?? 'standard')
                      if (def !== 'standard') setLine(i, { edition: def })
                    }).catch(() => { /* pricing stays manual */ })
                  }
                  commit()
                }}>
                <option value="">Module</option>
                {(modules.data ?? []).filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
              </select>
            </Field>
            <Field label="Kind">
              <select className="wf" value={l.kind}
                onChange={(e) => { const kind = e.target.value as Kind; setLine(i, { kind }); if (l.moduleId !== '') prefill(i, l.moduleId, kind, l.edition); commit() }}>
                <option value="">Kind</option>
                {(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
              </select>
            </Field>
            {editions.length > 1 && (
              <Field label="Pack">
                <select className="wf" value={l.edition}
                  onChange={(e) => { const edition = e.target.value; setLine(i, { edition }); if (l.moduleId !== '' && l.kind !== '') prefill(i, l.moduleId, l.kind, edition); commit() }}>
                  {editions.map((ed) => { const p = editionPrice(prices, ed, l.kind); return <option key={ed} value={ed}>{ed}{p !== null ? ` — ${formatINR(p)}` : ''}</option> })}
                </select>
              </Field>
            )}
            <Field label="Qty">
              <input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} onBlur={commit} />
            </Field>
            <Field label="Unit ₹ (ex-GST)">
              <input className="wf num" style={{ width: 120, ...(priceGap ? { borderColor: 'var(--warn, #d08700)' } : {}) }}
                placeholder={priceGap ? 'No price — enter Unit ₹' : ''} value={l.unitRs}
                onChange={(e) => setLine(i, { unitRs: e.target.value })} onBlur={commit} />
            </Field>
            <Field label="Description (optional)">
              <input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} onBlur={commit} />
            </Field>
            <Field label="What's included (one per line)">
              <textarea className="wf" rows={2} style={{ minWidth: 220 }} value={l.contentText} onChange={(e) => setLine(i, { contentText: e.target.value })} onBlur={commit} />
            </Field>
            {lines.length > 1 && (
              <Button tone="danger" onClick={() => { setLines((prev) => prev.filter((_x, j) => j !== i)); commit() }}>Remove</Button>
            )}
          </div>
        )
      })}
      <Toolbar>
        <Button onClick={() => { setLines((prev) => [...prev, { ...EMPTY_LINE }]); commit() }}>Add line</Button>
      </Toolbar>

      <Field label="Terms (optional)">
        <textarea className="wf" rows={2} style={{ maxWidth: 560 }} value={terms} onChange={(e) => setTerms(e.target.value)} onBlur={commit} />
      </Field>

      {modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
      {error !== undefined && <Notice tone="err">{error}</Notice>}
      <div style={{ marginTop: 10 }}>
        <Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save Draft'}</Button>
      </div>
    </div>
  )

  if (narrow) {
    return (
      <>
        {form}
        {/* pinned bar never disappears — always shows Payable + GST + Preview */}
        <div style={{ position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 20, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '8px 12px', background: 'var(--bg-raised)', borderTop: '1px solid var(--border)' }}>
          <div style={{ display: 'flex', gap: 12, opacity: stripDim ? 0.6 : 1 }}>
            <Cell label="GST" value={totals ? formatINR((totals.cgstPaise + totals.sgstPaise) || totals.igstPaise) : '—'} />
            <Cell label="Payable" value={totals ? formatINR(totals.payablePaise) : '—'} strong />
          </div>
          <Button tone="primary" onClick={() => setSheetOpen(true)}>Preview</Button>
        </div>
        {sheetOpen && (
          <div style={{ position: 'fixed', inset: 0, zIndex: 30, background: 'var(--bg-sunken, #e9e9ee)', display: 'flex', flexDirection: 'column' }}>
            <div style={{ display: 'flex', justifyContent: 'flex-end', padding: 8 }}><Button onClick={() => setSheetOpen(false)}>Close</Button></div>
            <div style={{ flex: 1, margin: 12, marginTop: 0, background: '#fff', borderRadius: 4, overflow: 'hidden' }}>{paper}</div>
            {totalsStrip}
          </div>
        )}
      </>
    )
  }

  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
      <div style={{ flex: '0 0 600px', minWidth: 0 }}>{form}</div>
      <div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
        <div style={{ flex: 1, minHeight: 0, background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>{paper}</div>
        {totalsStrip}
      </div>
    </div>
  )
}

function Cell({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
  return (
    <span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.2 }}>
      <span style={{ fontSize: 10, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>{label}</span>
      <Pulse on={value}><span style={{ fontSize: strong ? 15 : 13, fontWeight: strong ? 700 : 500 }}>{value}</span></Pulse>
    </span>
  )
}

Notes for the implementer: the React.ReactNode type in Pulse needs import type React from 'react' or switch the annotation to ReactNode imported from react — match whichever the repo's other files use (Dashboard.tsx imports type ReactNode from react; follow that). The old subtotalPaise reducer and its badge are gone — grep the file to confirm no subtotalPaise remains.

  • Step 2: Browser-verify (API on 5182, npm run dev -w @sims/hq-web on 5183; open http://localhost:5183 → New Document):

    • Server-truth totals: pick an in-state client, add a priced line → the strip shows Taxable / CGST / SGST / Payable from the server; there is no client-side subtotal anywhere. Switch to an out-of-state client → the strip flips to IGST live and the paper's totals block matches.
    • Split layout: form is ~600px on the left with its own scroll; the A4 paper fills the rest and stays sticky under the header.
    • Permissive: add a module with no price → the Unit ₹ cell flags amber ("No price — enter Unit ₹"), the strip shows "price gap(s)", the paper still renders (that line at ₹0); typing a Unit ₹ clears the flag on blur.
    • No client: before picking a client the paper shows "— pick a client —", the strip shows "split pending client" with an 18% total; picking a client clears the tag with a pulse.
    • Pulse: changing qty pulses the changed totals cells briefly.
    • Narrow: shrink the window below 800px → single column; the pinned bottom bar always shows Payable + GST; Preview opens a full-screen paper sheet with Close.
    • Save still strict: with a genuine price gap, click Save Draft → the server 400 surfaces as an error and nothing persists; fill the price and Save → navigates to the saved draft. Confirm the saved document's totals equal what the strip showed.
  • Step 3: Typecheck + build + commitnpm run typecheck clean; npm run build -w @sims/hq-web clean.

git add apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): composer split view — server-truth totals strip, live paper, responsive" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 10: Document Template page — company block + template.* + logo upload + live letterhead preview

Frontend task — browser-verified. This absorbs the company-profile letterhead surface: CompanyProfile.tsx is removed and its fields move here, into one owner-gated page.

Files:

  • Create: apps/hq-web/src/pages/DocumentTemplate.tsx
  • Modify: apps/hq-web/src/api.ts (template-settings + logo calls)
  • Modify: apps/hq-web/src/main.tsx (route), apps/hq-web/src/Layout.tsx (owner nav item)
  • Remove: apps/hq-web/src/pages/CompanyProfile.tsx (+ its /settings/company route and "Company" nav item)

Interfaces: reuses LivePreview + previewSample. The preview posts the unsaved company + template.* + per-type title edits ({ company, template, titles }) so the owner sees exactly how every document will print. The logo is saved on pick (POST /settings/template/logo, immediate) and the preview reads it back from the saved settings — keeping large data URIs out of every keystroke's request body. Text fields debounce; blur bumps commitNonce.

  • Step 1: Add the api calls to apps/hq-web/src/api.ts (after getCompanyProfile/putCompanyProfile, which stay for backward compatibility):
export const getTemplateSettings = (): Promise<Record<string, string>> =>
  apiFetch<{ settings: Record<string, string> }>('/settings/template').then((r) => r.settings)
export const putTemplateSettings = (
  body: { company?: Record<string, string>; template?: Record<string, string>; titles?: Record<string, string> },
): Promise<Record<string, string>> =>
  apiFetch<{ settings: Record<string, string> }>('/settings/template', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.settings)
export const uploadTemplateLogo = (dataUri: string): Promise<string> =>
  apiFetch<{ logo: string }>('/settings/template/logo', { method: 'POST', body: JSON.stringify({ dataUri }) }).then((r) => r.logo)
  • Step 2: Create apps/hq-web/src/pages/DocumentTemplate.tsx
import { useEffect, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { Button, Field, Notice, PageHeader } from '@sims/ui'
import { getTemplateSettings, previewSample, putTemplateSettings, role, uploadTemplateLogo } from '../api'
import { LivePreview } from '../components/LivePreview'

const COMPANY_FIELDS: { key: string; setting: string; label: string; area: boolean }[] = [
  { key: 'name', setting: 'company.name', label: 'Company name', area: false },
  { key: 'address', setting: 'company.address', label: 'Address', area: true },
  { key: 'gstin', setting: 'company.gstin', label: 'GSTIN', area: false },
  { key: 'stateCode', setting: 'company.state_code', label: 'State code', area: false },
  { key: 'phone', setting: 'company.phone', label: 'Phone', area: false },
  { key: 'email', setting: 'company.email', label: 'Email', area: false },
  { key: 'bank', setting: 'company.bank', label: 'Bank details', area: true },
]
const TEMPLATE_FIELDS: { key: string; setting: string; label: string; area: boolean }[] = [
  { key: 'terms', setting: 'template.terms', label: 'Default terms', area: true },
  { key: 'declaration', setting: 'template.declaration', label: 'GST declaration', area: true },
  { key: 'jurisdiction', setting: 'template.jurisdiction', label: 'Jurisdiction line', area: false },
  { key: 'footerNote', setting: 'template.footer_note', label: 'Footer note', area: false },
  { key: 'signatoryLabel', setting: 'template.signatory_label', label: 'Signatory label', area: false },
]
const DOC_TITLES: { type: string; setting: string; label: string }[] = [
  { type: 'INVOICE', setting: 'template.title_INVOICE', label: 'Invoice title' },
  { type: 'QUOTATION', setting: 'template.title_QUOTATION', label: 'Quotation title' },
  { type: 'PROFORMA', setting: 'template.title_PROFORMA', label: 'Proforma title' },
]

type Dict = Record<string, string>

/** Owner-only: all letterhead text data (company.* + template.*) with a live
 *  letterhead preview beside the form. One fixed layout — not a visual editor. */
export function DocumentTemplate() {
  const [company, setCompany] = useState<Dict>({})
  const [template, setTemplate] = useState<Dict>({})
  const [titles, setTitles] = useState<Dict>({})
  const [logo, setLogo] = useState('')
  const [commitNonce, setCommitNonce] = useState(0)
  const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
  const [saving, setSaving] = useState(false)

  const load = (s: Dict): void => {
    setCompany(Object.fromEntries(COMPANY_FIELDS.map((x) => [x.key, s[x.setting] ?? ''])))
    setTemplate(Object.fromEntries(TEMPLATE_FIELDS.map((x) => [x.key, s[x.setting] ?? ''])))
    setTitles(Object.fromEntries(DOC_TITLES.map((x) => [x.type, s[x.setting] ?? ''])))
    setLogo(s['template.logo'] ?? '')
  }
  useEffect(() => { getTemplateSettings().then(load).catch((e: Error) => setMsg({ tone: 'err', text: e.message })) }, [])

  // Logo excluded from the body — it is saved on pick and the preview reads it back.
  const previewBody = useMemo(() => JSON.stringify({ company, template, titles }), [company, template, titles])
  const commit = () => setCommitNonce((n) => n + 1)

  if (role() !== 'owner') return <Navigate to="/" replace />

  const save = () => {
    setSaving(true); setMsg(undefined)
    putTemplateSettings({ company, template, titles })
      .then((s) => { load(s); setMsg({ tone: 'ok', text: 'Saved. Every document uses the new template immediately.' }) })
      .catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
      .finally(() => setSaving(false))
  }
  const onLogo = (file: File | undefined) => {
    if (file === undefined) return
    const reader = new FileReader()
    reader.onload = () => {
      uploadTemplateLogo(String(reader.result))
        .then((saved) => { setLogo(saved); commit(); setMsg({ tone: 'ok', text: 'Logo saved.' }) })
        .catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
    }
    reader.readAsDataURL(file)
  }

  const textField = (dict: Dict, setDict: (u: (p: Dict) => Dict) => void) =>
    (x: { key: string; label: string; area: boolean }) => (
      <Field key={x.key} label={x.label}>
        {x.area
          ? <textarea className="wf" rows={2} style={{ width: '100%' }} value={dict[x.key] ?? ''} onChange={(e) => setDict((p) => ({ ...p, [x.key]: e.target.value }))} onBlur={commit} />
          : <input className="wf" style={{ width: '100%' }} value={dict[x.key] ?? ''} onChange={(e) => setDict((p) => ({ ...p, [x.key]: e.target.value }))} onBlur={commit} />}
      </Field>
    )

  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
      <div className="wf-page" style={{ flex: '0 0 520px', minWidth: 0, overflow: 'auto', height: 'calc(100vh - 140px)' }}>
        <PageHeader title="Document template" desc="Everything the letterhead prints — one fixed layout, all text editable. Changes apply to every document immediately." />
        {msg !== undefined && <Notice tone={msg.tone}>{msg.text}</Notice>}

        <h3>Company</h3>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{COMPANY_FIELDS.map(textField(company, setCompany))}</div>

        <h3 style={{ marginTop: 18 }}>Logo</h3>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <input type="file" accept="image/*" onChange={(e) => onLogo(e.target.files?.[0])} />
          {logo !== '' && <><img src={logo} alt="logo" style={{ maxHeight: 40 }} /><Button onClick={() => { uploadTemplateLogo('').then(() => { setLogo(''); commit() }).catch(() => { /* ignore */ }) }}>Remove</Button></>}
        </div>

        <h3 style={{ marginTop: 18 }}>Boilerplate text</h3>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{TEMPLATE_FIELDS.map(textField(template, setTemplate))}</div>

        <h3 style={{ marginTop: 18 }}>Document titles (optional overrides)</h3>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {DOC_TITLES.map((t) => (
            <Field key={t.type} label={t.label}>
              <input className="wf" style={{ width: '100%' }} placeholder="built-in default" value={titles[t.type] ?? ''} onChange={(e) => setTitles((p) => ({ ...p, [t.type]: e.target.value }))} onBlur={commit} />
            </Field>
          ))}
        </div>

        <div style={{ marginTop: 14 }}><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save template'}</Button></div>
      </div>

      <div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
        <div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
          <LivePreview
            body={previewBody}
            commitNonce={commitNonce}
            fetchPreview={(b, signal) => previewSample(JSON.parse(b), signal).then((r) => ({ html: r.html }))}
          />
        </div>
      </div>
    </div>
  )
}
  • Step 3: Route + nav + removal. In apps/hq-web/src/main.tsx, replace the CompanyProfile import + route:
import { DocumentTemplate } from './pages/DocumentTemplate'
// …
<Route path="/settings/template" element={<DocumentTemplate />} />

(delete the CompanyProfile import and the /settings/company route). In apps/hq-web/src/Layout.tsx, replace the owner nav item:

  const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/template', label: 'Document Template' }] : BASE_NAV

Delete apps/hq-web/src/pages/CompanyProfile.tsx. The server GET/PUT /settings/company routes and the client getCompanyProfile/putCompanyProfile exports stay (unused, backward-compatible; settings-company.test.ts keeps them green).

  • Step 4: Browser-verify (owner login → Document Template):

    • The right pane shows a sample TAX INVOICE letterhead. Edit Company name → header updates on blur; type in Bank details → bank box updates after the debounce; the paper never blanks.
    • Set GST declaration, Footer note, Jurisdiction line, Signatory label → each appears on the sample where it prints; clearing a field removes it (default). Set Invoice title = "GST INVOICE" → the doc title changes.
    • Upload a small PNG logo → it appears in the letterhead header within a moment (saved immediately); Remove clears it. Try a >200 KB image → an error notice, no change.
    • Click Save template, reload → all fields persist and the preview matches. Open New Document, add a line → the composer's live paper now shows the same footer/declaration/logo (composer reads the saved template.* too).
    • As staff (role=staff), /settings/template redirects to / and the nav item is absent.
  • Step 5: Typecheck + build + commitnpm run typecheck + npm run build -w @sims/hq-web clean.

git add apps/hq-web/src/pages/DocumentTemplate.tsx apps/hq-web/src/api.ts apps/hq-web/src/main.tsx apps/hq-web/src/Layout.tsx
git rm apps/hq-web/src/pages/CompanyProfile.tsx
git commit -m "feat(hq-web): Document Template page — editable letterhead data + live preview" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 11: Module quote-content — live print preview

Frontend task — browser-verified.

Files:

  • Modify: apps/hq-web/src/pages/Modules.tsx

Interfaces: the QuoteContent sub-component gains a live preview showing how the "what's included" bullets actually print (the 10px grey ul.line-content under a sample line) — today they are written blind. Reuses LivePreview + previewSample({ contentLines }). Owner-only surface (staff see the read-only list unchanged); the preview mounts only when isOwner.

  • Step 1: Edit apps/hq-web/src/pages/Modules.tsx. Add imports:
import { useMemo, useState } from 'react'
import { addPrice, createModule, getModules, getPrices, patchModule, previewSample, role, KIND_LABEL, type Kind, type Module } from '../api'
import { LivePreview } from '../components/LivePreview'

In the QuoteContent component, add preview state and mount the pane below the editor (owner path only). Replace the owner branch of QuoteContent with:

  const [commitNonce, setCommitNonce] = useState(0)
  const previewBody = useMemo(() => JSON.stringify({ contentLines: splitLines(text) }), [text])
  // ... existing `save` unchanged ...
  return (
    <>
      <h3 style={{ marginTop: 20 }}>Quote content  {props.module.name}</h3>
      {props.isOwner ? (
        <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
          <div style={{ flex: '0 0 560px', minWidth: 0 }}>
            <textarea className="wf" rows={4} placeholder="One included item per line…"
              value={text} onChange={(e) => { setText(e.target.value); setSaved(false) }} onBlur={() => setCommitNonce((n) => n + 1)} />
            <Toolbar>
              <Button tone="primary" onClick={save}>Save quote content</Button>
              {saved && <Badge tone="ok">saved</Badge>}
            </Toolbar>
            {error !== undefined && <Notice tone="err">{error}</Notice>}
          </div>
          <div style={{ flex: 1, minWidth: 320, height: 420, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
            <div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
              <LivePreview
                body={previewBody}
                commitNonce={commitNonce}
                fetchPreview={(b, signal) => previewSample(JSON.parse(b), signal).then((r) => ({ html: r.html }))}
              />
            </div>
          </div>
        </div>
      ) : props.module.quoteContent.length === 0 ? (
        <EmptyState>No quote content.</EmptyState>
      ) : (
        <ul>{props.module.quoteContent.map((line, i) => <li key={i}>{line}</li>)}</ul>
      )}
    </>
  )

splitLines is the existing helper at the top of Modules.tsx; leave it as-is.

  • Step 2: Browser-verify (owner login → Modules → click a module):

    • The "Quote content" editor shows a sample invoice on the right with the current bullets rendered as the grey ul.line-content under the line.
    • Add/remove a bullet line in the textarea → after blur the printed bullets update to match; the paper never blanks.
    • Staff login (or localStorage['hq.role']='staff') → the preview is absent and the read-only list renders as before.
  • Step 3: Typecheck + build + commitnpm run typecheck + npm run build -w @sims/hq-web clean.

git add apps/hq-web/src/pages/Modules.tsx
git commit -m "feat(hq-web): module quote-content live print preview" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 12: Reminder email preview in the manual queue

Frontend task — browser-verified.

Files:

  • Modify: apps/hq-web/src/pages/Dashboard.tsx

Interfaces: the reminder queue's QueueActions gains a Preview button (sendable kinds only) that fetches getReminderPreview(id) → the exact outgoing subject + body (from the pure reminderEmail) and shows it inline before Send — staff currently fire client-facing dunning mail unseen. This is text, not an iframe, so it does not use LivePreview.

  • Step 1: Edit apps/hq-web/src/pages/Dashboard.tsx. Add getReminderPreview to the api import:
import { getDashboard, sendReminder, dismissReminder, getReminderPreview, type Reminder } from '../api'

Replace QueueActions with a version that previews before sending:

function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone: () => void; onError: (m: string) => void }) {
  const [busy, setBusy] = useState(false)
  const [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
  const [loading, setLoading] = useState(false)
  const run = (p: Promise<unknown>) => {
    setBusy(true); props.onError('')
    p.catch((e: Error) => props.onError(e.message))
      // Reload even on failure: the server has already parked the reminder as
      // 'failed' with its error, and the row must flip live.
      .then(props.onDone)
      .finally(() => setBusy(false))
  }
  const preview = () => {
    if (mail !== undefined) { setMail(undefined); return } // toggle closed
    setLoading(true); props.onError('')
    getReminderPreview(props.rem.id)
      .then(setMail)
      .catch((e: Error) => props.onError(e.message))
      .finally(() => setLoading(false))
  }
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
      <div style={{ display: 'flex', gap: 6 }}>
        {SENDABLE.has(props.rem.ruleKind) && (
          <>
            <Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
            <Button tone="primary" onClick={() => run(sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
          </>
        )}
        <Button onClick={() => run(dismissReminder(props.rem.id))}>Dismiss</Button>
      </div>
      {mail !== undefined && (
        <div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
          <div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>
          <div style={{ fontWeight: 600, marginBottom: 4 }}>{mail.subject}</div>
          <div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Body</div>
          <pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'inherit', fontSize: 13 }}>{mail.body}</pre>
        </div>
      )}
    </div>
  )
}
  • Step 2: Browser-verify (owner login → Dashboard, with at least one sendable reminder in the queue — an overdue invoice reminder is easiest to seed):

    • Click Preview on an overdue reminder → the exact subject (e.g. Payment reminder — Invoice INV/… (Tecnostac)) and body appear inline, matching what Send would email; Hide collapses it.
    • Internal-only rows (follow-up / bounced) show no Preview/Send button (only Dismiss), unchanged.
  • Step 3: Typecheck + build + commitnpm run typecheck + npm run build -w @sims/hq-web clean.

git add apps/hq-web/src/pages/Dashboard.tsx
git commit -m "feat(hq-web): reminder email preview in the manual queue" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 13: Final whole-feature browser walk

Frontend verification only — no code, no commit unless a fix is needed (fix under the relevant task's commit style if so).

  • Step 1: Ensure a clean tree and green suite: npm test (all vitest green — server Tasks 17), npm run typecheck clean, npm run build -w @sims/hq-web clean.
  • Step 2: Start the API (npm run start -w @sims/hq, port 5182) and the web dev server (npm run dev -w @sims/hq-web, port 5183); log in as owner at http://localhost:5183.
  • Step 3: Composer walk. New Document → pick an in-state client, add two priced lines → watch the letterhead build in <~400ms; the totals strip shows CGST+SGST from the server (no client math anywhere). Switch to an out-of-state client → IGST appears live and the paper matches. Introduce a price gap → amber flag + "price gap(s)"; the paper keeps rendering. Save Draft with the gap → strict 400, nothing persists; fill the price → Save navigates to the draft whose stored totals equal the strip.
  • Step 4: No-blank + resilience. Type quickly across fields → no white flash, no scroll jump, one in-flight request at a time. Stop the API mid-edit → "Preview paused — retrying", last paper stays, totals grey; restart → recovers.
  • Step 5: Narrow. Below 800px → single column, pinned Payable+GST bar always visible, Preview opens the full-screen paper sheet.
  • Step 6: Editable template data. Document Template → set declaration / footer / jurisdiction / signatory / an invoice-title override, upload a logo → the sample letterhead reflects each live; Save, then open New Document and confirm the composer paper now carries the same footer/declaration/logo (both read the saved template.*). Confirm /settings/template is owner-only.
  • Step 7: Other siblings. Modules → quote-content bullets render live under a sample line. Dashboard → reminder Preview shows the exact outgoing email.
  • Step 8: Fidelity spot-check. Save a draft, open the Document page (DocumentView PDF), and confirm the printed PDF matches the composer's live paper for the same draft (same letterhead, template fields, logo, lines, totals, DRAFT marker). The golden test already guarantees byte identity; this is the human confirmation.

Self-review (pre-implementation)

  • 13 tasks: 7 server (TDD) + 6 frontend (browser-verified). Server: 1 prepareDraft extraction + parity, 2 POST /documents/preview, 3 fidelity golden, 4 template.* rendering with default-fallback, 5 @media screen + documentHtmlSample + POST /previews/sample, 6 GET/PUT /settings/template + logo, 7 reminder preview. Frontend: 8 LivePreview, 9 composer split view, 10 Document Template page, 11 quote-content, 12 reminder-queue preview, 13 final walk.
  • Spec coverage (all of §1§8): one shared compute path (§12 → Tasks 13), permissive preview persisting nothing (§2 → Task 2), the editable template.* data + Document Template page absorbing the company-profile letterhead (§4 → Tasks 4, 6, 10), the @media screen template-owned paper (§3 → Task 5), the split composer with the deleted client reducer + server-truth totals + IGST-live + responsive narrow sheet (§3 → Task 9), and the two remaining sibling surfaces — quote-content and reminder email (§5 → Tasks 1112). Out of scope stated where relevant: a visual layout editor (Store product's Print Templates, separate repo), template.* on receiptHtml, the "Exact PDF" button, and the dashed page-break line.
  • No placeholders: every server task ships a verbatim failing test and full implementation; every frontend task ships full component code + an explicit browser-verification step (iframe/matchMedia/layout/upload UI with no unit-testable surface).
  • Type consistency (checked against the real code): prepareDraft returns BillTotals/DocPayload already imported in repos-documents.ts; the preview route's synthetic Doc sets exactly the fields documentHtml reads and ignored fields are harmless; fyOf/type Client/type Doc imports added where used; documentHtml's settings-map param already types as Record<string,string>, so reading new template.* keys needs no signature change; companySettings() broadening feeds the same map to the pdf/send/preview/sample routes; the logo regex + 200 KB cap run inside the route (with express.json limit raised so a legitimate logo is not pre-rejected); client DocTotals is the exact subset the strip renders.
  • Two intentional refinements, both preserving "one renderer, one compute path": (1) prepareDraft returns { docType, clientId, docDate, totals, payload, warnings } (a superset of the design's { lines, totals, payload }) so save and preview share one docDate/fy and warnings[] has a home. (2) The design's "one preview route" is realized as the shared LivePreview mechanism + the single documentHtml/documentHtmlSample renderer, fed by POST /documents/preview (composer) and one thin POST /previews/sample (the two template-editing surfaces preview unsaved company/template.*/bullet edits that /documents/preview structurally cannot). Editable template.* is pure data through that same renderer — still no second renderer, and the PDF picks the fields up for free via the broadened companySettings().
  • Regression discipline: the two extractions (prepareDraft, reminderContext) are behaviour-preserving and each task re-runs the pre-existing suite (documents.test.ts, send-reminder.test.ts) as its green gate; templates.test.ts (substring assertions) and settings-company.test.ts are unaffected by the added @media screen lines, template.* defaults, and the new /settings/template routes (the old /settings/company routes stay); the root typecheck gate and apps/hq-web Vite build run at every commit.