|
|
# Live Document Preview — Implementation Plan (HQ console)
|
|
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Server tasks (1–7) are full TDD loops:** write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → `npm run typecheck` → commit. **Frontend tasks (8–13) cannot be unit-tested** (they are iframe/layout/matchMedia/upload UI) — each ends with an explicit **browser-verification** step against the live app instead of a vitest step, still committing on completion. No task depends on code a later task writes.
|
|
|
|
|
|
**Goal:** Ship the live document preview from [docs/superpowers/specs/2026-07-13-live-preview-design.md](../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 1–2 established; anyone who ever forks the template breaks it).
|
|
|
|
|
|
**Interfaces:** none produced. The test constructs a fixture draft with `createDraft` (the persisted, un-issued draft the `/documents/:id/pdf` route would render), computes `expected = documentHtml(fixtureDoc, client, company)` — **the exact string** `renderPdf` receives in `api.ts` (`renderPdf(documentHtml(document, client, company))`) — and asserts the preview route's `html` equals it. No puppeteer; strings only.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test** — `apps/hq/test/preview-fidelity.test.ts`
|
|
|
|
|
|
```ts
|
|
|
// apps/hq/test/preview-fidelity.test.ts
|
|
|
import { describe, it, expect, afterAll } from 'vitest'
|
|
|
import express from 'express'
|
|
|
import { openDb } from '../src/db'
|
|
|
import { seedIfEmpty } from '../src/seed'
|
|
|
import { createStaff } from '../src/auth'
|
|
|
import { createClient, getClient } from '../src/repos-clients'
|
|
|
import { createModule, setPrice } from '../src/repos-modules'
|
|
|
import { createDraft } from '../src/repos-documents'
|
|
|
import { documentHtml } from '../src/templates'
|
|
|
import { apiRouter } from '../src/api'
|
|
|
|
|
|
function companyMap(db: any): Record<string, string> {
|
|
|
const rows = db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`).all() as { key: string; value: string }[]
|
|
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
|
|
}
|
|
|
|
|
|
describe('preview fidelity (string identity with the PDF path)', () => {
|
|
|
const db = openDb(':memory:'); seedIfEmpty(db)
|
|
|
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
|
|
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', address: 'Kochi', gstin: '32ABCDE1234F1Z5' })
|
|
|
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS', 'GST filing'] })
|
|
|
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
|
|
const app = express(); app.use(express.json()); app.use('/api', apiRouter(db))
|
|
|
const server = app.listen(0)
|
|
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
|
|
afterAll(() => server.close())
|
|
|
|
|
|
it('preview.html === documentHtml the /pdf route would feed renderPdf', async () => {
|
|
|
const input = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
|
|
|
lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
|
|
|
// The fixture draft: exactly what /documents/:id/pdf renders (un-issued draft, docNo=null).
|
|
|
const fixture = createDraft(db, 'u1', input)
|
|
|
const expected = documentHtml(fixture, getClient(db, c.id)!, companyMap(db))
|
|
|
|
|
|
const token = (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
|
|
|
const res = await fetch(`${base}/documents/preview`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(input) })
|
|
|
const out = await res.json() as any
|
|
|
|
|
|
expect(out.html).toBe(expected) // one renderer — no drift possible
|
|
|
})
|
|
|
})
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: Run** — `npx vitest run apps/hq/test/preview-fidelity.test.ts`. It should PASS immediately on top of Tasks 1–2 (the whole point: fidelity is already structural). **If it fails**, the divergence is a real bug — 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 1–7), `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 (§1–2 → Tasks 1–3), permissive preview persisting nothing (§2 → Task 2), the editable `template.*` data + Document Template page absorbing the company-profile letterhead (§4 → Tasks 4, 6, 10), the `@media 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 11–12). 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.
|