feat(hq-web): Document Template page — editable letterhead data + live preview

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

@ -25,8 +25,8 @@ export function Layout() {
if (!hasSession()) return <Navigate to="/login" replace /> if (!hasSession()) return <Navigate to="/login" replace />
// Company (the company.* settings editor) is owner-only — staff never see the item. // Document Template (company.* + template.* letterhead data) is owner-only — staff never see the item.
const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/company', label: 'Company' }] : BASE_NAV const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/template', label: 'Document Template' }] : BASE_NAV
return ( return (
<div className="hq-shell"> <div className="hq-shell">

@ -325,6 +325,15 @@ export const getCompanyProfile = (): Promise<Record<string, string>> =>
export const putCompanyProfile = (body: Record<string, string>): Promise<Record<string, string>> => export const putCompanyProfile = (body: Record<string, string>): Promise<Record<string, string>> =>
apiFetch<{ company: Record<string, string> }>('/settings/company', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.company) apiFetch<{ company: Record<string, string> }>('/settings/company', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.company)
export const getTemplateSettings = (): Promise<Record<string, string>> =>
apiFetch<{ settings: Record<string, string> }>('/settings/template').then((r) => r.settings)
export const putTemplateSettings = (
body: { company?: Record<string, string>; template?: Record<string, string>; titles?: Record<string, string> },
): Promise<Record<string, string>> =>
apiFetch<{ settings: Record<string, string> }>('/settings/template', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.settings)
export const uploadTemplateLogo = (dataUri: string): Promise<string> =>
apiFetch<{ logo: string }>('/settings/template/logo', { method: 'POST', body: JSON.stringify({ dataUri }) }).then((r) => r.logo)
/** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */ /** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */
export async function fetchPdfBlob(documentId: string): Promise<Blob> { export async function fetchPdfBlob(documentId: string): Promise<Blob> {
const token = localStorage.getItem(TOKEN_KEY) ?? '' const token = localStorage.getItem(TOKEN_KEY) ?? ''

@ -12,7 +12,7 @@ import { Clients } from './pages/Clients'
import { ClientDetail } from './pages/ClientDetail' import { ClientDetail } from './pages/ClientDetail'
import { Modules } from './pages/Modules' import { Modules } from './pages/Modules'
import { Reports } from './pages/Reports' import { Reports } from './pages/Reports'
import { CompanyProfile } from './pages/CompanyProfile' import { DocumentTemplate } from './pages/DocumentTemplate'
import { NewDocument } from './pages/NewDocument' import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView' import { DocumentView } from './pages/DocumentView'
@ -27,7 +27,7 @@ function App() {
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} /> <Route path="/modules" element={<Modules />} />
<Route path="/reports" element={<Reports />} /> <Route path="/reports" element={<Reports />} />
<Route path="/settings/company" element={<CompanyProfile />} /> <Route path="/settings/template" element={<DocumentTemplate />} />
<Route path="/documents/new" element={<NewDocument />} /> <Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} /> <Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

@ -1,54 +0,0 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { Button, Field, Notice, PageHeader } from '@sims/ui'
import { getCompanyProfile, putCompanyProfile, role } from '../api'
const FIELDS: { key: string; setting: string; label: string }[] = [
{ key: 'name', setting: 'company.name', label: 'Company name' },
{ key: 'address', setting: 'company.address', label: 'Address' },
{ key: 'gstin', setting: 'company.gstin', label: 'GSTIN' },
{ key: 'stateCode', setting: 'company.state_code', label: 'State code' },
{ key: 'phone', setting: 'company.phone', label: 'Phone' },
{ key: 'email', setting: 'company.email', label: 'Email' },
{ key: 'bank', setting: 'company.bank', label: 'Bank details' },
]
/** Owner-only editor for the company.* settings the letterhead PDFs read. */
export function CompanyProfile() {
const [f, setF] = useState<Record<string, string>>({})
const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
getCompanyProfile()
.then((c) => setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))))
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
}, [])
if (role() !== 'owner') return <Navigate to="/" replace />
const save = () => {
setSaving(true); setMsg(undefined)
putCompanyProfile(f)
.then((c) => { setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))); setMsg({ tone: 'ok', text: 'Saved. PDFs use the new details immediately.' }) })
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
.finally(() => setSaving(false))
}
return (
<div className="wf-page">
<PageHeader title="Company profile" desc="These details appear on every quotation, invoice, and receipt." />
{msg !== undefined && <Notice tone={msg.tone}>{msg.text}</Notice>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 520, marginTop: 10 }}>
{FIELDS.map((x) => (
<Field key={x.key} label={x.label}>
{x.key === 'address' || x.key === 'bank'
? <textarea className="wf" rows={2} style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />
: <input className="wf" style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />}
</Field>
))}
<div><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save company profile'}</Button></div>
</div>
</div>
)
}

