From 828f533a69d73229518a983a48328592df549661 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 14:13:08 +0530 Subject: [PATCH] feat(templates): section-per-module quotation PDF (Phase 9, D18 WS-H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/hq-web/src/app.css | 11 + apps/hq-web/src/main.tsx | 5 +- apps/hq-web/src/nav.ts | 4 +- apps/hq-web/src/pages/DocumentTemplate.tsx | 20 +- apps/hq-web/src/pages/Settings.tsx | 386 +++++++++++++++++++++ apps/hq/src/templates.ts | 42 ++- apps/hq/test/templates.test.ts | 40 +++ 7 files changed, 494 insertions(+), 14 deletions(-) create mode 100644 apps/hq-web/src/pages/Settings.tsx diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 4ab0136..e6dfd22 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -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; } } diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 40ca557..e87f034 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -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() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 31fbd59..22cef8f 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -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 }, ], }, ] diff --git a/apps/hq-web/src/pages/DocumentTemplate.tsx b/apps/hq-web/src/pages/DocumentTemplate.tsx index 1d3e0b9..4cb397c 100644 --- a/apps/hq-web/src/pages/DocumentTemplate.tsx +++ b/apps/hq-web/src/pages/DocumentTemplate.tsx @@ -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 -/** 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({}) const [template, setTemplate] = useState({}) const [titles, setTitles] = useState({}) @@ -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 - const save = () => { setSaving(true); setMsg(undefined) putTemplateSettings({ company, template, titles }) @@ -122,3 +120,9 @@ export function DocumentTemplate() { ) } + +/** 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 +} diff --git a/apps/hq-web/src/pages/Settings.tsx b/apps/hq-web/src/pages/Settings.tsx new file mode 100644 index 0000000..4b22c3a --- /dev/null +++ b/apps/hq-web/src/pages/Settings.tsx @@ -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 +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 ( +
+ +
+ +
+ {active === 'company' && } + {active === 'template' && } + {active === 'reminders' && } + {active === 'sharing' && } + {active === 'integrations' && } + {active === 'appearance' && } +
+
+
+ ) +} + +/** 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({}) + 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 ( +
+

Company

+

Printed on every quotation, proforma, invoice and credit note.

+ {!loaded ? : ( + <> + + {COMPANY_FIELDS.map((x) => ( + + {x.area + ?