You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
125 lines
7.0 KiB
TypeScript
125 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|