feat(hq-web): Document Template page — editable letterhead data + live preview
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
08e067c7cc
commit
b65e03e1c4
@ -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…
Reference in New Issue