feat(hq): render editable template.* letterhead fields with default fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent 1ac44ae7de
commit a948a92350

@ -53,8 +53,9 @@ const staffId = (res: Response): string => (res.locals['staff'] as { id: string
export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router { export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
const r = Router() const r = Router()
const companySettings = (): Record<string, string> => { const companySettings = (): Record<string, string> => {
// The full letterhead settings map documentHtml reads — company.* identity + template.* text.
const rows = db.prepare( const rows = db.prepare(
`SELECT key, value FROM setting WHERE key LIKE 'company.%'`, `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`,
).all() as { key: string; value: string }[] ).all() as { key: string; value: string }[]
return Object.fromEntries(rows.map((row) => [row.key, row.value])) return Object.fromEntries(rows.map((row) => [row.key, row.value]))
} }

@ -63,6 +63,10 @@ function totalsRows(doc: Doc): string {
export function documentHtml(doc: Doc, client: Client, company: Record<string, string>): string { export function documentHtml(doc: Doc, client: Client, company: Record<string, string>): string {
if (doc.docType === 'RECEIPT') return receiptHtml(doc, client, company) if (doc.docType === 'RECEIPT') return receiptHtml(doc, client, company)
const get = (key: string): string => company[`company.${key}`] ?? '' 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')
// Bank details belong on documents payment is made against — tax and proforma invoices. // Bank details belong on documents payment is made against — tax and proforma invoices.
const showBank = (doc.docType === 'INVOICE' || doc.docType === 'PROFORMA') && get('bank') !== '' const showBank = (doc.docType === 'INVOICE' || doc.docType === 'PROFORMA') && get('bank') !== ''
const companyMeta = [ const companyMeta = [
@ -100,16 +104,21 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
table.totals tr.grand td { border-top: 2px solid #1a1a1a; border-bottom: none; font-weight: 700; font-size: 13px; } table.totals tr.grand td { border-top: 2px solid #1a1a1a; border-bottom: none; font-weight: 700; font-size: 13px; }
.bank, .terms { margin-top: 12px; border: 1px solid #ccc; padding: 6px 8px; } .bank, .terms { margin-top: 12px; border: 1px solid #ccc; padding: 6px 8px; }
.bank h3, .terms h3 { font-size: 10px; text-transform: uppercase; margin: 0 0 3px; color: #666; } .bank h3, .terms h3 { font-size: 10px; text-transform: uppercase; margin: 0 0 3px; color: #666; }
.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; }
.sign { margin-top: 28px; text-align: right; } .sign { margin-top: 28px; text-align: right; }
</style> </style>
</head> </head>
<body> <body>
<header class="letterhead"> <header class="letterhead">
${logo !== '' ? `<img class="logo" src="${esc(logo)}" alt="">` : ''}
<h1>${esc(get('name'))}</h1> <h1>${esc(get('name'))}</h1>
${companyMeta.map((line) => `<p>${esc(line)}</p>`).join('\n ')} ${companyMeta.map((line) => `<p>${esc(line)}</p>`).join('\n ')}
</header> </header>
<div class="doc-title">${TITLE[doc.docType]}</div> <div class="doc-title">${esc(title)}</div>
<div class="meta"> <div class="meta">
<div class="box"> <div class="box">
@ -151,13 +160,17 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
${showBank ? `<div class="bank"><h3>Bank Details</h3><p>${esc(get('bank'))}</p></div>` : ''} ${showBank ? `<div class="bank"><h3>Bank Details</h3><p>${esc(get('bank'))}</p></div>` : ''}
${doc.payload.terms !== undefined && doc.payload.terms !== '' ${termsText !== '' ? `<div class="terms"><h3>Terms</h3><p>${esc(termsText)}</p></div>` : ''}
? `<div class="terms"><h3>Terms</h3><p>${esc(doc.payload.terms)}</p></div>` : ''}
${tpl('declaration') !== '' ? `<div class="declaration">${esc(tpl('declaration'))}</div>` : ''}
${tpl('jurisdiction') !== '' ? `<p class="jurisdiction">${esc(tpl('jurisdiction'))}</p>` : ''}
<div class="sign"> <div class="sign">
<p>For <strong>${esc(get('name'))}</strong></p> <p>For <strong>${esc(get('name'))}</strong></p>
<p style="margin-top: 36px;">Authorised Signatory</p> <p style="margin-top: 36px;">${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}</p>
</div> </div>
${tpl('footer_note') !== '' ? `<div class="footer-note">${esc(tpl('footer_note'))}</div>` : ''}
</body> </body>
</html>` </html>`
} }

@ -0,0 +1,52 @@
// 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')
})
})
Loading…
Cancel
Save