feat(hq): beautify document template — "Slate Ledger" visual system + template.accent

Rewrite documentHtml to a restrained, print-first GST layout: hairline table
rules, tabular money aligned to the paisa, a single accent driving every
structural color (letterhead rule, title, table-header rule + color-mix tint,
docmeta edge, amount-in-words edge, filled grand-total band).

Add optional template.accent setting (validated hex-or-fallback #334155
slate-indigo) read from the settings map and interpolated only after
validation, so a raw setting string can never be injected into the CSS.
print-color-adjust:exact so the accent tint and grand-total fill survive
puppeteer. DRAFT stays: accent chip in docmeta + optional watermark for
un-issued docs. lineRow/totalsRows untouched; @media screen paper + @page
14mm preserved; renderer stays one pure function so preview===pdf identity holds.

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

@ -69,76 +69,139 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
const termsText = doc.payload.terms ?? tpl('terms') 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 = [ // Accent = the single chromatic voice. Validate-or-fallback BEFORE interpolation so a
get('address'), // raw setting string can never be injected into the CSS (`#334155` is professional slate-indigo).
get('gstin') !== '' ? `GSTIN: ${get('gstin')}` : '', const accentRaw = tpl('accent')
[get('phone'), get('email')].filter((s) => s !== '').join(' · '), const accent = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(accentRaw) ? accentRaw : '#334155'
].filter((s) => s !== '') const isDraft = (doc.docNo ?? '') === ''
const phoneEmail = [get('phone'), get('email')].filter((s) => s !== '').join(' · ')
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>${esc(doc.docNo ?? 'Draft')}</title> <title>${esc(doc.docNo ?? 'Draft')}</title>
<style> <style>
:root{
--accent:${accent};
--ink:#1f2937; --ink-strong:#111827; --muted:#6b7280; --faint:#9ca3af;
--line:#e5e7eb; --panel:#f8fafc;
}
@page{ size:A4; margin:14mm; } @page{ size:A4; margin:14mm; }
/* On screen (live preview) puppeteer is not involved, so @page margins do not /* 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 apply mimic the 14mm print margin on a white A4 card over a desk-grey
print media, so these screen-only rules never affect the actual PDF. */ backdrop. page.pdf renders the print media, so these screen-only rules never
@media screen { body { padding: 14mm; background: #fff; } } affect the actual PDF. */
@media screen{
html{ background:#eceef1; }
body{ padding:14mm; background:#fff; max-width:210mm; margin:0 auto; box-shadow:0 1px 4px rgba(0,0,0,.18); }
}
*{ box-sizing:border-box; } *{ box-sizing:border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; } body{
.letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; } font-family:'Segoe UI','Inter','Helvetica Neue',Arial,sans-serif;
.letterhead h1 { font-size: 20px; margin: 0 0 2px; letter-spacing: 0.5px; } font-size:10.5px; line-height:1.45; color:var(--ink); margin:0;
.letterhead p { margin: 1px 0; color: #444; } -webkit-print-color-adjust:exact; print-color-adjust:exact;
.doc-title { text-align: center; font-size: 13px; font-weight: 700; letter-spacing: 2px; margin: 12px 0 8px; } }
.meta { display: flex; justify-content: space-between; margin-bottom: 10px; } strong{ font-weight:600; }
.meta .box { width: 48%; } .eyebrow{ font-size:8.5px; font-weight:600; letter-spacing:.9px; text-transform:uppercase; color:var(--muted); margin:0 0 3px; }
.meta h2 { font-size: 10px; text-transform: uppercase; color: #666; margin: 0 0 3px; }
.meta p { margin: 1px 0; } /* Letterhead band */
table.lines { width: 100%; border-collapse: collapse; margin-bottom: 8px; } .letterhead{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; padding-bottom:10px; border-bottom:2px solid var(--accent); }
table.lines th, table.lines td { border: 1px solid #999; padding: 4px 6px; } .letterhead .lh-brand{ flex:0 0 auto; }
table.lines th { background: #f0f0f0; font-size: 10px; text-transform: uppercase; } .letterhead .logo{ max-height:60px; max-width:200px; display:block; }
td.r, th.r { text-align: right; } td.c, th.c { text-align: center; } .letterhead .lh-company{ text-align:right; }
ul.line-content { margin: 3px 0 0; padding-left: 16px; color: #555; font-size: 10px; } .letterhead.no-logo .lh-brand{ display:none; }
.letterhead.no-logo .lh-company{ text-align:left; }
.lh-company h1{ font-size:21px; font-weight:600; letter-spacing:.2px; color:var(--ink-strong); margin:0 0 3px; line-height:1.15; }
.lh-company p{ margin:1px 0; color:var(--muted); font-size:10px; }
.lh-company .gstin{ color:var(--ink-strong); font-weight:600; }
/* Document title */
.doc-title{ text-align:center; margin:14px 0 12px; }
.doc-title span{ display:inline-block; font-size:14px; font-weight:700; letter-spacing:3px; text-transform:uppercase; color:var(--accent); padding-bottom:4px; border-bottom:2px solid var(--accent); }
/* Bill-to / meta */
.meta{ display:flex; justify-content:space-between; gap:16px; margin-bottom:14px; }
.meta .party{ flex:1 1 55%; }
.party .party-name{ font-size:12px; font-weight:600; color:var(--ink-strong); margin:0 0 2px; }
.party p{ margin:1px 0; }
.meta .docmeta{ flex:0 0 38%; align-self:flex-start; border:1px solid var(--line); border-left:3px solid var(--accent); border-radius:2px; padding:8px 10px; background:var(--panel); }
.docmeta .row{ display:flex; justify-content:space-between; gap:10px; margin:2px 0; }
.docmeta .k{ color:var(--muted); }
.docmeta .v{ font-weight:600; color:var(--ink-strong); font-variant-numeric:tabular-nums; }
.chip-draft{ display:inline-block; font-size:8.5px; font-weight:700; letter-spacing:1px; color:#fff; background:var(--accent); padding:1px 6px; border-radius:2px; }
/* Line table */
table.lines{ width:100%; border-collapse:collapse; margin-bottom:14px; }
table.lines thead{ display:table-header-group; }
table.lines th{ font-size:9px; font-weight:600; letter-spacing:.5px; text-transform:uppercase; color:var(--ink-strong); text-align:left; padding:7px 8px; background:#f1f5f9; border-bottom:2px solid var(--accent); }
table.lines td{ padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
table.lines tbody tr{ break-inside:avoid; page-break-inside:avoid; }
table.lines td.c, table.lines th.c{ text-align:center; }
table.lines td.r, table.lines th.r{ text-align:right; font-variant-numeric:tabular-nums; font-feature-settings:"tnum" 1; }
table.lines td:first-child, table.lines th:first-child{ padding-left:2px; }
table.lines td:last-child, table.lines th:last-child{ padding-right:2px; }
table.lines td:last-child{ font-weight:600; }
ul.line-content{ margin:4px 0 0; padding-left:15px; color:var(--muted); font-size:9.5px; }
ul.line-content li{ margin:1px 0; } ul.line-content li{ margin:1px 0; }
.bottom { display: flex; justify-content: space-between; align-items: flex-start; } @supports (background: color-mix(in srgb, red, blue)){
.words { width: 55%; } table.lines th{ background: color-mix(in srgb, var(--accent) 7%, #ffffff); }
table.totals { width: 40%; border-collapse: collapse; } }
table.totals td { padding: 3px 6px; border-bottom: 1px solid #ddd; }
table.totals tr.grand td { border-top: 2px solid #1a1a1a; border-bottom: none; font-weight: 700; font-size: 13px; } /* Totals + amount in words */
.bank, .terms { margin-top: 12px; border: 1px solid #ccc; padding: 6px 8px; } .bottom{ display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:6px; break-inside:avoid; }
.bank h3, .terms h3 { font-size: 10px; text-transform: uppercase; margin: 0 0 3px; color: #666; } .words{ flex:1 1 55%; border-left:2px solid var(--accent); background:var(--panel); padding:8px 12px; border-radius:0 2px 2px 0; }
.letterhead .logo { max-height: 56px; margin-bottom: 4px; display: block; } .words .amt-words{ font-style:italic; font-weight:500; color:var(--ink-strong); }
.declaration { margin-top: 10px; font-size: 10px; color: #555; } table.totals{ flex:0 0 40%; border-collapse:collapse; }
.jurisdiction { margin-top: 6px; font-size: 10px; color: #666; } table.totals td{ padding:5px 10px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
.footer-note { margin-top: 16px; padding-top: 6px; border-top: 1px solid #ddd; text-align: center; font-size: 10px; color: #666; } table.totals td:first-child{ color:var(--muted); }
.sign { margin-top: 28px; text-align: right; } table.totals td.r{ text-align:right; color:var(--ink-strong); font-weight:500; }
table.totals tr.grand td{ background:var(--accent); color:#fff; border:none; font-size:14px; font-weight:700; padding:8px 10px; letter-spacing:.3px; }
/* Supporting blocks */
.panels{ display:flex; gap:14px; margin-top:14px; break-inside:avoid; }
.panels>*{ flex:1; }
.bank, .terms{ border:1px solid var(--line); border-radius:2px; padding:8px 10px; }
.bank p, .terms p{ margin:2px 0 0; }
.declaration{ margin-top:12px; font-size:9.5px; color:var(--muted); }
.jurisdiction{ margin-top:4px; font-size:9.5px; color:var(--faint); }
.sign{ margin-top:26px; text-align:right; break-inside:avoid; }
.sign .for{ color:var(--ink-strong); }
.sign .label{ margin-top:34px; font-weight:600; color:var(--ink-strong); }
.footer-note{ margin-top:18px; padding-top:8px; border-top:1px solid var(--line); text-align:center; font-size:9px; color:var(--faint); }
/* OPTIONAL draft watermark (only un-issued) */
.watermark{ position:fixed; top:50%; left:50%; transform:translate(-50%,-50%) rotate(-24deg); font-size:120px; font-weight:800; letter-spacing:8px; color:var(--accent); opacity:.05; z-index:-1; pointer-events:none; }
</style> </style>
</head> </head>
<body> <body>
<header class="letterhead"> ${isDraft ? `<div class="watermark">DRAFT</div>` : ''}
${logo !== '' ? `<img class="logo" src="${esc(logo)}" alt="">` : ''}
<header class="letterhead ${logo !== '' ? 'has-logo' : 'no-logo'}">
<div class="lh-brand">${logo !== '' ? `<img class="logo" src="${esc(logo)}" alt="">` : ''}</div>
<div class="lh-company">
<h1>${esc(get('name'))}</h1> <h1>${esc(get('name'))}</h1>
${companyMeta.map((line) => `<p>${esc(line)}</p>`).join('\n ')} ${get('address') !== '' ? `<p>${esc(get('address'))}</p>` : ''}
${get('gstin') !== '' ? `<p class="gstin">GSTIN: ${esc(get('gstin'))}</p>` : ''}
${phoneEmail !== '' ? `<p>${esc(phoneEmail)}</p>` : ''}
</div>
</header> </header>
<div class="doc-title">${esc(title)}</div> <div class="doc-title"><span>${esc(title)}</span></div>
<div class="meta"> <section class="meta">
<div class="box"> <div class="party">
<h2>${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}</h2> <div class="eyebrow">${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}</div>
<p><strong>${esc(client.name)}</strong> (${esc(client.code)})</p> <p class="party-name">${esc(client.name)} <span style="color:var(--muted);font-weight:400">(${esc(client.code)})</span></p>
${client.address !== '' ? `<p>${esc(client.address)}</p>` : ''} ${client.address !== '' ? `<p>${esc(client.address)}</p>` : ''}
${client.gstin !== undefined ? `<p>GSTIN: ${esc(client.gstin)}</p>` : ''} ${client.gstin !== undefined ? `<p><strong>GSTIN:</strong> ${esc(client.gstin)}</p>` : ''}
<p>State Code: ${esc(client.stateCode)}</p> <p><strong>State Code:</strong> ${esc(client.stateCode)}</p>
</div>
<div class="box">
<h2>Document</h2>
<p>No: <strong>${esc(doc.docNo ?? 'DRAFT')}</strong></p>
<p>Date: ${displayDate(doc.docDate)}</p>
<p>FY: ${esc(doc.fy)}</p>
</div> </div>
<div class="docmeta">
<div class="row"><span class="k">Document No</span><span class="v">${doc.docNo !== null ? esc(doc.docNo) : '<span class="chip-draft">DRAFT</span>'}</span></div>
<div class="row"><span class="k">Date</span><span class="v">${displayDate(doc.docDate)}</span></div>
<div class="row"><span class="k">FY</span><span class="v">${esc(doc.fy)}</span></div>
</div> </div>
</section>
<table class="lines"> <table class="lines">
<thead> <thead>
@ -154,24 +217,26 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
<div class="bottom"> <div class="bottom">
<div class="words"> <div class="words">
<strong>Amount in words:</strong><br>${esc(rupeesInWords(doc.payablePaise))} <div class="eyebrow">Amount in Words</div>
<div class="amt-words">${esc(rupeesInWords(doc.payablePaise))}</div>
</div> </div>
<table class="totals"> <table class="totals">
${totalsRows(doc)} ${totalsRows(doc)}
<tr class="grand"><td>Total</td><td class="r">${formatINR(doc.payablePaise)}</td></tr> <tr class="grand"><td>Grand Total</td><td class="r">${formatINR(doc.payablePaise)}</td></tr>
</table> </table>
</div> </div>
${showBank ? `<div class="bank"><h3>Bank Details</h3><p>${esc(get('bank'))}</p></div>` : ''} ${(showBank || termsText !== '') ? `<div class="panels">
${showBank ? `<div class="bank"><div class="eyebrow">Bank Details</div><p>${esc(get('bank'))}</p></div>` : ''}
${termsText !== '' ? `<div class="terms"><h3>Terms</h3><p>${esc(termsText)}</p></div>` : ''} ${termsText !== '' ? `<div class="terms"><div class="eyebrow">Terms &amp; Conditions</div><p>${esc(termsText)}</p></div>` : ''}
</div>` : ''}
${tpl('declaration') !== '' ? `<div class="declaration">${esc(tpl('declaration'))}</div>` : ''} ${tpl('declaration') !== '' ? `<div class="declaration">${esc(tpl('declaration'))}</div>` : ''}
${tpl('jurisdiction') !== '' ? `<p class="jurisdiction">${esc(tpl('jurisdiction'))}</p>` : ''} ${tpl('jurisdiction') !== '' ? `<p class="jurisdiction">${esc(tpl('jurisdiction'))}</p>` : ''}
<div class="sign"> <div class="sign">
<p>For <strong>${esc(get('name'))}</strong></p> <p class="for">For <strong>${esc(get('name'))}</strong></p>
<p style="margin-top: 36px;">${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}</p> <p class="label">${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}</p>
</div> </div>
${tpl('footer_note') !== '' ? `<div class="footer-note">${esc(tpl('footer_note'))}</div>` : ''} ${tpl('footer_note') !== '' ? `<div class="footer-note">${esc(tpl('footer_note'))}</div>` : ''}

@ -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('<span class="chip-draft">DRAFT</span>')
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')
})
})
Loading…
Cancel
Save