diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 0206406..5e7d3aa 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -69,76 +69,139 @@ export function documentHtml(doc: Doc, client: Client, company: Record s !== '').join(' · '), - ].filter((s) => s !== '') + // Accent = the single chromatic voice. Validate-or-fallback BEFORE interpolation so a + // raw setting string can never be injected into the CSS (`#334155` is professional slate-indigo). + const accentRaw = tpl('accent') + const accent = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(accentRaw) ? accentRaw : '#334155' + const isDraft = (doc.docNo ?? '') === '' + const phoneEmail = [get('phone'), get('email')].filter((s) => s !== '').join(' · ') return ` ${esc(doc.docNo ?? 'Draft')} -
- ${logo !== '' ? `` : ''} -

${esc(get('name'))}

- ${companyMeta.map((line) => `

${esc(line)}

`).join('\n ')} + ${isDraft ? `
DRAFT
` : ''} + + -
${esc(title)}
+
${esc(title)}
-
-
-

${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}

-

${esc(client.name)} (${esc(client.code)})

+
+
+
${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}
+

${esc(client.name)} (${esc(client.code)})

${client.address !== '' ? `

${esc(client.address)}

` : ''} - ${client.gstin !== undefined ? `

GSTIN: ${esc(client.gstin)}

` : ''} -

State Code: ${esc(client.stateCode)}

+ ${client.gstin !== undefined ? `

GSTIN: ${esc(client.gstin)}

` : ''} +

State Code: ${esc(client.stateCode)}

-
-

Document

-

No: ${esc(doc.docNo ?? 'DRAFT')}

-

Date: ${displayDate(doc.docDate)}

-

FY: ${esc(doc.fy)}

+
+
Document No${doc.docNo !== null ? esc(doc.docNo) : 'DRAFT'}
+
Date${displayDate(doc.docDate)}
+
FY${esc(doc.fy)}
-
+
@@ -154,24 +217,26 @@ export function documentHtml(doc: Doc, client: Client, company: Record
- Amount in words:
${esc(rupeesInWords(doc.payablePaise))} +
Amount in Words
+
${esc(rupeesInWords(doc.payablePaise))}
${totalsRows(doc)} - +
Total${formatINR(doc.payablePaise)}
Grand Total${formatINR(doc.payablePaise)}
- ${showBank ? `

Bank Details

${esc(get('bank'))}

` : ''} - - ${termsText !== '' ? `

Terms

${esc(termsText)}

` : ''} + ${(showBank || termsText !== '') ? `
+ ${showBank ? `
Bank Details

${esc(get('bank'))}

` : ''} + ${termsText !== '' ? `
Terms & Conditions

${esc(termsText)}

` : ''} +
` : ''} ${tpl('declaration') !== '' ? `
${esc(tpl('declaration'))}
` : ''} ${tpl('jurisdiction') !== '' ? `

${esc(tpl('jurisdiction'))}

` : ''}
-

For ${esc(get('name'))}

-

${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}

+

For ${esc(get('name'))}

+

${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}

${tpl('footer_note') !== '' ? `` : ''} diff --git a/apps/hq/test/template-accent.test.ts b/apps/hq/test/template-accent.test.ts new file mode 100644 index 0000000..6ad3b28 --- /dev/null +++ b/apps/hq/test/template-accent.test.ts @@ -0,0 +1,42 @@ +// apps/hq/test/template-accent.test.ts +import { describe, it, expect } from 'vitest' +import { documentHtml } from '../src/templates' + +const base = { + 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', gstin: '32ABCDE1234F1Z9', contacts: [], status: 'active', notes: '' } as never + +describe('template.accent (validated hex, single source of truth)', () => { + it('defaults to slate-indigo #334155 when unset', () => { + const html = documentHtml(base, client, { 'company.name': 'Tecnostac' }) + expect(html).toContain('--accent:#334155') + }) + it('applies a valid hex accent verbatim (6- and 3-digit)', () => { + expect(documentHtml(base, client, { 'company.name': 'X', 'template.accent': '#0ea5e9' })) + .toContain('--accent:#0ea5e9') + expect(documentHtml(base, client, { 'company.name': 'X', 'template.accent': '#fff' })) + .toContain('--accent:#fff') + }) + it('falls back to the default for a non-hex / injection value (never injects raw setting into CSS)', () => { + const html = documentHtml(base, client, { 'company.name': 'X', 'template.accent': 'red; } body{display:none' }) + expect(html).toContain('--accent:#334155') + expect(html).not.toContain('body{display:none') + }) + it('prints the accent through puppeteer via print-color-adjust:exact', () => { + expect(documentHtml(base, client, { 'company.name': 'X' })).toContain('print-color-adjust:exact') + }) + it('renders a DRAFT chip (literal DRAFT) for an un-issued doc, real number otherwise', () => { + const draft = { ...(base as object), docNo: null } as never + const dh = documentHtml(draft, client, { 'company.name': 'X' }) + expect(dh).toContain('DRAFT') + const issued = documentHtml(base, client, { 'company.name': 'X' }) + expect(issued).not.toContain('class="chip-draft"') // CSS defines .chip-draft, but issued docs never emit the chip + expect(issued).toContain('INV/26-27-0001') + }) +})