@ -0,0 +1,124 @@
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 { LivePreview } from '../components/LivePreview'
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 },
{ key: 'stateCode', setting: 'company.state_code', label: 'State code', area: false },
{ key: 'phone', setting: 'company.phone', label: 'Phone', area: false },
{ key: 'email', setting: 'company.email', label: 'Email', area: false },
{ key: 'bank', setting: 'company.bank', label: 'Bank details', area: true },
]
const TEMPLATE_FIELDS: { key: string; setting: string; label: string; area: boolean }[] = [
{ key: 'terms', setting: 'template.terms', label: 'Default terms', area: true },
{ key: 'declaration', setting: 'template.declaration', label: 'GST declaration', area: true },
{ key: 'jurisdiction', setting: 'template.jurisdiction', label: 'Jurisdiction line', area: false },
{ key: 'footerNote', setting: 'template.footer_note', label: 'Footer note', area: false },
{ key: 'signatoryLabel', setting: 'template.signatory_label', label: 'Signatory label', area: false },
]
const DOC_TITLES: { type: string; setting: string; label: string }[] = [
{ type: 'INVOICE', setting: 'template.title_INVOICE', label: 'Invoice title' },
{ type: 'QUOTATION', setting: 'template.title_QUOTATION', label: 'Quotation title' },
{ type: 'PROFORMA', setting: 'template.title_PROFORMA', label: 'Proforma title' },
]
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() {
const [company, setCompany] = useState<Dict>({})
const [template, setTemplate] = useState<Dict>({})
const [titles, setTitles] = useState<Dict>({})
const [logo, setLogo] = useState('')
const [commitNonce, setCommitNonce] = useState(0)
const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
const [saving, setSaving] = useState(false)
const load = (s: Dict): void => {
setCompany(Object.fromEntries(COMPANY_FIELDS.map((x) => [x.key, s[x.setting] ?? ''])))
setTemplate(Object.fromEntries(TEMPLATE_FIELDS.map((x) => [x.key, s[x.setting] ?? ''])))
setTitles(Object.fromEntries(DOC_TITLES.map((x) => [x.type, s[x.setting] ?? ''])))
setLogo(s['template.logo'] ?? '')
}
useEffect(() => { getTemplateSettings().then(load).catch((e: Error) => setMsg({ tone: 'err', text: e.message })) }, [])
// Logo excluded from the body — it is saved on pick and the preview reads it back.
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 })
.then((s) => { load(s); setMsg({ tone: 'ok', text: 'Saved. Every document uses the new template immediately.' }) })
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
.finally(() => setSaving(false))
}
const onLogo = (file: File | undefined) => {
if (file === undefined) return
const reader = new FileReader()
reader.onload = () => {
uploadTemplateLogo(String(reader.result))
.then((saved) => { setLogo(saved); commit(); setMsg({ tone: 'ok', text: 'Logo saved.' }) })
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
}
reader.readAsDataURL(file)
}
const textField = (dict: Dict, setDict: (u: (p: Dict) => Dict) => void) =>
(x: { key: string; label: string; area: boolean }) => (
<Field key={x.key} label={x.label}>
{x.area
? <textarea className="wf" rows={2} style={{ width: '100%' }} value={dict[x.key] ?? ''} onChange={(e) => setDict((p) => ({ ...p, [x.key]: e.target.value }))} onBlur={commit} />
: <input className="wf" style={{ width: '100%' }} value={dict[x.key] ?? ''} onChange={(e) => setDict((p) => ({ ...p, [x.key]: e.target.value }))} onBlur={commit} />}
</Field>
)
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div className="wf-page" style={{ flex: '0 0 520px', minWidth: 0, overflow: 'auto', height: 'calc(100vh - 140px)' }}>
<PageHeader title="Document template" desc="Everything the letterhead prints — one fixed layout, all text editable. Changes apply to every document immediately." />
{msg !== undefined && <Notice tone={msg.tone}>{msg.text}</Notice>}
<h3>Company</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{COMPANY_FIELDS.map(textField(company, setCompany))}</div>
<h3 style={{ marginTop: 18 }}>Logo</h3>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input type="file" accept="image/*" onChange={(e) => onLogo(e.target.files?.[0])} />
{logo !== '' && <><img src={logo} alt="logo" style={{ maxHeight: 40 }} /><Button onClick={() => { uploadTemplateLogo('').then(() => { setLogo(''); commit() }).catch(() => { /* ignore */ }) }}>Remove</Button></>}
</div>
<h3 style={{ marginTop: 18 }}>Boilerplate text</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{TEMPLATE_FIELDS.map(textField(template, setTemplate))}</div>
<h3 style={{ marginTop: 18 }}>Document titles (optional overrides)</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{DOC_TITLES.map((t) => (
<Field key={t.type} label={t.label}>
<input className="wf" style={{ width: '100%' }} placeholder="built-in default" value={titles[t.type] ?? ''} onChange={(e) => setTitles((p) => ({ ...p, [t.type]: e.target.value }))} onBlur={commit} />
</Field>
))}
</div>
<div style={{ marginTop: 14 }}><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save template'}</Button></div>
</div>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewSample(JSON.parse(b), signal).then((r) => ({ html: r.html }))}
/>
</div>
</div>
</div>
)
}
Loading…
Cancel
Save