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 (1–7) 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 (8–13) 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, 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.
computeBillis the only tax authority. The client-side subtotal reducer atNewDocument.tsx:91is 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 inapps/hq-web; the totals strip rendersresponse.totalsverbatim viaformatINR. - Preview persists nothing.
POST /api/documents/previewandPOST /api/previews/samplewrite nodocument, noaudit_log, nodocument_eventrow — they are reads.prepareDraftis 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 inwarnings[], never throwing where save would.POST /api/documents(save) andcreateDraftstay 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
htmlequals the exact stringrenderPdfwould receive for a fixture draft; aprepareDraft↔createDraftparity 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
Docuses the throwawayid: 'preview'(never persisted).docNo: null→ the template prints "DRAFT";docDate= today;fyviafyOf. - 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 screenpaper styles live insidetemplates.ts(with apage.pdf renders print mediacomment) 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
/api→http://localhost:5182(seeapps/hq-web/vite.config.ts). - Tests are offline and deterministic — vitest under
apps/hq/test/(already in the vitestinclude); the express+listen+fetch harness fromsettings-company.test.ts; puppeteer never launches. No test performs a network call. - Per-app typecheck is part of the root gate:
npm run typecheckrunstsc -p tsconfig.json && npm run typecheck --workspaces --if-present. Bothapps/hqandapps/hq-webmust typecheck clean at every commit, andnpm run build -w @sims/hq-web(Vite) must stay clean. - Commit after every task. Each commit message ends with the trailer — use the two-
-mform: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); 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.tsmust 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 oldcreateDraft, persisting nothing. Strict by default (missing price throws the unchanged message; unknown client throwsClient not found). Permissive: a missing price computes at ₹0 with awarnings[]entry; a null/unknown client defaultsplaceOfSupplyto our own state and adds a pending-client warning; zero lines returns zero totals + empty payload (never callscomputeBill, which throws on an empty bill).
-
Modifies:
createDraftbecomesprepareDraft+insertDocRowinside the existingdb.transaction. Byte-for-byte the same persisted result.buildLinesgains an optionalwarnings?: string[]param — when passed (permissive), a missing price pushes a warning and resolves to0; when omitted (strict), it throws the original message verbatim.createCreditNotestill callsbuildLineswithout warnings → unchanged strict behaviour.
-
Consumes: existing
computeBill,buildLines,supplyStateCode,taxRates,getClient,getModule,todayIso,fyOf— all already in the file. -
Step 1: Write the failing test —
apps/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 FAIL —
npx vitest run apps/hq/test/prepare-draft.test.ts→ FAIL (prepareDraftnot 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 tests —
npx 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 exactDraftInputshapePOST /documentstakes. Builds a synthetic unsavedDoc(id: 'preview',docNo: null→ template prints "DRAFT",docDate: today,fyviafyOf), runsprepareDraft(..., { permissive: true })+documentHtml, and returns{ ok: true, html: string, totals: BillTotals, warnings: string[] }. When no client is picked (clientIdempty/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 existingvalidateGstinimport),prepareDraft+type Doc(from./repos-documents),type Client(from./repos-clients). -
Step 1: Write the failing test —
apps/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 FAIL —
npx 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 tests —
npx 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 1–2 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 test —
apps/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: Run —
npx vitest run apps/hq/test/preview-fidelity.test.ts. It should PASS immediately on top of Tasks 1–2 (the whole point: fidelity is already structural). If it fails, the divergence is a real bug — diffout.htmlvsexpectedand fix the preview route's syntheticDoc(usually afy/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(broadencompanySettings()to carrytemplate.*) - Test:
apps/hq/test/template-render.test.ts - Regression:
apps/hq/test/templates.test.ts,preview-route.test.ts,preview-fidelity.test.tsstay green (they assertcompany.*/structural substrings, nottemplate.*).
Interfaces:
-
documentHtmlrenders the new optionaltemplate.*keys from its settings map, each defaulting to today's output when the key is absent (spec §4 — nothing breaks pre-config). A localtpl(key) = company['template.' + key] ?? ''helper mirrors the existingget:- Title:
template.title_<DOCTYPE>overridesTITLE[doc.docType](default = the built-in map). - Logo:
template.logo(adata: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.termsis 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 Signatorywithtpl('signatory_label')when set (default =Authorised Signatory). - This round touches
documentHtmlonly;receiptHtmlkeeps today's letterhead (receipts aren't previewed — a documented small follow-up).
- Title:
-
api.ts:companySettings()broadens its query toWHERE key LIKE 'company.%' OR key LIKE 'template.%'so the one settings map documentHtml reads (in the pdf/send/preview/sample routes) now carriestemplate.*. The name stayscompanySettingsto avoid touching call sites; a comment notes it is the full letterhead settings map. -
Step 1: Write the failing test —
apps/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 FAIL —
npx vitest run apps/hq/test/template-render.test.ts→ FAIL. -
Step 3a: Implement
documentHtmlintemplates.ts. Add thetplhelper besideget, 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()inapi.tsso every documentHtml route carriestemplate.*:
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 tests —
npx 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 assertcompany.*/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 screenblock insidedocumentHtml's<style>(mirrored intoreceiptHtml's<style>for consistency): on screen puppeteer is not involved, so@pagemargins do not apply — mimic the 14mm print margin and a white page.page.pdfrenders the print media, so these screen-only rules never touch the actual PDF. Existingtemplates.test.tsuses.toContainsubstring assertions, so the added lines cannot regress it; the fidelity test (Task 3) comparesdocumentHtml↔documentHtml, so both sides gain the lines identically. documentHtmlSample(company: Record<string, string>, opts?: { contentLines?: string[] }): string— a cannedDoc+Client(a TAX INVOICE so header + GSTIN + bank box + signatory all render) passed through the ONEdocumentHtml. Because the map it receives may carrytemplate.*, the sample automatically showcases the Task-4 fields (logo/title/declaration/footer/…). The sample client'sstateCode= the company's own state (intra-state → CGST/SGST).opts.contentLinesoverrides the single line's "what's included" bullets. Used by the Document Template page and the quote-content editor; there is no second renderer.
- Adds a ~5-line
-
api.ts:POST /api/previews/sample(requireAuth) — body{ company?: Record<string, string>; template?: Record<string, string>; titles?: Record<string, string>; logo?: string; contentLines?: string[] }, wherecompany/templateuse the same field keys asPUT /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 sharedCOMPANY_FIELDS/TEMPLATE_FIELDS/DOC_TYPESmaps near the top ofapiRouter(Task 6'sPUT /settings/templateconsumes the same definitions).- Imports added:
documentHtmlSample(join the existingdocumentHtmlimport from./templates).
-
Step 1: Write the failing test —
apps/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 FAIL —
npx vitest run apps/hq/test/preview-sample.test.ts→ FAIL (documentHtmlSample/ route missing,@media screenabsent). -
Step 3a: Implement
templates.ts. AddBillTotalsto 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. AdddocumentHtmlSampleto 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 tests —
npx 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>"
Task 6: GET/PUT /api/settings/template + POST /api/settings/template/logo
Files:
- Modify:
apps/hq/src/api.ts - Test:
apps/hq/test/settings-template.test.ts
Interfaces:
-
GET /api/settings/template(requireAuth) →{ ok, settings }wheresettingsis the full letterhead map (company.*+template.*) fromcompanySettings(). -
PUT /api/settings/template(requireAuth,requireOwner) — body{ company?, template?, titles? }using the field keys ofCOMPANY_FIELDS/TEMPLATE_FIELDS+ atitlesobject keyed by DocType. Validates GSTIN + two-digit state code exactly like the existingPUT /settings/company; writes each provided field viasetSetting(owner-gated, each field audited); returns the refreshed{ ok, settings }. The existingGET/PUT /settings/companyroutes stay (backward-compatible;settings-company.test.tskeeps 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 adata: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 viasetSetting('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 test —
apps/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 FAIL —
npx 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 existingPUT /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 tests —
npx 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;sendRemindercalls it — behaviour unchanged) - Modify:
apps/hq/src/api.ts - Test:
apps/hq/test/reminder-preview.test.ts - Regression:
apps/hq/test/send-reminder.test.tsmust stay green.
Interfaces:
-
send-reminder.tsproducesreminderContext(db, reminder, companyName): { ctx: ReminderContext; doc: Doc | null; client: Client }— the per-rule context the send path builds, resolved once (client + optional doc).sendReminderconsumes it (singlegetClient, 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 ofsendReminder, beforereminderContext. -
api.ts:GET /api/reminders/:id/preview(requireAuth) →{ ok, subject, body }fromreminderEmail(reminder.ruleKind, ctx). 404 for an unknown reminder; 400 for an internal kind (mirrorssendReminder). Imports added:reminderContext(from./send-reminder),reminderEmail(from./reminder-templates). -
Step 1: Write the failing test —
apps/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 FAIL —
npx vitest run apps/hq/test/reminder-preview.test.ts→ FAIL (route missing). -
Step 3a: Extract
reminderContextinsend-reminder.ts. Add the exported helper and rewrite the body ofsendReminderto 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 tests —
npx 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 mirroringfetchPdfBlob(carries the bearer token, passes theAbortSignal, and lets an abort reject asAbortErrorrather than the genericapiFetch"unreachable" soLivePreviewcan 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).commitNoncebumps to force an immediate (0ms) refire; abody-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, restoresscrollTopon 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).
- Props
-
Step 1: Add the preview fetchers to
apps/hq-web/src/api.ts(afterfetchPdfBlob):
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 existingreturn (...)body in a two-column flex, keeping the current form on the left and adding the preview on the right, fed by the existinglines/client/docTypestate:
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). Openhttp://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/previewat a time (superseded ones show as canceled).
-
Step 5: Typecheck + build + commit —
npm run typecheckclean;npm run build -w @sims/hq-webclean.
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; bothsave()and the live preview send byte-identical bodies (JSON.stringify(buildDraftBody())). -
The client-side subtotal reducer (
subtotalPaise, old line 91) and itsSubtotal … + GSTbadge are DELETED. The sticky totals strip rendersresponse.totals(Taxable / CGST+SGST or IGST / Round-off / Payable) viaformatINR, 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.tsxwith 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-webon 5183; openhttp://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 + commit —
npm run typecheckclean;npm run build -w @sims/hq-webclean.
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.tsxis 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/companyroute 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(aftergetCompanyProfile/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/templateredirects to/and the nav item is absent.
-
Step 5: Typecheck + build + commit —
npm run typecheck+npm run build -w @sims/hq-webclean.
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-contentunder 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.
- The "Quote content" editor shows a sample invoice on the right with the current bullets rendered as the grey
-
Step 3: Typecheck + build + commit —
npm run typecheck+npm run build -w @sims/hq-webclean.
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. AddgetReminderPreviewto 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.
- Click Preview on an overdue reminder → the exact subject (e.g.
-
Step 3: Typecheck + build + commit —
npm run typecheck+npm run build -w @sims/hq-webclean.
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 1–7),npm run typecheckclean,npm run build -w @sims/hq-webclean. - 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 athttp://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/templateis 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 (
DocumentViewPDF), 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
prepareDraftextraction + parity, 2POST /documents/preview, 3 fidelity golden, 4template.*rendering with default-fallback, 5@media screen+documentHtmlSample+POST /previews/sample, 6GET/PUT /settings/template+ logo, 7 reminder preview. Frontend: 8LivePreview, 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 (§1–2 → Tasks 1–3), 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 screentemplate-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 11–12). Out of scope stated where relevant: a visual layout editor (store product's Print Templates),template.*onreceiptHtml, 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):
prepareDraftreturnsBillTotals/DocPayloadalready imported inrepos-documents.ts; the preview route's syntheticDocsets exactly the fieldsdocumentHtmlreads and ignored fields are harmless;fyOf/type Client/type Docimports added where used;documentHtml's settings-map param already types asRecord<string,string>, so reading newtemplate.*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 (withexpress.jsonlimit raised so a legitimate logo is not pre-rejected); clientDocTotalsis the exact subset the strip renders. - Two intentional refinements, both preserving "one renderer, one compute path": (1)
prepareDraftreturns{ docType, clientId, docDate, totals, payload, warnings }(a superset of the design's{ lines, totals, payload }) so save and preview share onedocDate/fyandwarnings[]has a home. (2) The design's "one preview route" is realized as the sharedLivePreviewmechanism + the singledocumentHtml/documentHtmlSamplerenderer, fed byPOST /documents/preview(composer) and one thinPOST /previews/sample(the two template-editing surfaces preview unsaved company/template.*/bullet edits that/documents/previewstructurally cannot). Editabletemplate.*is pure data through that same renderer — still no second renderer, and the PDF picks the fields up for free via the broadenedcompanySettings(). - 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) andsettings-company.test.tsare unaffected by the added@media screenlines,template.*defaults, and the new/settings/templateroutes (the old/settings/companyroutes stay); the root typecheck gate andapps/hq-webVite build run at every commit.