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
parent
747026fa3b
commit
828f533a69
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue