feat(templates): section-per-module quotation PDF (Phase 9, D18 WS-H)

- QUOTATION renders one titled section per line: module name, its what's-
  included block, SAC + qty × rate + amount; sections never split mid-page and
  each module after the first opens a fresh page in print
- summary line up top ('This proposal covers: …'); totals/terms/signature
  unchanged; INVOICE/PROFORMA/CN/RECEIPT keep the compact grid (regression-
  asserted); preview-fidelity golden test untouched and green
- 3 tests; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 747026fa3b
commit 828f533a69

@ -182,3 +182,14 @@ table.wf { display: block; overflow-x: auto; }
.pulse-open { color: var(--accent-strong); margin-left: auto; font-size: 11.5px; }
@media (max-width: 900px) { .pulse-grid { display: none; } .pulse-readout { display: none; }
.pulse::after { content: 'Timeline hidden on small screens.'; color: var(--text-dim); font-size: 12px; } }
/* ================= Settings hub (rail + panels) ================= */
.set-wrap { display: flex; gap: 24px; }
.set-rail { flex: 0 0 190px; display: flex; flex-direction: column; gap: 2px; }
.set-rail button { text-align: left; border: none; background: none; padding: 8px 12px; border-radius: 8px; font: 500 13.5px var(--font); color: var(--text-dim); cursor: pointer; }
.set-rail button:hover { background: var(--bg-hover); color: var(--text); }
.set-rail button.active { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
.set-panel { flex: 1; min-width: 0; max-width: 860px; }
.set-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
.set-card h3 { margin: 0 0 8px; font-size: 14px; }
@media (max-width: 900px) { .set-wrap { flex-direction: column; } .set-rail { flex-direction: row; flex-wrap: wrap; } }

@ -12,7 +12,7 @@ import { Clients } from './pages/Clients'
import { ClientDetail } from './pages/ClientDetail'
import { Modules } from './pages/Modules'
import { Reports } from './pages/Reports'
import { DocumentTemplate } from './pages/DocumentTemplate'
import { Settings } from './pages/Settings'
import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView'
import { Employees } from './pages/Employees'
@ -34,7 +34,8 @@ function App() {
<Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings/template" element={<DocumentTemplate />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/template" element={<Navigate to="/settings?s=template" replace />} />
<Route path="/employees" element={<Employees />} />
<Route path="/profile" element={<Profile />} />
<Route path="/documents" element={<Documents />} />

@ -1,5 +1,5 @@
import {
BarChart3, BellRing, Boxes, FilePlus2, Files, FileText, Filter, LayoutDashboard, UserCog, Users,
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, Settings as SettingsIcon, UserCog, Users,
type LucideIcon,
} from 'lucide-react'
@ -29,7 +29,7 @@ export const NAV_GROUPS: NavGroup[] = [
label: 'Admin',
items: [
{ to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
{ to: '/settings/template', label: 'Document Template', icon: FileText, ownerOnly: true },
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
],
},
]

@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { Button, Field, Notice, PageHeader } from '@sims/ui'
import { getTemplateSettings, previewSample, putTemplateSettings, role, uploadTemplateLogo } from '../api'
import { getTemplateSettings, previewSample, putTemplateSettings, uploadTemplateLogo } from '../api'
import { LivePreview } from '../components/LivePreview'
const COMPANY_FIELDS: { key: string; setting: string; label: string; area: boolean }[] = [
export const COMPANY_FIELDS: { key: string; setting: string; label: string; area: boolean }[] = [
{ key: 'name', setting: 'company.name', label: 'Company name', area: false },
{ key: 'address', setting: 'company.address', label: 'Address', area: true },
{ key: 'gstin', setting: 'company.gstin', label: 'GSTIN', area: false },
@ -28,9 +27,10 @@ const DOC_TITLES: { type: string; setting: string; label: string }[] = [
type Dict = Record<string, string>
/** Owner-only: all letterhead text data (company.* + template.*) with a live
* letterhead preview beside the form. One fixed layout not a visual editor. */
export function DocumentTemplate() {
/** All letterhead text data (company.* + template.*) with a live letterhead preview
* beside the form. One fixed layout not a visual editor. Owner gating lives in
* the Settings rail that hosts this panel, not here. */
export function TemplatePanel() {
const [company, setCompany] = useState<Dict>({})
const [template, setTemplate] = useState<Dict>({})
const [titles, setTitles] = useState<Dict>({})
@ -51,8 +51,6 @@ export function DocumentTemplate() {
const previewBody = useMemo(() => JSON.stringify({ company, template, titles }), [company, template, titles])
const commit = () => setCommitNonce((n) => n + 1)
if (role() !== 'owner') return <Navigate to="/" replace />
const save = () => {
setSaving(true); setMsg(undefined)
putTemplateSettings({ company, template, titles })
@ -122,3 +120,9 @@ export function DocumentTemplate() {
</div>
)
}
/** Compat wrapper the page shell now lives in Settings; kept so a stray import
* of the old page-level name still resolves. */
export function DocumentTemplate() {
return <TemplatePanel />
}

@ -0,0 +1,386 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Button, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid,
Notice, PageHeader, Skeleton, ThemeSwitcher, useToast,
} from '@sims/ui'
import {
createSchedule, getAwsRanking, getCompanyProfile, getEmailStatus, getReminderSettings,
getSharingSettings, putCompanyProfile, putReminderSettings, putSharingSettings, role,
} from '../api'
import { COMPANY_FIELDS, TemplatePanel } from './DocumentTemplate'
import { useData } from './Clients'
const inr = (p: number) => formatINR(p, { symbol: false })
type Dict = Record<string, string>
type SectionKey = 'company' | 'template' | 'reminders' | 'sharing' | 'integrations' | 'appearance'
const SECTIONS: { key: SectionKey; label: string; ownerOnly: boolean }[] = [
{ key: 'company', label: 'Company', ownerOnly: true },
{ key: 'template', label: 'Document template', ownerOnly: true },
{ key: 'reminders', label: 'Reminders', ownerOnly: true },
{ key: 'sharing', label: 'Sharing', ownerOnly: true },
{ key: 'integrations', label: 'Integrations', ownerOnly: true },
{ key: 'appearance', label: 'Appearance', ownerOnly: false },
]
/**
* One console-wide settings hub company identity, letterhead template, reminder
* cadences, sharing defaults, integration status, and appearance. Pinterest left
* rail; deep-links via `?s=`. Every section but Appearance is owner-only (the
* rail itself hides gated sections, so staff/manager land on Appearance and can
* never navigate past it, even by typing a `?s=` value directly).
*/
export function Settings() {
const [sp, setSp] = useSearchParams()
const isOwner = role() === 'owner'
const visible = SECTIONS.filter((s) => !s.ownerOnly || isOwner)
const requested = sp.get('s')
const active = visible.find((s) => s.key === requested)?.key ?? visible[0]!.key
return (
<div className="wf-page">
<PageHeader
title="Settings"
desc="Company identity, the letterhead template, reminder cadences, sharing defaults, integrations, and appearance."
/>
<div className="set-wrap">
<aside className="set-rail">
{visible.map((s) => (
<button
key={s.key}
type="button"
className={s.key === active ? 'active' : undefined}
onClick={() => setSp({ s: s.key })}
>
{s.label}
</button>
))}
</aside>
<div className="set-panel">
{active === 'company' && <CompanyPanel />}
{active === 'template' && <TemplatePanel />}
{active === 'reminders' && <RemindersPanel />}
{active === 'sharing' && <SharingPanel />}
{active === 'integrations' && <IntegrationsPanel />}
{active === 'appearance' && <AppearancePanel />}
</div>
</div>
</div>
)
}
/** Company identity same field list as the letterhead template's Company section
* (`COMPANY_FIELDS`, imported so the two never drift apart). */
function CompanyPanel() {
const toast = useToast()
const [company, setCompany] = useState<Dict>({})
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
getCompanyProfile()
.then((s) => {
setCompany(Object.fromEntries(COMPANY_FIELDS.map((x) => [x.key, s[x.setting] ?? ''])))
setLoaded(true)
})
.catch((e: Error) => toast.err(e.message))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const save = () => {
setSaving(true)
putCompanyProfile(company)
.then((s) => {
setCompany(Object.fromEntries(COMPANY_FIELDS.map((x) => [x.key, s[x.setting] ?? ''])))
toast.ok('Company profile saved.')
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setSaving(false))
}
return (
<div>
<h2>Company</h2>
<p className="wf-page-desc">Printed on every quotation, proforma, invoice and credit note.</p>
{!loaded ? <Skeleton rows={4} /> : (
<>
<FormGrid>
{COMPANY_FIELDS.map((x) => (
<FormField key={x.key} label={x.label} wide={x.area}>
{x.area
? <textarea className="wf" rows={2} value={company[x.key] ?? ''} onChange={(e) => setCompany((p) => ({ ...p, [x.key]: e.target.value }))} />
: <input className="wf" value={company[x.key] ?? ''} onChange={(e) => setCompany((p) => ({ ...p, [x.key]: e.target.value }))} />}
</FormField>
))}
</FormGrid>
<div style={{ marginTop: 14 }}>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</div>
</>
)}
</div>
)
}
const RULE_KINDS: { value: string; label: string }[] = [
{ value: 'quote_followup', label: 'Quote follow-up' },
{ value: 'invoice_overdue', label: 'Invoice overdue' },
]
/** Overdue/renewal day thresholds plus the dated cadence rows that drive follow-up
* timing and message text (rule 3 a cadence change is a new dated row, never an
* edit of an existing one). */
function RemindersPanel() {
const toast = useToast()
const { data, error, reload } = useData(getReminderSettings, [])
const [overdueDays, setOverdueDays] = useState('')
const [renewalDays, setRenewalDays] = useState('')
const [saving, setSaving] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
if (data === undefined) return
setOverdueDays(String(data.overdueDays))
setRenewalDays(String(data.renewalDays))
}, [data])
const save = () => {
const o = Number(overdueDays)
const r = Number(renewalDays)
if (!Number.isInteger(o) || o < 1 || !Number.isInteger(r) || r < 1) {
toast.err('Overdue days and renewal days must both be positive integers')
return
}
setSaving(true)
putReminderSettings({ overdueDays: o, renewalDays: r })
.then(() => { toast.ok('Reminder thresholds saved.'); reload() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setSaving(false))
}
return (
<div>
<h2>Reminders</h2>
<p className="wf-page-desc">
Days-overdue and days-before-renewal thresholds, plus the dated follow-up cadences the reminder engine resolves by business date.
</p>
{error !== undefined ? <ErrorState message={error} onRetry={reload} />
: data === undefined ? <Skeleton rows={4} /> : (
<>
<FormGrid>
<FormField label="Overdue after (days)">
<input className="wf" type="number" min={1} value={overdueDays} onChange={(e) => setOverdueDays(e.target.value)} />
</FormField>
<FormField label="Renewal reminder (days before)">
<input className="wf" type="number" min={1} value={renewalDays} onChange={(e) => setRenewalDays(e.target.value)} />
</FormField>
</FormGrid>
<div style={{ margin: '14px 0 22px' }}>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</div>
<h3>Dated cadences</h3>
{data.schedules.length === 0 ? <EmptyState>No dated schedule rows yet the built-in defaults apply.</EmptyState> : (
<DataTable
columns={[
{ key: 'rule', label: 'Rule' }, { key: 'from', label: 'From' }, { key: 'to', label: 'To' },
{ key: 'offsets', label: 'Day offsets', mono: true }, { key: 'subject', label: 'Subject' },
]}
rows={data.schedules.map((s) => ({
rule: s.ruleKind, from: s.effectiveFrom, to: s.effectiveTo ?? '—',
offsets: s.dayOffsets, subject: s.subject ?? '—',
}))}
/>
)}
<p style={{ fontSize: 12, color: 'var(--text-dim)', margin: '10px 0' }}>
Rows are never edited a cadence change is a new dated row.
</p>
<Button onClick={() => setDialogOpen(true)}>+ New dated row</Button>
</>
)}
<NewScheduleDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onDone={reload} />
</div>
)
}
function NewScheduleDialog(props: { open: boolean; onClose: () => void; onDone: () => void }) {
const toast = useToast()
const [ruleKind, setRuleKind] = useState(RULE_KINDS[0]!.value)
const [effectiveFrom, setEffectiveFrom] = useState('')
const [dayOffsets, setDayOffsets] = useState('')
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
setRuleKind(RULE_KINDS[0]!.value); setEffectiveFrom(''); setDayOffsets('')
setSubject(''); setBody(''); setError(undefined)
}, [props.open])
const close = () => { if (!saving) props.onClose() }
const save = () => {
if (effectiveFrom === '' || dayOffsets.trim() === '') {
setError('Effective date and day offsets are required')
return
}
setError(undefined)
setSaving(true)
createSchedule({
ruleKind, effectiveFrom, dayOffsets: dayOffsets.trim(),
...(subject.trim() !== '' ? { subject: subject.trim() } : {}),
...(body.trim() !== '' ? { body: body.trim() } : {}),
})
.then(() => { toast.ok('Schedule row created.'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<Dialog
open={props.open}
onClose={close}
title="New dated cadence row"
footer={(
<>
<Button pill onClick={close}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Create'}</Button>
</>
)}
>
<FormGrid>
<FormField label="Rule">
<select className="wf" value={ruleKind} onChange={(e) => setRuleKind(e.target.value)}>
{RULE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</FormField>
<FormField label="Effective from">
<input className="wf" type="date" value={effectiveFrom} onChange={(e) => setEffectiveFrom(e.target.value)} />
</FormField>
<FormField label="Day offsets" wide>
<input className="wf" placeholder="3,7,14" value={dayOffsets} onChange={(e) => setDayOffsets(e.target.value)} />
</FormField>
<FormField label="Subject (optional — falls back to the built-in default)" wide>
<input className="wf" value={subject} onChange={(e) => setSubject(e.target.value)} />
</FormField>
<FormField label="Body (optional — falls back to the built-in default)" wide>
<textarea className="wf" rows={3} value={body} onChange={(e) => setBody(e.target.value)} />
</FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)
}
/** Default expiry for newly minted public share links — a fixed day count or never. */
function SharingPanel() {
const toast = useToast()
const { data, error, reload } = useData(getSharingSettings, [])
const [mode, setMode] = useState<'days' | 'never'>('days')
const [days, setDays] = useState('30')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (data === undefined) return
if (data.defaultExpiryDays === null) setMode('never')
else { setMode('days'); setDays(String(data.defaultExpiryDays)) }
}, [data])
const save = () => {
let n: number | null = null
if (mode === 'days') {
n = Number(days)
if (!Number.isInteger(n) || n < 1) { toast.err('Days must be a positive integer'); return }
}
setSaving(true)
putSharingSettings(n)
.then(() => { toast.ok('Sharing default saved.'); reload() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setSaving(false))
}
return (
<div>
<h2>Sharing</h2>
<p className="wf-page-desc">Default expiry applied to new public document share links.</p>
{error !== undefined ? <ErrorState message={error} onRetry={reload} />
: data === undefined ? <Skeleton rows={2} /> : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '10px 0 16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="radio" name="share-expiry" checked={mode === 'days'} onChange={() => setMode('days')} />
Expire after
<input
className="wf" type="number" min={1} style={{ width: 70 }}
value={days} disabled={mode !== 'days'} onChange={(e) => setDays(e.target.value)}
/>
days
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="radio" name="share-expiry" checked={mode === 'never'} onChange={() => setMode('never')} />
Never expires
</label>
</div>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</>
)}
</div>
)
}
/** Gmail send health and the AWS cost-pull status read-only, links to the fix
* (server-side `gmail-connect` for the former, the Reports AWS tab for the latter). */
function IntegrationsPanel() {
const email = useData(getEmailStatus, [])
const aws = useData(() => getAwsRanking(), [])
const emailOk = email.data !== undefined && email.data.connected && !email.data.dead
return (
<div>
<h2>Integrations</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 10 }}>
<div className="set-card">
<h3>Gmail</h3>
{email.data === undefined ? <Skeleton rows={1} /> : (
<>
<span className={`hq-pill${emailOk ? ' ok' : ' warn'}`}>
<span className={`hq-dot${emailOk ? ' ok' : ' warn'}`} />
{emailOk ? `Connected${email.data.address !== undefined ? `${email.data.address}` : ''}` : 'Disconnected'}
</span>
{!emailOk && (
<p style={{ fontSize: 12.5, color: 'var(--text-dim)', margin: '8px 0 0' }}>
Run gmail-connect on the server to reconnect. Sends queue to manual until then.
</p>
)}
</>
)}
</div>
<div className="set-card">
<h3>AWS cost recovery</h3>
{aws.data === undefined ? <Skeleton rows={1} />
: aws.data.rows.length === 0 ? <EmptyState>No AWS cost recorded yet.</EmptyState>
: <p style={{ margin: 0 }}>Last pull month {aws.data.month} · {inr(aws.data.total)}</p>}
</div>
</div>
</div>
)
}
/** Mode + accent reuses the existing `<ThemeSwitcher>` wholesale so the swatch
* colours live in exactly one place (`packages/ui/src/ThemeSwitcher.tsx`). The
* only section visible to every role: it is per-browser, not per-tenant config. */
function AppearancePanel() {
return (
<div>
<h2>Appearance</h2>
<p className="wf-page-desc">
Mode and accent are stored in this browser only every signed-in user picks their own.
</p>
<ThemeSwitcher />
</div>
)
}

@ -32,6 +32,32 @@ function displayDate(iso: string): string {
return `${d ?? ''}/${m ?? ''}/${y ?? ''}`
}
/**
* WS-H (D18): quotations print one titled SECTION per module proposal paper,
* not a billing grid. Each section carries the module's name, its "what's
* included" block and its price; sections never split across pages and, in
* print, every module after the first starts on a fresh page. Totals, terms
* and signature follow unchanged. All other doc types keep the compact table.
*/
function quotationSections(doc: Doc): string {
const summary = doc.payload.lines.map((l) => esc(l.name)).join(' · ')
const sections = doc.payload.lines.map((l, i) => {
const contents = doc.payload.lineContents?.[i] ?? []
return `<section class="qsection${i > 0 ? ' qbreak' : ''}">
<h3 class="qtitle"><span class="qnum">${i + 1}.</span>${esc(l.name)}</h3>
${contents.length > 0
? `<div class="eyebrow">What&#39;s included</div><ul class="line-content">${contents.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
: ''}
<table class="qpricing"><tr>
<td>SAC ${esc(l.hsn)}</td>
<td class="r">${l.qty} ${esc(l.unitCode)} × ${formatINR(l.unitPricePaise)}</td>
<td class="r qamt">${formatINR(l.taxablePaise)}</td>
</tr></table>
</section>`
}).join('\n')
return `<p class="qsummary">This proposal covers: <strong>${summary}</strong></p>\n${sections}`
}
function lineRow(l: BillLine, i: number, contents: string[]): string {
const bullets = contents.length > 0
? `<ul class="line-content">${contents.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
@ -169,6 +195,18 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
.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); }
/* Quotation sections (WS-H): one module per titled block, never split mid-page;
in print each module after the first opens a fresh page. */
.qsummary{ margin:0 0 12px; color:var(--muted); }
.qsection{ border:1px solid var(--line); border-radius:2px; padding:10px 12px; margin-bottom:12px; break-inside:avoid; page-break-inside:avoid; }
@media print { .qbreak{ break-before:page; page-break-before:always; } }
.qtitle{ font-size:12.5px; margin:0 0 6px; color:var(--ink-strong); }
.qnum{ display:inline-block; min-width:16px; margin-right:6px; color:var(--accent); }
table.qpricing{ width:100%; border-collapse:collapse; margin-top:8px; border-top:1px solid var(--line); }
table.qpricing td{ padding:6px 0 0; color:var(--muted); }
table.qpricing td.r{ text-align:right; font-variant-numeric:tabular-nums; }
table.qpricing td.qamt{ font-weight:700; color:var(--ink-strong); }
/* 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>
@ -204,7 +242,7 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
</div>
</section>
<table class="lines">
${doc.docType === 'QUOTATION' ? quotationSections(doc) : `<table class="lines">
<thead>
<tr>
<th class="c">#</th><th>Description</th><th class="c">SAC</th><th class="r">Qty</th>
@ -214,7 +252,7 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
<tbody>
${doc.payload.lines.map((l, i) => lineRow(l, i, doc.payload.lineContents?.[i] ?? [])).join('\n')}
</tbody>
</table>
</table>`}
<div class="bottom">
<div class="words">

@ -52,3 +52,43 @@ describe('rupees in words', () => {
expect(rupeesInWords(50)).toBe('Fifty Paise Only')
})
})
describe('quotation sections (D18 WS-H)', () => {
const line = (n: number) => ({
itemId: `m${n}`, name: `Module ${n}`, hsn: '998313', qty: 1, unitCode: 'NOS',
unitPricePaise: 1_000_00, grossPaise: 1_000_00, discountPaise: 0, taxablePaise: 1_000_00,
taxRateBp: 1800, cgstPaise: 90_00, sgstPaise: 90_00, igstPaise: 0, cessPaise: 0,
lineTotalPaise: 1_180_00,
})
const fiveModuleQuote = {
...(doc as object), docType: 'QUOTATION', docNo: 'QT/26-27-0002',
payload: {
lines: [1, 2, 3, 4, 5].map(line),
totals: {},
lineContents: [['Core ledgers', 'Day-end'], ['RTGS/NEFT'], [], ['App for members'], ['SMS 50k pack']],
},
} as never
it('a 5-module quotation renders 5 titled sections, the summary line, and NO billing grid', () => {
const html = documentHtml(fiveModuleQuote, client, company)
expect(html.match(/class="qsection/g)).toHaveLength(5)
expect(html.match(/class="qsection qbreak/g)).toHaveLength(4) // fresh page from the 2nd on (print)
expect(html).toContain('This proposal covers:')
expect(html).toContain('Module 1')
expect(html).toContain('Core ledgers') // per-module contents land in their section
expect(html).toContain('SMS 50k pack')
expect(html).not.toContain('<table class="lines">') // proposals are not billing grids
expect(html).toContain('Grand Total') // totals block still follows
})
it('a module without content prints a tight section (no empty included-block)', () => {
const html = documentHtml(fiveModuleQuote, client, company)
expect(html.match(/What&#39;s included/g)).toHaveLength(4) // section 3 has no contents
})
it('INVOICE keeps the compact billing grid — layout untouched by WS-H', () => {
const html = documentHtml(doc, client, company)
expect(html).toContain('<table class="lines">')
expect(html).not.toContain('class="qsection')
})
})

Loading…
Cancel
Save