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

2159 lines
130 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Live Document Preview — Implementation Plan (HQ console)
> **STATUS: DELIVERED (2026-07-13).** Kept as the design/task record; current state lives in STATUS.md.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Server tasks (17) are full TDD loops:** write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → `npm run typecheck` → commit. **Frontend tasks (813) cannot be unit-tested** (they are iframe/layout/matchMedia/upload UI) — each ends with an explicit **browser-verification** step against the live app instead of a vitest step, still committing on completion. No task depends on code a later task writes.
**Goal:** Ship the live document preview from [docs/superpowers/specs/2026-07-13-live-preview-design.md](../specs/2026-07-13-live-preview-design.md) on the completed HQ-1/HQ-2/HQ-3a `apps/hq` + `apps/hq-web` codebase. Staff drafting a quotation watch the letterhead build itself under ~400ms per edit, with GST (including the IGST split for out-of-state clients) always computed by the server; the on-screen paper is **byte-identical** to the PDF that later prints; there are no blank flashes; **save stays strict** so bad drafts never persist while **preview is permissive** so staff can keep typing. The same mechanism previews the company letterhead, module quote-content bullets, and reminder emails.
**Architecture:** The one idea that makes this safe — the preview shows the output of the **same** `documentHtml()` template and the **same** `computeBill()` tax engine the PDF uses. There is no second renderer and **no client-side money math**. We extract a pure `prepareDraft(db, input)` from `createDraft` (compute-only, no persist); `createDraft` then calls `prepareDraft` + persists, so save and preview cannot diverge by construction. A new `POST /api/documents/preview` builds a synthetic **unsaved** `Doc`, runs `prepareDraft` + `documentHtml`, and returns `{ ok, html, totals, warnings }` — persisting nothing, writing no audit. The browser renders that HTML directly in a sandboxed `srcdoc` iframe; puppeteer only rasterizes the identical HTML for the actual PDF. Sibling surfaces reuse the one renderer: the letterhead and quote-content previews go through `documentHtmlSample()` + a single thin `POST /api/previews/sample` route (unsaved edits posted in the request body); the reminder-email preview goes through the pure `reminderEmail(kind, ctx)` via `GET /api/reminders/:id/preview`, with the ctx-builder extracted so the previewed email equals the sent one.
**Editable template data (spec §4, founder addition):** all letterhead **text data** becomes DB-editable through settings — one fixed, well-designed layout, **not** a visual layout editor (that is the Store product's Print Templates surface — a separate product/repo — out of scope). GST split logic stays automatic (derived from `company.state_code` vs the client state). Beyond the existing `company.*` settings, `templates.ts` renders new optional `template.*` keys (`template.terms`, `template.declaration`, `template.jurisdiction`, `template.footer_note`, `template.signatory_label`, `template.logo` as a size-capped `data:` URI, and per-type `template.title_<DOCTYPE>`) **each with a built-in default so an absent key renders exactly today's output** — nothing breaks pre-configuration. These live in the same `setting` table (`getSetting`/`setSetting`), so an edit is a DB write the next PDF render and the live preview reflect immediately, no release. A new owner-gated **Document Template** page (`DocumentTemplate.tsx`) groups the `company.*` block + the `template.*` fields + a logo upload with the live letterhead preview beside the form — it **absorbs** the earlier company-profile-letterhead idea into one page. New routes `GET/PUT /api/settings/template` (both groups, owner-gated, each field audited via `setSetting`) and `POST /api/settings/template/logo` (validates content-type + size cap, stores the data URI) model on the existing `GET/PUT /api/settings/company`.
**Tech Stack:** unchanged — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (never launched in tests), vitest ^3, React 19 + Vite 6 + react-router 7, `@sims/ui`. **No new dependencies.** The preview iframe uses native `srcdoc` + `AbortController`; no rasterization anywhere in the test suite.
## Global Constraints
Everything from the HQ-1/HQ-2/HQ-3a plans still holds; the load-bearing ones for the live preview:
- **Money is integer paise**, server-computed. `computeBill` is the only tax authority. The client-side subtotal reducer at `NewDocument.tsx:91` is **DELETED** — its removal is the single biggest trust win (the IGST split now appears live for out-of-state clients). No arithmetic on amounts ever runs in `apps/hq-web`; the totals strip renders `response.totals` verbatim via `formatINR`.
- **Preview persists nothing.** `POST /api/documents/preview` and `POST /api/previews/sample` write no `document`, no `audit_log`, no `document_event` row — they are reads. `prepareDraft` is pure (SQLite reads + string templating, single-digit ms); no puppeteer, no browser contention.
- **Permissive preview vs strict save.** The preview path (`prepareDraft(..., { permissive: true })`) computes a line with no price-book row and no manual rate at **₹0** and reports it in `warnings[]`, never throwing where save would. `POST /api/documents` (save) and `createDraft` stay **strict** and still reject the same bad document with the unchanged error. Empty composer (zero lines) previews as a real letterhead with an empty table and ₹0 — never a mock.
- **Fidelity is architectural, guarded by one golden test.** A string-identity test asserts the preview route's `html` equals the exact string `renderPdf` would receive for a fixture draft; a `prepareDraft`↔`createDraft` parity test asserts totals/payload deep-equal. **No puppeteer in either test — compare the HTML strings, do not rasterize.**
- All ids remain **UUIDv7**; the preview synthetic `Doc` uses the throwaway `id: 'preview'` (never persisted). `docNo: null` → the template prints "DRAFT"; `docDate` = today; `fy` via `fyOf`.
- SQL stays **portable** (D12). This feature adds **no schema, no migration, no new table** — it is compute + templating + routes + UI only.
- **Screen/print deltas are template-owned.** The ~5-line `@media screen` paper styles live **inside `templates.ts`** (with a `page.pdf renders print media` comment) so the paper itself is template-owned and the PDF is unaffected (print media ignores `@media screen`); only preview *chrome* (shimmer, paused banner, totals pulse, "split pending client" tag) is client overlay.
- Server port **5182**; hq-web Vite dev server **5183**, proxying `/api``http://localhost:5182` (see `apps/hq-web/vite.config.ts`).
- **Tests are offline and deterministic** — vitest under `apps/hq/test/` (already in the vitest `include`); the express+listen+fetch harness from `settings-company.test.ts`; puppeteer never launches. No test performs a network call.
- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit, and `npm run build -w @sims/hq-web` (Vite) must stay clean.
- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form:
```bash
git commit -m "feat(hq): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
## Scope (locked — do not reopen)
**In:** `prepareDraft` extraction + parity/permissive; `POST /api/documents/preview`; the string-identity fidelity test; `templates.ts` rendering the new `template.*` fields with default-fallback; `@media screen` paper styles + `documentHtmlSample` + `POST /api/previews/sample`; `GET/PUT /api/settings/template` + `POST /api/settings/template/logo`; `reminderContext` extraction + `GET /api/reminders/:id/preview`; `LivePreview.tsx` (debounced/immediate fetch + double-buffer iframe); `NewDocument.tsx` split layout + `buildDraftBody` + server-truth totals strip (reducer deleted) + responsive narrow layout; the owner-gated **Document Template** page (company block + `template.*` + logo upload + live letterhead preview, absorbing the company-profile letterhead surface); module quote-content live print preview; reminder-email preview in the queue; final browser walk.
**Out (do not plan):** a **visual layout** editor (drag/reposition/restyle — that is the Store product's Print Templates surface, doc 09 in the Store repo); credit-note & receipt preview; the other Store-product surfaces (Label Printing, Price-list bulk revise, Schemes editor, WhatsApp/message-catalog, POS printer-settings receipt preview) — documented handoffs; `template.*` rendering on `receiptHtml` (receipts aren't previewed this round — a small follow-up); the "Exact PDF" on-demand puppeteer button and the approximate page-break line (deferred UX polish); generalizing `LivePreview` beyond the surfaces here (YAGNI). Do not re-litigate these.
## File Structure
```
apps/hq/
src/repos-documents.ts — MODIFY: extract prepareDraft (pure); createDraft calls it; buildLines gains permissive warnings
src/templates.ts — MODIFY: render template.* fields (default-fallback); @media screen paper styles; documentHtmlSample
src/send-reminder.ts — MODIFY: extract reminderContext; sendReminder calls it (behavior unchanged)
src/api.ts — MODIFY: companySettings() → company.* + template.*; POST /documents/preview, POST /previews/sample,
GET/PUT /settings/template, POST /settings/template/logo, GET /reminders/:id/preview
test/prepare-draft.test.ts — NEW (Task 1)
test/preview-route.test.ts — NEW (Task 2)
test/preview-fidelity.test.ts — NEW (Task 3)
test/template-render.test.ts — NEW (Task 4)
test/preview-sample.test.ts — NEW (Task 5)
test/settings-template.test.ts — NEW (Task 6)
test/reminder-preview.test.ts — NEW (Task 7)
apps/hq-web/
src/api.ts — MODIFY: preview fetchers + template-settings/logo calls + types
src/components/LivePreview.tsx — NEW (Task 8): debounced/immediate fetch + double-buffer srcdoc iframe
src/pages/NewDocument.tsx — MODIFY (Task 9): split layout, buildDraftBody, server-truth totals strip, reducer deleted, responsive
src/pages/DocumentTemplate.tsx — NEW (Task 10): owner page — company block + template.* + logo upload + live letterhead preview
src/pages/CompanyProfile.tsx — REMOVE (Task 10): folded into DocumentTemplate; drop its route + nav item + import
src/main.tsx, src/Layout.tsx — MODIFY (Task 10): /settings/template route + "Document Template" owner nav (replaces Company)
src/pages/Modules.tsx — MODIFY (Task 11): live quote-content print preview
src/pages/Dashboard.tsx — MODIFY (Task 12): reminder-email preview in the queue
```
---
### Task 1: Extract `prepareDraft` from `createDraft` (pure, no persist) + permissive warnings
**Files:**
- Modify: `apps/hq/src/repos-documents.ts`
- Test: `apps/hq/test/prepare-draft.test.ts`
- Regression: `apps/hq/test/documents.test.ts` must stay green (createDraft behaviour unchanged).
**Interfaces:**
- Produces:
- `PreparedDraft = { docType: DocType; clientId: string; docDate: string; totals: BillTotals; payload: DocPayload; warnings: string[] }`
- `prepareDraft(db: DB, input: DraftInput, opts?: { permissive?: boolean }): PreparedDraft` — the compute block of the old `createDraft`, persisting nothing. Strict by default (missing price throws the unchanged message; unknown client throws `Client not found`). Permissive: a missing price computes at ₹0 with a `warnings[]` entry; a null/unknown client defaults `placeOfSupply` to our own state and adds a pending-client warning; zero lines returns zero totals + empty payload (never calls `computeBill`, which throws on an empty bill).
- Modifies:
- `createDraft` becomes `prepareDraft` + `insertDocRow` inside the existing `db.transaction`. Byte-for-byte the same persisted result.
- `buildLines` gains an optional `warnings?: string[]` param — when passed (permissive), a missing price pushes a warning and resolves to `0`; when omitted (strict), it throws the original message verbatim. `createCreditNote` still calls `buildLines` without warnings → unchanged strict behaviour.
- Consumes: existing `computeBill`, `buildLines`, `supplyStateCode`, `taxRates`, `getClient`, `getModule`, `todayIso`, `fyOf` — all already in the file.
- [ ] **Step 1: Write the failing test**`apps/hq/test/prepare-draft.test.ts`
```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 (`prepareDraft` not exported).
- [ ] **Step 3: Implement.** In `apps/hq/src/repos-documents.ts`:
Change `buildLines` to accept optional `warnings`:
```ts
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:
```ts
/** 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**
```bash
git add apps/hq/src/repos-documents.ts apps/hq/test/prepare-draft.test.ts
git commit -m "feat(hq): extract pure prepareDraft from createDraft (permissive preview path)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: `POST /api/documents/preview` — permissive, persists nothing
**Files:**
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/preview-route.test.ts`
**Interfaces:**
- Route: `POST /api/documents/preview` (`requireAuth`). Body = the **exact** `DraftInput` shape `POST /documents` takes. Builds a synthetic unsaved `Doc` (`id: 'preview'`, `docNo: null` → template prints "DRAFT", `docDate: today`, `fy` via `fyOf`), runs `prepareDraft(..., { permissive: true })` + `documentHtml`, and returns `{ ok: true, html: string, totals: BillTotals, warnings: string[] }`. When no client is picked (`clientId` empty/unknown) it renders a placeholder "— pick a client —" letterhead at our own state (GST total still authoritative). Persists nothing; writes no audit; no puppeteer.
- Imports added to `api.ts`: `fyOf` (from `@sims/domain`, join the existing `validateGstin` import), `prepareDraft` + `type Doc` (from `./repos-documents`), `type Client` (from `./repos-clients`).
- [ ] **Step 1: Write the failing test**`apps/hq/test/preview-route.test.ts`
```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:
```ts
import { fyOf, validateGstin } from '@sims/domain'
```
```ts
import {
cancelDocument, convertDocument, createCreditNote, createDraft, getDocument,
issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft,
type Doc, type DocumentFilter, type DraftInput, type DraftLineInput,
} from './repos-documents'
```
```ts
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):
```ts
// 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**
```bash
git add apps/hq/src/api.ts apps/hq/test/preview-route.test.ts
git commit -m "feat(hq): POST /documents/preview — permissive live preview, persists nothing" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: Fidelity golden test — preview HTML is byte-identical to the PDF's HTML
**Files:**
- Test only: `apps/hq/test/preview-fidelity.test.ts` (no implementation — this guards the architecture Tasks 12 established; anyone who ever forks the template breaks it).
**Interfaces:** none produced. The test constructs a fixture draft with `createDraft` (the persisted, un-issued draft the `/documents/:id/pdf` route would render), computes `expected = documentHtml(fixtureDoc, client, company)`**the exact string** `renderPdf` receives in `api.ts` (`renderPdf(documentHtml(document, client, company))`) — and asserts the preview route's `html` equals it. No puppeteer; strings only.
- [ ] **Step 1: Write the failing test**`apps/hq/test/preview-fidelity.test.ts`
```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 12 (the whole point: fidelity is already structural). **If it fails**, the divergence is a real bug — diff `out.html` vs `expected` and fix the preview route's synthetic `Doc` (usually a `fy`/`docDate`/totals field) until identical; do **not** weaken the assertion. `npm run typecheck` → clean.
- [ ] **Step 3: Commit**
```bash
git add apps/hq/test/preview-fidelity.test.ts
git commit -m "test(hq): golden fidelity — preview HTML byte-identical to the PDF's HTML" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: `templates.ts` — render the `template.*` fields with default-fallback
**Files:**
- Modify: `apps/hq/src/templates.ts`
- Modify: `apps/hq/src/api.ts` (broaden `companySettings()` to carry `template.*`)
- Test: `apps/hq/test/template-render.test.ts`
- Regression: `apps/hq/test/templates.test.ts`, `preview-route.test.ts`, `preview-fidelity.test.ts` stay green (they assert `company.*`/structural substrings, not `template.*`).
**Interfaces:**
- `documentHtml` renders the new optional `template.*` keys from its settings map, **each defaulting to today's output when the key is absent** (spec §4 — nothing breaks pre-config). A local `tpl(key) = company['template.' + key] ?? ''` helper mirrors the existing `get`:
- **Title:** `template.title_<DOCTYPE>` overrides `TITLE[doc.docType]` (default = the built-in map).
- **Logo:** `template.logo` (a `data:` URI) renders as `<img class="logo">` above the company name (default = none — just the `<h1>`).
- **Terms:** the Terms box now shows `doc.payload.terms ?? tpl('terms')` (a per-doc terms still wins; `template.terms` is the editable default; default = today's behaviour of showing nothing when both are empty).
- **Declaration / Jurisdiction / Footer note:** new blocks rendered only when the key is non-empty (default = absent, exactly today).
- **Signatory label:** replaces the hardcoded `Authorised Signatory` with `tpl('signatory_label')` when set (default = `Authorised Signatory`).
- This round touches **`documentHtml` only**; `receiptHtml` keeps today's letterhead (receipts aren't previewed — a documented small follow-up).
- `api.ts`: `companySettings()` broadens its query to `WHERE key LIKE 'company.%' OR key LIKE 'template.%'` so the one settings map documentHtml reads (in the pdf/send/preview/sample routes) now carries `template.*`. The name stays `companySettings` to avoid touching call sites; a comment notes it is the full letterhead settings map.
- [ ] **Step 1: Write the failing test**`apps/hq/test/template-render.test.ts`
```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 `documentHtml` in `templates.ts`.** Add the `tpl` helper beside `get`, and resolve the title/logo/terms:
```ts
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):
```css
.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:
```html
<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:
```html
<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):
```html
${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>`:
```html
<div class="sign">
<p>For <strong>${esc(get('name'))}</strong></p>
<p style="margin-top: 36px;">${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}</p>
</div>
${tpl('footer_note') !== '' ? `<div class="footer-note">${esc(tpl('footer_note'))}</div>` : ''}
```
(The `@media screen` paper style is added in Task 5, not here.)
- [ ] **Step 3b: Broaden `companySettings()` in `api.ts`** so every documentHtml route carries `template.*`:
```ts
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 assert `company.*`/structural substrings). `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/templates.ts apps/hq/src/api.ts apps/hq/test/template-render.test.ts
git commit -m "feat(hq): render editable template.* letterhead fields with default fallback" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 5: `templates.ts` — `@media screen` paper styles + `documentHtmlSample`; `POST /api/previews/sample`
**Files:**
- Modify: `apps/hq/src/templates.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/preview-sample.test.ts`
**Interfaces:**
- `templates.ts`:
- Adds a ~5-line `@media screen` block **inside** `documentHtml`'s `<style>` (mirrored into `receiptHtml`'s `<style>` for consistency): on screen puppeteer is not involved, so `@page` margins do not apply — mimic the 14mm print margin and a white page. `page.pdf` renders the **print** media, so these screen-only rules never touch the actual PDF. Existing `templates.test.ts` uses `.toContain` substring assertions, so the added lines cannot regress it; the fidelity test (Task 3) compares `documentHtml`↔`documentHtml`, so both sides gain the lines identically.
- `documentHtmlSample(company: Record<string, string>, opts?: { contentLines?: string[] }): string` — a canned `Doc` + `Client` (a TAX INVOICE so header + GSTIN + bank box + signatory all render) passed through the ONE `documentHtml`. Because the map it receives may carry `template.*`, the sample automatically showcases the Task-4 fields (logo/title/declaration/footer/…). The sample client's `stateCode` = the company's own state (intra-state → CGST/SGST). `opts.contentLines` overrides the single line's "what's included" bullets. Used by the Document Template page and the quote-content editor; there is no second renderer.
- `api.ts`:
- `POST /api/previews/sample` (`requireAuth`) — body `{ company?: Record<string, string>; template?: Record<string, string>; titles?: Record<string, string>; logo?: string; contentLines?: string[] }`, where `company`/`template` use the **same field keys as `PUT /settings/template`**. It merges the posted (unsaved) fields over the saved settings map (`companySettings()`, now company.* + template.*) and returns `{ ok, html }` = `documentHtmlSample(merged, { contentLines })`. So the letterhead preview shows precisely what Save would produce. **This task defines** the shared `COMPANY_FIELDS`/`TEMPLATE_FIELDS`/`DOC_TYPES` maps near the top of `apiRouter` (Task 6's `PUT /settings/template` consumes the same definitions).
- Imports added: `documentHtmlSample` (join the existing `documentHtml` import from `./templates`).
- [ ] **Step 1: Write the failing test**`apps/hq/test/preview-sample.test.ts`
```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 screen` absent).
- [ ] **Step 3a: Implement `templates.ts`.** Add `BillTotals` to the domain import:
```ts
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:
```css
/* 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:
```ts
/**
* Canned sample doc for previewing letterhead + quote-content edits (design §4).
* Reuses documentHtml — no second renderer — so what the owner sees is exactly
* how a real bill prints. A TAX INVOICE so the header, GSTIN and bank box all
* render; the state code follows the company so the split is intra-state.
*/
export function documentHtmlSample(company: Record<string, string>, opts: { contentLines?: string[] } = {}): string {
const stateCode = company['company.state_code'] ?? '32'
const client: Client = {
id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5',
stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '',
}
const line: BillLine = {
itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS',
unitPricePaise: 10_000_00, grossPaise: 10_000_00, discountPaise: 0, taxablePaise: 10_000_00,
taxRateBp: 1800, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, cessPaise: 0, lineTotalPaise: 11_800_00,
}
const totals: BillTotals = {
grossPaise: 10_000_00, discountPaise: 0, taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00,
igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00, savingsVsMrpPaise: 0,
}
const doc: Doc = {
id: 'sample', docType: 'INVOICE', docNo: null, fy: '2026-27', clientId: 'sample',
docDate: new Date().toISOString().slice(0, 10), status: 'draft', refDocId: null,
taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0,
roundOffPaise: 0, payablePaise: 11_800_00,
payload: {
lines: [line], totals,
lineContents: [opts.contentLines ?? ['Cloud POS billing', 'GST invoicing', 'Priority support']],
},
source: 'sample', createdBy: 'system', createdAt: '',
}
return documentHtml(doc, client, company)
}
```
- [ ] **Step 3b: Implement the route in `api.ts`.** Add `documentHtmlSample` to the templates import:
```ts
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:
```ts
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):
```ts
// 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**
```bash
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 }` where `settings` is the full letterhead map (`company.*` + `template.*`) from `companySettings()`.
- `PUT /api/settings/template` (`requireAuth`, `requireOwner`) — body `{ company?, template?, titles? }` using the field keys of `COMPANY_FIELDS`/`TEMPLATE_FIELDS` + a `titles` object keyed by DocType. Validates GSTIN + two-digit state code exactly like the existing `PUT /settings/company`; writes each provided field via `setSetting` (owner-gated, **each field audited**); returns the refreshed `{ ok, settings }`. The existing `GET/PUT /settings/company` routes stay (backward-compatible; `settings-company.test.ts` keeps passing) — the new route is the superset the Document Template page uses.
- `POST /api/settings/template/logo` (`requireAuth`, `requireOwner`) — body `{ dataUri: string }`. Validates it is a `data:image/(png|jpeg|jpg|gif|webp|svg+xml);base64,…` URI and that the decoded payload is ≤ **200 KB** (data URIs bloat every PDF row); stores via `setSetting('template.logo', dataUri)` (audited). `dataUri: ''` clears the logo. Returns `{ ok, logo }`.
- Consumes `COMPANY_FIELDS`/`TEMPLATE_FIELDS`/`DOC_TYPES` (defined in Task 5); `setSetting` (already imported), `validateGstin` (already imported).
- [ ] **Step 1: Write the failing test**`apps/hq/test/settings-template.test.ts`
```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 existing `PUT /settings/company`), reusing the Task-5 field maps:
```ts
// ---------- 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**
```bash
git add apps/hq/src/api.ts apps/hq/src/server.ts apps/hq/test/settings-template.test.ts
git commit -m "feat(hq): GET/PUT /settings/template + logo upload (owner-gated, audited)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 7: `GET /api/reminders/:id/preview` — the exact outgoing email (extract `reminderContext`)
**Files:**
- Modify: `apps/hq/src/send-reminder.ts` (extract the ctx-builder; `sendReminder` calls it — behaviour unchanged)
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/reminder-preview.test.ts`
- Regression: `apps/hq/test/send-reminder.test.ts` must stay green.
**Interfaces:**
- `send-reminder.ts` produces `reminderContext(db, reminder, companyName): { ctx: ReminderContext; doc: Doc | null; client: Client }` — the per-rule context the send path builds, resolved once (client + optional doc). `sendReminder` consumes it (single `getClient`, same ctx), so the previewed subject/body equal the sent ones by construction. The internal-kind guard (`follow_up`/`email_bounced` → throw) stays at the top of `sendReminder`, before `reminderContext`.
- `api.ts`: `GET /api/reminders/:id/preview` (`requireAuth`) → `{ ok, subject, body }` from `reminderEmail(reminder.ruleKind, ctx)`. 404 for an unknown reminder; 400 for an internal kind (mirrors `sendReminder`). Imports added: `reminderContext` (from `./send-reminder`), `reminderEmail` (from `./reminder-templates`).
- [ ] **Step 1: Write the failing test**`apps/hq/test/reminder-preview.test.ts`
```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 `reminderContext` in `send-reminder.ts`.** Add the exported helper and rewrite the body of `sendReminder` to consume it (no behaviour change — client resolved once, same ctx, same guard order):
```ts
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):
```ts
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:
```ts
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`:
```ts
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**
```bash
git add apps/hq/src/send-reminder.ts apps/hq/src/api.ts apps/hq/test/reminder-preview.test.ts
git commit -m "feat(hq): GET /reminders/:id/preview — exact email via shared reminderContext" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 8: `LivePreview.tsx` — debounced/immediate fetch + double-buffer `srcdoc` iframe
> Frontend task — no vitest; ends with a **browser-verification** step. Build the reusable mechanism and wire a minimal mount into `NewDocument`; Task 9 completes the composer layout.
**Files:**
- Modify: `apps/hq-web/src/api.ts` (preview fetchers + types)
- Create: `apps/hq-web/src/components/LivePreview.tsx`
- Modify: `apps/hq-web/src/pages/NewDocument.tsx` (minimal right-pane mount, replaced in Task 9)
**Interfaces:**
- `api.ts`:
- `DocTotals = { taxablePaise; cgstPaise; sgstPaise; igstPaise; roundOffPaise; payablePaise: number }` (the subset the strip renders — extra server fields ignored).
- `DocPreview = { html: string; totals: DocTotals; warnings: string[] }`.
- `postPreview<T>(path, body, signal?)` — a dedicated fetcher mirroring `fetchPdfBlob` (carries the bearer token, passes the `AbortSignal`, and lets an abort reject as `AbortError` rather than the generic `apiFetch` "unreachable" so `LivePreview` can ignore superseded calls).
- `previewDocument(body, signal?) → Promise<DocPreview>`; `previewSample(body, signal?) → Promise<{ html: string }>`; `getReminderPreview(id) → Promise<{ subject; body: string }>`.
- `LivePreview.tsx`:
- Props `{ body: string; commitNonce: number; fetchPreview: (body, signal) => Promise<PreviewFetchResult>; onResult?; onStatus? }`. `body` = serialized request identity (skip the POST when unchanged). `commitNonce` bumps to force an **immediate** (0ms) refire; a `body`-only change **debounces 400ms**. One in-flight max (`AbortController` + a sequence guard drops stale responses). Never blanks: two stacked sandboxed iframes; the next render writes into the hidden one, restores `scrollTop` on load, then cross-fades (120ms). A 2px shimmer shows while in-flight; a slim "Preview paused — retrying" banner shows on error while the last good paper stays.
- `PreviewFetchResult = { html: string; totals?: DocTotals; warnings?: string[] }`.
- Deliberately modest — a composer helper reused by the composer + the two sample surfaces, not a general widget (YAGNI).
- [ ] **Step 1: Add the preview fetchers to `apps/hq-web/src/api.ts`** (after `fetchPdfBlob`):
```ts
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`**
```tsx
import { type CSSProperties, useEffect, useRef, useState } from 'react'
import type { DocTotals } from '../api'
export interface PreviewFetchResult { html: string; totals?: DocTotals; warnings?: string[] }
interface LivePreviewProps {
/** Serialized request body — identity for skip-if-unchanged. */
body: string
/** Bump to force an immediate (0ms) refire; body-only changes debounce 400ms. */
commitNonce: number
/** Performs the POST; must honour the AbortSignal. */
fetchPreview: (body: string, signal: AbortSignal) => Promise<PreviewFetchResult>
/** After each successful fetch — totals/warnings for the parent's strip. */
onResult?: (r: PreviewFetchResult) => void
/** In-flight/error changes — parent dims totals + shows the paused state. */
onStatus?: (s: { busy: boolean; error: string | null }) => void
}
const DEBOUNCE_MS = 400
/**
* Live preview pane (composer + company + quote-content). Debounced/immediate
* POST → double-buffered iframe of the server's real documentHtml, so the paper
* on screen is the same HTML puppeteer rasterizes for the PDF. Never blanks: the
* previous paper stays under a 2px shimmer, the next render is written into a
* hidden stacked iframe, then cross-faded in with scrollTop kept. Modest by
* design — a composer helper, not a general widget (YAGNI).
*/
export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatus }: LivePreviewProps) {
const aRef = useRef<HTMLIFrameElement>(null)
const bRef = useRef<HTMLIFrameElement>(null)
const [aShown, setAShown] = useState(true) // which buffer is visible
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const seq = useRef(0) // sequence guard: only the latest response wins
const lastBody = useRef<string | null>(null) // skip-if-unchanged
const prevCommit = useRef(commitNonce)
const shownRef = useRef(aShown)
shownRef.current = aShown
useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus])
useEffect(() => {
const immediate = commitNonce !== prevCommit.current
prevCommit.current = commitNonce
if (body === lastBody.current) return // identical body — no POST (design §3)
const controller = new AbortController()
const mySeq = ++seq.current
const timer = setTimeout(() => {
setBusy(true)
fetchPreview(body, controller.signal)
.then((r) => {
if (mySeq !== seq.current) return // superseded
lastBody.current = body
setError(null)
swapIn(r.html)
onResult?.(r)
})
.catch((e: unknown) => {
if (controller.signal.aborted || mySeq !== seq.current) return
setError(e instanceof Error ? e.message : String(e)) // keep the last good paper
})
.finally(() => { if (mySeq === seq.current) setBusy(false) })
}, immediate ? 0 : DEBOUNCE_MS)
return () => { controller.abort(); clearTimeout(timer) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [body, commitNonce])
/** Write html into the hidden buffer; on load restore scroll then cross-fade. */
function swapIn(html: string): void {
const a = aRef.current, b = bRef.current
if (a === null || b === null) return
const visible = shownRef.current ? a : b
const hidden = shownRef.current ? b : a
const keepScroll = visible.contentWindow?.scrollY ?? 0
const onLoad = (): void => {
hidden.removeEventListener('load', onLoad)
try { hidden.contentWindow?.scrollTo(0, keepScroll) } catch { /* srcdoc is same-origin — never throws */ }
setAShown((s) => !s) // opacity transition = the 120ms cross-fade
}
hidden.addEventListener('load', onLoad)
hidden.srcdoc = html
}
const frame = (shown: boolean): CSSProperties => ({
position: 'absolute', inset: 0, width: '100%', height: '100%', border: 'none',
background: '#fff', opacity: shown ? 1 : 0, transition: 'opacity 120ms ease',
})
return (
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
{busy && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--accent, #4b8bf5)', opacity: 0.85, zIndex: 3 }} />}
{error !== null && (
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 3, background: 'var(--bg-raised, #fff)', border: '1px solid var(--border, #ccc)', borderRadius: 6, padding: '2px 8px', fontSize: 12 }}>
Preview paused retrying
</div>
)}
{/* sandbox="" strips scripts — the template is pure HTML/CSS. */}
<iframe ref={aRef} title="preview-a" sandbox="" style={frame(aShown)} />
<iframe ref={bRef} title="preview-b" sandbox="" style={frame(!aShown)} />
</div>
)
}
```
- [ ] **Step 3: Minimal mount in `NewDocument.tsx`** (temporary — Task 9 replaces the whole layout). Wrap the existing `return (...)` body in a two-column flex, keeping the current form on the left and adding the preview on the right, fed by the existing `lines`/`client`/`docType` state:
```tsx
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):
```tsx
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>
{/* …the existing composer form JSX, unchanged… */}
</div>
<div style={{ flex: 1, minWidth: 0, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, height: 'calc(100vh - 140px)', position: 'sticky', top: 12, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
/>
</div>
</div>
</div>
)
```
Bump `commitNonce` on the client pick handler (`onClick={() => { setClient(c); setHits([]); setCommitNonce((n) => n + 1) }}`) so the first render is immediate. (Task 9 wires the remaining commit events + totals.)
- [ ] **Step 4: Browser-verify.** Start the API: `npm run start -w @sims/hq` (port 5182; note the printed owner password on first boot, or reuse an existing DB). In a second shell start the web dev server: `npm run dev -w @sims/hq-web` (port 5183). Open `http://localhost:5183`, log in as owner, go to **New Document**. Confirm:
- Pick a client, add a module + kind → the A4 paper renders the real letterhead within ~400ms; typing in Qty/Unit updates it after a short debounce; picking the client/kind updates it immediately.
- Editing rapidly never blanks the pane (the previous paper stays; a 2px bar flickers at the top; the new paper cross-fades in) and does not jump scroll.
- Kill the API server, edit a field → the "Preview paused — retrying" banner appears and the last paper stays; restart the API and edit again → it recovers.
- Browser devtools Network shows at most one in-flight `/api/documents/preview` at a time (superseded ones show as canceled).
- [ ] **Step 5: Typecheck + build + commit**`npm run typecheck` clean; `npm run build -w @sims/hq-web` clean.
```bash
git add apps/hq-web/src/api.ts apps/hq-web/src/components/LivePreview.tsx apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): LivePreview — debounced double-buffer iframe + preview fetchers" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 9: `NewDocument.tsx` — split layout, `buildDraftBody`, server-truth totals strip (reducer deleted), responsive
> Frontend task — browser-verified. This replaces the Task-8 minimal mount with the real composer.
**Files:**
- Modify: `apps/hq-web/src/pages/NewDocument.tsx`
**Interfaces:**
- `buildDraftBody()` — the ONE body builder; both `save()` and the live preview send **byte-identical** bodies (`JSON.stringify(buildDraftBody())`).
- The client-side subtotal reducer (`subtotalPaise`, old line 91) and its `Subtotal … + GST` badge are **DELETED**. The sticky totals strip renders `response.totals` (Taxable / CGST+SGST *or* IGST / Round-off / Payable) via `formatINR`, dimmed to ~60% while a preview is in flight, greyed on error, with the IGST split appearing live for out-of-state clients. A "split pending client" tag shows when no client is picked; a changed cell pulses briefly.
- Commit-shaped events (client pick/change, doctype radio, add/remove line, module/kind/pack select, field blur) bump `commitNonce` → immediate refire; keystrokes (qty/unit/description/content/terms) → 400ms debounce.
- Responsive: ≥800px = form ~600px left (own scroll) + A4 paper card filling the rest (sticky). <800px = single-column form + a pinned bottom bar (always shows Payable + GST) with a **Preview** button that opens the paper as a full-screen sheet.
- [ ] **Step 1: Replace `apps/hq-web/src/pages/NewDocument.tsx`** with the full split-view composer:
```tsx
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
createDocument, getClients, getModules, getPrices, previewDocument,
KIND_LABEL, type Client, type DocTotals, type Kind, type ModulePrice,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'
const today = () => new Date().toISOString().slice(0, 10)
interface LineDraft {
moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string
edition: string; contentText: string
}
const EMPTY_LINE: LineDraft = {
moduleId: '', kind: '', qty: '1', unitRs: '', description: '', edition: 'standard', contentText: '',
}
const editionsOf = (prices: ModulePrice[]): string[] => [...new Set(prices.map((p) => p.edition))]
const splitContent = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '')
function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): number | null {
const hit = prices
.filter((p) => p.kind === kind && p.edition === edition && p.effectiveFrom <= today())
.sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
return hit !== undefined ? hit.pricePaise : null
}
function editionPrice(prices: ModulePrice[], edition: string, kind: Kind | ''): number | null {
if (kind !== '') return latestPrice(prices, kind, edition)
const hit = prices
.filter((p) => p.edition === edition && p.effectiveFrom <= today())
.sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
return hit !== undefined ? hit.pricePaise : null
}
/** matchMedia hook — the composer collapses to one column on the owner's phone. */
function useNarrow(): boolean {
const [narrow, setNarrow] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 800px)').matches)
useEffect(() => {
const mq = window.matchMedia('(max-width: 800px)')
const on = () => setNarrow(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return narrow
}
/** Value that flashes a highlight for 250ms when it changes (cause→effect visible). */
function Pulse({ children, on }: { children: React.ReactNode; on: string | number }) {
const [pulse, setPulse] = useState(false)
useEffect(() => { setPulse(true); const t = setTimeout(() => setPulse(false), 250); return () => clearTimeout(t) }, [on])
return <span style={{ transition: 'background 250ms ease', background: pulse ? 'var(--accent-soft, #dce8ff)' : 'transparent', borderRadius: 4, padding: '0 3px' }}>{children}</span>
}
/** The quotation-in-minutes composer: client → lines → live preview → Save Draft. */
export function NewDocument() {
const nav = useNavigate()
const narrow = useNarrow()
const modules = useData(getModules, [])
const [docType, setDocType] = useState<'QUOTATION' | 'PROFORMA' | 'INVOICE'>('QUOTATION')
const [lines, setLines] = useState<LineDraft[]>([{ ...EMPTY_LINE }])
const [terms, setTerms] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
// -- live preview wiring: server totals + warnings replace all client money math --
const [commitNonce, setCommitNonce] = useState(0)
const commit = () => setCommitNonce((n) => n + 1)
const [totals, setTotals] = useState<DocTotals | undefined>()
const [warnings, setWarnings] = useState<string[]>([])
const [previewBusy, setPreviewBusy] = useState(false)
const [previewErr, setPreviewErr] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false) // narrow: full-screen paper sheet
// -- client type-ahead (debounced) --
const [q, setQ] = useState('')
const [hits, setHits] = useState<Client[]>([])
const [client, setClient] = useState<Client | undefined>()
useEffect(() => {
if (client !== undefined || q.trim() === '') { setHits([]); return }
const t = setTimeout(() => { getClients(q).then(setHits).catch(() => setHits([])) }, 250)
return () => clearTimeout(t)
}, [q, client])
const [pricesByModule, setPricesByModule] = useState<Record<string, ModulePrice[]>>({})
const loadPrices = (moduleId: string): Promise<ModulePrice[]> => {
const cached = pricesByModule[moduleId]
if (cached !== undefined) return Promise.resolve(cached)
return getPrices(moduleId).then((prices) => { setPricesByModule((prev) => ({ ...prev, [moduleId]: prices })); return prices })
}
const prefill = (index: number, moduleId: string, kind: Kind, edition: string) => {
loadPrices(moduleId).then((prices) => {
const paise = latestPrice(prices, kind, edition)
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l)))
}).catch(() => { /* no prefill — the unit price stays editable */ })
}
const setLine = (index: number, patch: Partial<LineDraft>) =>
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)))
/** The ONE body builder — save() and the live preview post byte-identical bodies. */
const buildDraftBody = () => ({
docType,
clientId: client?.id ?? '',
lines: lines.filter((l) => l.moduleId !== '' && l.kind !== '').map((l) => ({
moduleId: l.moduleId, kind: l.kind as Kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
const previewBody = useMemo(() => JSON.stringify(buildDraftBody()), [docType, client, lines, terms])
const save = () => {
if (client === undefined) { setError('Pick a client first'); return }
const ready = lines.filter((l) => l.moduleId !== '' && l.kind !== '')
if (ready.length === 0) { setError('Add at least one line (module + kind)'); return }
for (const l of ready) {
if (!Number.isFinite(Number(l.qty)) || Number(l.qty) <= 0) { setError('Every line needs a positive quantity'); return }
if (l.unitRs !== '' && !Number.isFinite(Number(l.unitRs))) { setError('Unit price must be a rupee amount'); return }
}
setError(undefined); setSaving(true)
createDocument(buildDraftBody())
.then((d) => nav(`/documents/${d.id}`))
.catch((e: Error) => { setError(e.message); setSaving(false) })
}
const pendingClient = client === undefined
const stripDim = previewBusy || previewErr !== null
const totalsStrip = (
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'baseline', padding: '8px 12px', borderTop: '1px solid var(--border)', background: 'var(--bg-raised)', opacity: stripDim ? 0.6 : 1, transition: 'opacity 120ms ease' }}>
<Cell label="Taxable" value={totals ? formatINR(totals.taxablePaise) : '—'} />
{totals && totals.igstPaise > 0
? <Cell label="IGST" value={formatINR(totals.igstPaise)} />
: <><Cell label="CGST" value={totals ? formatINR(totals.cgstPaise) : '—'} /><Cell label="SGST" value={totals ? formatINR(totals.sgstPaise) : '—'} /></>}
{totals && totals.roundOffPaise !== 0 && <Cell label="Round-off" value={formatINR(totals.roundOffPaise)} />}
<Cell label="Payable" value={totals ? formatINR(totals.payablePaise) : '—'} strong />
{pendingClient && <Badge tone="warn">split pending client</Badge>}
{warnings.length > 0 && <Badge tone="warn">{warnings.length} price gap(s)</Badge>}
</div>
)
const paper = (
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewDocument(JSON.parse(b), signal)}
onResult={(r) => { setTotals(r.totals); setWarnings(r.warnings ?? []) }}
onStatus={(s) => { setPreviewBusy(s.busy); setPreviewErr(s.error) }}
/>
)
const form = (
<div className="wf-page" style={{ overflow: 'auto', height: narrow ? 'auto' : 'calc(100vh - 200px)', paddingBottom: narrow ? 72 : 0 }}>
<PageHeader title="New Document" desc="Compose a quotation, proforma or invoice. GST is computed and previewed live by the server." />
<Field label="Client">
{client !== undefined ? (
<Toolbar>
<Badge tone="accent">{client.code} · {client.name}</Badge>
<Button onClick={() => { setClient(undefined); setQ(''); commit() }}>Change</Button>
</Toolbar>
) : (
<div style={{ position: 'relative', maxWidth: 340 }}>
<input className="wf" placeholder="Type to search clients…" value={q} autoFocus onChange={(e) => setQ(e.target.value)} />
{hits.length > 0 && (
<div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 8 }}>
{hits.slice(0, 8).map((c) => (
<button key={c.id} type="button" className="wf" style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
onClick={() => { setClient(c); setHits([]); commit() }}>
{c.code} · {c.name}
</button>
))}
</div>
)}
</div>
)}
</Field>
<Field label="Document type">
<Toolbar>
{(['QUOTATION', 'PROFORMA', 'INVOICE'] as const).map((t) => (
<label key={t} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="radio" name="doctype" checked={docType === t} onChange={() => { setDocType(t); commit() }} />
{t === 'QUOTATION' ? 'Quotation' : t === 'PROFORMA' ? 'Proforma' : 'Invoice'}
</label>
))}
</Toolbar>
</Field>
<h3>Lines</h3>
{lines.map((l, i) => {
const mod = modules.data?.find((m) => m.id === l.moduleId)
const prices = pricesByModule[l.moduleId] ?? []
const editions = editionsOf(prices)
const priceGap = warnings.some((w) => mod !== undefined && w.includes(mod.code))
return (
<div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Module">
<select className="wf" value={l.moduleId}
onChange={(e) => {
const moduleId = e.target.value
const picked = modules.data?.find((m) => m.id === moduleId)
setLine(i, { moduleId, kind: '', unitRs: '', edition: 'standard', contentText: (picked?.quoteContent ?? []).join('\n') })
if (moduleId !== '') {
loadPrices(moduleId).then((ps) => {
const eds = editionsOf(ps); const def = eds.includes('standard') ? 'standard' : (eds[0] ?? 'standard')
if (def !== 'standard') setLine(i, { edition: def })
}).catch(() => { /* pricing stays manual */ })
}
commit()
}}>
<option value="">Module</option>
{(modules.data ?? []).filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</Field>
<Field label="Kind">
<select className="wf" value={l.kind}
onChange={(e) => { const kind = e.target.value as Kind; setLine(i, { kind }); if (l.moduleId !== '') prefill(i, l.moduleId, kind, l.edition); commit() }}>
<option value="">Kind</option>
{(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
</Field>
{editions.length > 1 && (
<Field label="Pack">
<select className="wf" value={l.edition}
onChange={(e) => { const edition = e.target.value; setLine(i, { edition }); if (l.moduleId !== '' && l.kind !== '') prefill(i, l.moduleId, l.kind, edition); commit() }}>
{editions.map((ed) => { const p = editionPrice(prices, ed, l.kind); return <option key={ed} value={ed}>{ed}{p !== null ? ` — ${formatINR(p)}` : ''}</option> })}
</select>
</Field>
)}
<Field label="Qty">
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} onBlur={commit} />
</Field>
<Field label="Unit ₹ (ex-GST)">
<input className="wf num" style={{ width: 120, ...(priceGap ? { borderColor: 'var(--warn, #d08700)' } : {}) }}
placeholder={priceGap ? 'No price — enter Unit ₹' : ''} value={l.unitRs}
onChange={(e) => setLine(i, { unitRs: e.target.value })} onBlur={commit} />
</Field>
<Field label="Description (optional)">
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} onBlur={commit} />
</Field>
<Field label="What's included (one per line)">
<textarea className="wf" rows={2} style={{ minWidth: 220 }} value={l.contentText} onChange={(e) => setLine(i, { contentText: e.target.value })} onBlur={commit} />
</Field>
{lines.length > 1 && (
<Button tone="danger" onClick={() => { setLines((prev) => prev.filter((_x, j) => j !== i)); commit() }}>Remove</Button>
)}
</div>
)
})}
<Toolbar>
<Button onClick={() => { setLines((prev) => [...prev, { ...EMPTY_LINE }]); commit() }}>Add line</Button>
</Toolbar>
<Field label="Terms (optional)">
<textarea className="wf" rows={2} style={{ maxWidth: 560 }} value={terms} onChange={(e) => setTerms(e.target.value)} onBlur={commit} />
</Field>
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
{error !== undefined && <Notice tone="err">{error}</Notice>}
<div style={{ marginTop: 10 }}>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save Draft'}</Button>
</div>
</div>
)
if (narrow) {
return (
<>
{form}
{/* pinned bar never disappears — always shows Payable + GST + Preview */}
<div style={{ position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 20, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '8px 12px', background: 'var(--bg-raised)', borderTop: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: 12, opacity: stripDim ? 0.6 : 1 }}>
<Cell label="GST" value={totals ? formatINR((totals.cgstPaise + totals.sgstPaise) || totals.igstPaise) : '—'} />
<Cell label="Payable" value={totals ? formatINR(totals.payablePaise) : '—'} strong />
</div>
<Button tone="primary" onClick={() => setSheetOpen(true)}>Preview</Button>
</div>
{sheetOpen && (
<div style={{ position: 'fixed', inset: 0, zIndex: 30, background: 'var(--bg-sunken, #e9e9ee)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 8 }}><Button onClick={() => setSheetOpen(false)}>Close</Button></div>
<div style={{ flex: 1, margin: 12, marginTop: 0, background: '#fff', borderRadius: 4, overflow: 'hidden' }}>{paper}</div>
{totalsStrip}
</div>
)}
</>
)
}
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>{form}</div>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ flex: 1, minHeight: 0, background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>{paper}</div>
{totalsStrip}
</div>
</div>
)
}
function Cell({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.2 }}>
<span style={{ fontSize: 10, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>{label}</span>
<Pulse on={value}><span style={{ fontSize: strong ? 15 : 13, fontWeight: strong ? 700 : 500 }}>{value}</span></Pulse>
</span>
)
}
```
Notes for the implementer: the `React.ReactNode` type in `Pulse` needs `import type React from 'react'` **or** switch the annotation to `ReactNode` imported from `react` match whichever the repo's other files use (`Dashboard.tsx` imports `type ReactNode` from `react`; follow that). The old `subtotalPaise` reducer and its badge are gone grep the file to confirm no `subtotalPaise` remains.
- [ ] **Step 2: Browser-verify** (API on 5182, `npm run dev -w @sims/hq-web` on 5183; open `http://localhost:5183` New Document):
- **Server-truth totals:** pick an in-state client, add a priced line the strip shows Taxable / CGST / SGST / Payable from the server; there is no client-side subtotal anywhere. Switch to an **out-of-state** client the strip flips to IGST live and the paper's totals block matches.
- **Split layout:** form is ~600px on the left with its own scroll; the A4 paper fills the rest and stays sticky under the header.
- **Permissive:** add a module with no price the Unit cell flags amber ("No price enter Unit ₹"), the strip shows "price gap(s)", the paper still renders (that line at 0); typing a Unit clears the flag on blur.
- **No client:** before picking a client the paper shows "— pick a client —", the strip shows "split pending client" with an 18% total; picking a client clears the tag with a pulse.
- **Pulse:** changing qty pulses the changed totals cells briefly.
- **Narrow:** shrink the window below 800px single column; the pinned bottom bar always shows Payable + GST; **Preview** opens a full-screen paper sheet with Close.
- **Save still strict:** with a genuine price gap, click Save Draft the server 400 surfaces as an error and nothing persists; fill the price and Save navigates to the saved draft. Confirm the saved document's totals equal what the strip showed.
- [ ] **Step 3: Typecheck + build + commit** `npm run typecheck` clean; `npm run build -w @sims/hq-web` clean.
```bash
git add apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): composer split view — server-truth totals strip, live paper, responsive" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 10: Document Template page — company block + `template.*` + logo upload + live letterhead preview
> Frontend task — browser-verified. This **absorbs** the company-profile letterhead surface: `CompanyProfile.tsx` is removed and its fields move here, into one owner-gated page.
**Files:**
- Create: `apps/hq-web/src/pages/DocumentTemplate.tsx`
- Modify: `apps/hq-web/src/api.ts` (template-settings + logo calls)
- Modify: `apps/hq-web/src/main.tsx` (route), `apps/hq-web/src/Layout.tsx` (owner nav item)
- Remove: `apps/hq-web/src/pages/CompanyProfile.tsx` (+ its `/settings/company` route and "Company" nav item)
**Interfaces:** reuses `LivePreview` + `previewSample`. The preview posts the **unsaved** company + `template.*` + per-type title edits (`{ company, template, titles }`) so the owner sees exactly how every document will print. The logo is saved on pick (`POST /settings/template/logo`, immediate) and the preview reads it back from the saved settings keeping large data URIs out of every keystroke's request body. Text fields debounce; blur bumps `commitNonce`.
- [ ] **Step 1: Add the api calls to `apps/hq-web/src/api.ts`** (after `getCompanyProfile`/`putCompanyProfile`, which stay for backward compatibility):
```ts
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`**
```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:
```tsx
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:
```tsx
const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/template', label: 'Document Template' }] : BASE_NAV
```
Delete `apps/hq-web/src/pages/CompanyProfile.tsx`. The server `GET/PUT /settings/company` routes and the client `getCompanyProfile`/`putCompanyProfile` exports stay (unused, backward-compatible; `settings-company.test.ts` keeps them green).
- [ ] **Step 4: Browser-verify** (owner login **Document Template**):
- The right pane shows a sample TAX INVOICE letterhead. Edit **Company name** header updates on blur; type in **Bank details** bank box updates after the debounce; the paper never blanks.
- Set **GST declaration**, **Footer note**, **Jurisdiction line**, **Signatory label** each appears on the sample where it prints; clearing a field removes it (default). Set **Invoice title** = "GST INVOICE" the doc title changes.
- Upload a small PNG logo it appears in the letterhead header within a moment (saved immediately); **Remove** clears it. Try a >200 KB image → an error notice, no change.
- Click **Save template**, reload → all fields persist and the preview matches. Open **New Document**, add a line → the composer's live paper now shows the same footer/declaration/logo (composer reads the saved `template.*` too).
- As **staff** (role=staff), `/settings/template` redirects to `/` and the nav item is absent.
- [ ] **Step 5: Typecheck + build + commit**`npm run typecheck` + `npm run build -w @sims/hq-web` clean.
```bash
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:
```tsx
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:
```tsx
const [commitNonce, setCommitNonce] = useState(0)
const previewBody = useMemo(() => JSON.stringify({ contentLines: splitLines(text) }), [text])
// ... existing `save` unchanged ...
return (
<>
<h3 style={{ marginTop: 20 }}>Quote content {props.module.name}</h3>
{props.isOwner ? (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '0 0 560px', minWidth: 0 }}>
<textarea className="wf" rows={4} placeholder="One included item per line…"
value={text} onChange={(e) => { setText(e.target.value); setSaved(false) }} onBlur={() => setCommitNonce((n) => n + 1)} />
<Toolbar>
<Button tone="primary" onClick={save}>Save quote content</Button>
{saved && <Badge tone="ok">saved</Badge>}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
<div style={{ flex: 1, minWidth: 320, height: 420, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewSample(JSON.parse(b), signal).then((r) => ({ html: r.html }))}
/>
</div>
</div>
</div>
) : props.module.quoteContent.length === 0 ? (
<EmptyState>No quote content.</EmptyState>
) : (
<ul>{props.module.quoteContent.map((line, i) => <li key={i}>{line}</li>)}</ul>
)}
</>
)
```
`splitLines` is the existing helper at the top of `Modules.tsx`; leave it as-is.
- [ ] **Step 2: Browser-verify** (owner login → **Modules** → click a module):
- The "Quote content" editor shows a sample invoice on the right with the current bullets rendered as the grey `ul.line-content` under the line.
- Add/remove a bullet line in the textarea → after blur the printed bullets update to match; the paper never blanks.
- Staff login (or `localStorage['hq.role']='staff'`) → the preview is absent and the read-only list renders as before.
- [ ] **Step 3: Typecheck + build + commit**`npm run typecheck` + `npm run build -w @sims/hq-web` clean.
```bash
git add apps/hq-web/src/pages/Modules.tsx
git commit -m "feat(hq-web): module quote-content live print preview" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 12: Reminder email preview in the manual queue
> Frontend task — browser-verified.
**Files:**
- Modify: `apps/hq-web/src/pages/Dashboard.tsx`
**Interfaces:** the reminder queue's `QueueActions` gains a **Preview** button (sendable kinds only) that fetches `getReminderPreview(id)` → the exact outgoing subject + body (from the pure `reminderEmail`) and shows it inline before Send — staff currently fire client-facing dunning mail unseen. This is text, not an iframe, so it does not use `LivePreview`.
- [ ] **Step 1: Edit `apps/hq-web/src/pages/Dashboard.tsx`.** Add `getReminderPreview` to the api import:
```tsx
import { getDashboard, sendReminder, dismissReminder, getReminderPreview, type Reminder } from '../api'
```
Replace `QueueActions` with a version that previews before sending:
```tsx
function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone: () => void; onError: (m: string) => void }) {
const [busy, setBusy] = useState(false)
const [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
const [loading, setLoading] = useState(false)
const run = (p: Promise<unknown>) => {
setBusy(true); props.onError('')
p.catch((e: Error) => props.onError(e.message))
// Reload even on failure: the server has already parked the reminder as
// 'failed' with its error, and the row must flip live.
.then(props.onDone)
.finally(() => setBusy(false))
}
const preview = () => {
if (mail !== undefined) { setMail(undefined); return } // toggle closed
setLoading(true); props.onError('')
getReminderPreview(props.rem.id)
.then(setMail)
.catch((e: Error) => props.onError(e.message))
.finally(() => setLoading(false))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6 }}>
{SENDABLE.has(props.rem.ruleKind) && (
<>
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
<Button tone="primary" onClick={() => run(sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
</>
)}
<Button onClick={() => run(dismissReminder(props.rem.id))}>Dismiss</Button>
</div>
{mail !== undefined && (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mail.subject}</div>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Body</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'inherit', fontSize: 13 }}>{mail.body}</pre>
</div>
)}
</div>
)
}
```
- [ ] **Step 2: Browser-verify** (owner login → **Dashboard**, with at least one sendable reminder in the queue — an overdue invoice reminder is easiest to seed):
- Click **Preview** on an overdue reminder → the exact subject (e.g. `Payment reminder — Invoice INV/… (Tecnostac)`) and body appear inline, matching what Send would email; **Hide** collapses it.
- Internal-only rows (follow-up / bounced) show no Preview/Send button (only Dismiss), unchanged.
- [ ] **Step 3: Typecheck + build + commit**`npm run typecheck` + `npm run build -w @sims/hq-web` clean.
```bash
git add apps/hq-web/src/pages/Dashboard.tsx
git commit -m "feat(hq-web): reminder email preview in the manual queue" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 13: Final whole-feature browser walk
> Frontend verification only — no code, no commit unless a fix is needed (fix under the relevant task's commit style if so).
- [ ] **Step 1:** Ensure a clean tree and green suite: `npm test` (all vitest green — server Tasks 17), `npm run typecheck` clean, `npm run build -w @sims/hq-web` clean.
- [ ] **Step 2:** Start the API (`npm run start -w @sims/hq`, port 5182) and the web dev server (`npm run dev -w @sims/hq-web`, port 5183); log in as owner at `http://localhost:5183`.
- [ ] **Step 3: Composer walk.** New Document → pick an in-state client, add two priced lines → watch the letterhead build in <~400ms; the totals strip shows CGST+SGST from the server (no client math anywhere). Switch to an out-of-state client → IGST appears live and the paper matches. Introduce a price gap → amber flag + "price gap(s)"; the paper keeps rendering. Save Draft with the gap → strict 400, nothing persists; fill the price → Save navigates to the draft whose stored totals equal the strip.
- [ ] **Step 4: No-blank + resilience.** Type quickly across fields → no white flash, no scroll jump, one in-flight request at a time. Stop the API mid-edit → "Preview paused — retrying", last paper stays, totals grey; restart → recovers.
- [ ] **Step 5: Narrow.** Below 800px → single column, pinned Payable+GST bar always visible, **Preview** opens the full-screen paper sheet.
- [ ] **Step 6: Editable template data.** Document Template → set declaration / footer / jurisdiction / signatory / an invoice-title override, upload a logo → the sample letterhead reflects each live; Save, then open New Document and confirm the composer paper now carries the same footer/declaration/logo (both read the saved `template.*`). Confirm `/settings/template` is owner-only.
- [ ] **Step 7: Other siblings.** Modules → quote-content bullets render live under a sample line. Dashboard → reminder **Preview** shows the exact outgoing email.
- [ ] **Step 8: Fidelity spot-check.** Save a draft, open the Document page (`DocumentView` PDF), and confirm the printed PDF matches the composer's live paper for the same draft (same letterhead, template fields, logo, lines, totals, DRAFT marker). The golden test already guarantees byte identity; this is the human confirmation.
---
## Self-review (pre-implementation)
- **13 tasks: 7 server (TDD) + 6 frontend (browser-verified).** Server: 1 `prepareDraft` extraction + parity, 2 `POST /documents/preview`, 3 fidelity golden, 4 `template.*` rendering with default-fallback, 5 `@media screen` + `documentHtmlSample` + `POST /previews/sample`, 6 `GET/PUT /settings/template` + logo, 7 reminder preview. Frontend: 8 `LivePreview`, 9 composer split view, 10 Document Template page, 11 quote-content, 12 reminder-queue preview, 13 final walk.
- **Spec coverage (all of §1§8):** one shared compute path (§12 → Tasks 13), permissive preview persisting nothing (§2 → Task 2), the editable `template.*` data + Document Template page absorbing the company-profile letterhead (§4 → Tasks 4, 6, 10), the `@media screen` template-owned paper (§3 → Task 5), the split composer with the deleted client reducer + server-truth totals + IGST-live + responsive narrow sheet (§3 → Task 9), and the two remaining sibling surfaces — quote-content and reminder email (§5 → Tasks 1112). Out of scope stated where relevant: a **visual layout** editor (Store product's Print Templates, separate repo), `template.*` on `receiptHtml`, the "Exact PDF" button, and the dashed page-break line.
- **No placeholders:** every server task ships a verbatim failing test and full implementation; every frontend task ships full component code + an explicit browser-verification step (iframe/matchMedia/layout/upload UI with no unit-testable surface).
- **Type consistency (checked against the real code):** `prepareDraft` returns `BillTotals`/`DocPayload` already imported in `repos-documents.ts`; the preview route's synthetic `Doc` sets exactly the fields `documentHtml` reads and ignored fields are harmless; `fyOf`/`type Client`/`type Doc` imports added where used; `documentHtml`'s settings-map param already types as `Record<string,string>`, so reading new `template.*` keys needs no signature change; `companySettings()` broadening feeds the same map to the pdf/send/preview/sample routes; the logo regex + 200 KB cap run inside the route (with `express.json` limit raised so a legitimate logo is not pre-rejected); client `DocTotals` is the exact subset the strip renders.
- **Two intentional refinements, both preserving "one renderer, one compute path":** (1) `prepareDraft` returns `{ docType, clientId, docDate, totals, payload, warnings }` (a superset of the design's `{ lines, totals, payload }`) so save and preview share one `docDate`/`fy` and `warnings[]` has a home. (2) The design's "one preview route" is realized as the shared `LivePreview` mechanism + the single `documentHtml`/`documentHtmlSample` renderer, fed by `POST /documents/preview` (composer) and one thin `POST /previews/sample` (the two template-editing surfaces preview **unsaved** company/`template.*`/bullet edits that `/documents/preview` structurally cannot). Editable `template.*` is pure data through that same renderer — still no second renderer, and the PDF picks the fields up for free via the broadened `companySettings()`.
- **Regression discipline:** the two extractions (`prepareDraft`, `reminderContext`) are behaviour-preserving and each task re-runs the pre-existing suite (`documents.test.ts`, `send-reminder.test.ts`) as its green gate; `templates.test.ts` (substring assertions) and `settings-company.test.ts` are unaffected by the added `@media screen` lines, `template.*` defaults, and the new `/settings/template` routes (the old `/settings/company` routes stay); the root typecheck gate and `apps/hq-web` Vite build run at every commit.