From c2f94df06df5b15e2295cf71e01716cc9d27ea5a Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 13 Jul 2026 15:18:43 +0530 Subject: [PATCH] feat(hq): screen paper styles + documentHtmlSample + POST /previews/sample Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 47 ++++++++++++++++-- apps/hq/src/templates.ts | 45 ++++++++++++++++- apps/hq/test/preview-sample.test.ts | 77 +++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 apps/hq/test/preview-sample.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index ccfdcd2..3607225 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -43,7 +43,7 @@ import { } from './repos-aws' import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' -import { documentHtml } from './templates' +import { documentHtml, documentHtmlSample } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' import { sendDocumentEmail, type GmailDeps } from './gmail' @@ -59,6 +59,17 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { ).all() as { key: string; value: string }[] return Object.fromEntries(rows.map((row) => [row.key, row.value])) } + // Shared letterhead field maps — the one definition every route (company profile, + // template settings, sample preview) uses to map request field → setting key. + const COMPANY_FIELDS: Record = { + 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 = { + 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 // Env read at request time so gmail-connect can run without a restart. const gmail = (): GmailDeps => ({ f: gmailDeps?.f ?? fetch, @@ -224,6 +235,36 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + // 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; template?: Record + titles?: Record; logo?: unknown; contentLines?: unknown + } + const merged = companySettings() // company.* + template.* + const applyGroup = (group: Record | undefined, fields: Record): 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) }) + }) r.get('/documents', requireAuth, (req, res) => { const filter: DocumentFilter = {} if (typeof req.query['type'] === 'string') filter.type = req.query['type'] @@ -580,10 +621,6 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { }) // ---------- company profile (owner-editable; PDFs read these live) ---------- - const COMPANY_FIELDS: Record = { - name: 'company.name', address: 'company.address', gstin: 'company.gstin', - stateCode: 'company.state_code', phone: 'company.phone', email: 'company.email', bank: 'company.bank', - } r.get('/settings/company', requireAuth, (_req, res) => { res.json({ ok: true, company: companySettings() }) }) diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 0f58c76..0206406 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -1,4 +1,4 @@ -import { amountInWordsINR, formatINR, type BillLine, type Paise } from '@sims/domain' +import { amountInWordsINR, formatINR, type BillLine, type BillTotals, type Paise } from '@sims/domain' import type { Client } from './repos-clients' import type { Doc, DocType } from './repos-documents' @@ -81,6 +81,10 @@ export function documentHtml(doc: Doc, client: Client, company: Record${esc(doc.docNo ?? 'Draft')}