feat(hq): screen paper styles + documentHtmlSample + POST /previews/sample

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

@ -43,7 +43,7 @@ import {
} from './repos-aws' } from './repos-aws'
import { pullMonthlyCosts } from './aws-costs' import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
import { documentHtml } from './templates' import { documentHtml, documentHtmlSample } from './templates'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { emailStatus } from './repos-email' import { emailStatus } from './repos-email'
import { sendDocumentEmail, type GmailDeps } from './gmail' import { sendDocumentEmail, type GmailDeps } from './gmail'
@ -59,6 +59,17 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
).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]))
} }
// 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<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
// Env read at request time so gmail-connect can run without a restart. // Env read at request time so gmail-connect can run without a restart.
const gmail = (): GmailDeps => ({ const gmail = (): GmailDeps => ({
f: gmailDeps?.f ?? fetch, f: gmailDeps?.f ?? fetch,
@ -224,6 +235,36 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) 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<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) })
})
r.get('/documents', requireAuth, (req, res) => { r.get('/documents', requireAuth, (req, res) => {
const filter: DocumentFilter = {} const filter: DocumentFilter = {}
if (typeof req.query['type'] === 'string') filter.type = req.query['type'] if (typeof req.query['type'] === 'string') filter.type = req.query['type']
@ -580,10 +621,6 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
}) })
// ---------- company profile (owner-editable; PDFs read these live) ---------- // ---------- company profile (owner-editable; PDFs read these live) ----------
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',
}
r.get('/settings/company', requireAuth, (_req, res) => { r.get('/settings/company', requireAuth, (_req, res) => {
res.json({ ok: true, company: companySettings() }) res.json({ ok: true, company: companySettings() })
}) })

@ -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 { Client } from './repos-clients'
import type { Doc, DocType } from './repos-documents' import type { Doc, DocType } from './repos-documents'
@ -81,6 +81,10 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
<title>${esc(doc.docNo ?? 'Draft')}</title> <title>${esc(doc.docNo ?? 'Draft')}</title>
<style> <style>
@page { size: A4; margin: 14mm; } @page { size: A4; margin: 14mm; }
/* 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; } }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; } body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
.letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; } .letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; }
@ -200,6 +204,10 @@ function receiptHtml(doc: Doc, client: Client, company: Record<string, string>):
<title>${esc(doc.docNo ?? 'Receipt')}</title> <title>${esc(doc.docNo ?? 'Receipt')}</title>
<style> <style>
@page { size: A4; margin: 14mm; } @page { size: A4; margin: 14mm; }
/* 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; } }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; } body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
.letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; } .letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; }
@ -256,3 +264,38 @@ function receiptHtml(doc: Doc, client: Client, company: Record<string, string>):
</body> </body>
</html>` </html>`
} }
/**
* 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)
}

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