diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index f78bfe5..5562f66 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -328,6 +328,20 @@ export const addPrice = (moduleId: string, body: Record): Promi apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.prices) +// Onboarding step templates (owner editor): shared "front" (every module) + per-module "tail". +export interface TemplateStep { key: string; label: string } +export const getFrontTemplate = (): Promise => + apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front').then((r) => r.steps) +export const setFrontTemplate = (steps: TemplateStep[]): Promise => + apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front', { method: 'PUT', body: JSON.stringify({ steps }) }) + .then((r) => r.steps) +export const getModuleTemplate = (code: string): Promise => + apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`).then((r) => r.steps) +export const setModuleTemplate = (code: string, steps: TemplateStep[]): Promise => + apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`, { + method: 'PUT', body: JSON.stringify({ steps }), + }).then((r) => r.steps) + export const getClientModules = (clientId: string): Promise => apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules) export const assignClientModule = (clientId: string, body: Record): Promise => diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index 631e8b2..38c90a7 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -5,11 +5,11 @@ import { FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast, } from '@sims/ui' import { - addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, - getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, - previewSample, role, + addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, getFrontTemplate, + getModuleClients, getModuleTemplate, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, + previewSample, role, setFrontTemplate, setModuleTemplate, CLIENT_MODULE_STATUSES, KIND_LABEL, - type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, + type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, type TemplateStep, } from '../api' import { LivePreview } from '../components/LivePreview' import { useData } from './Clients' @@ -121,6 +121,7 @@ export function Modules() { onCreated={() => { setCreating(false); modules.reload() }} /> )} + {isOwner && modules.data !== undefined && } ) } @@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod ) } + +/** + * Owner-only editor for the COMPOSED onboarding checklist template: one shared "front" (same + * lead-in steps for every module) plus each module's own "tail". Both are config (`setting` + * rows) resolved by `milestoneTemplate` — this just edits the two halves. Staged local edits; + * nothing is written until Save. + */ +function OnboardingTemplatesSection(props: { modules: Module[] }) { + const front = useData(getFrontTemplate, []) + const [expanded, setExpanded] = useState() + + return ( +
+

Onboarding steps

+
+ Every project's onboarding checklist is this shared front followed + by that module's own tail below. Changes reach existing projects + additively — new steps appear on projects already in progress, and any step already ticked stays ticked as-is. + Re-ordering or renaming steps that existing projects already have doesn't retro-fit those projects — that needs + the onboarding-template migration script. +
+ +

Shared front — applies to every module

+ {front.error !== undefined ? + : front.data === undefined ? : ( +
+ setFrontTemplate(steps).then((saved) => { front.reload(); return saved })} + /> +
+ )} + +

Per-module tail

+
+ {props.modules.map((m) => ( +
+
+ {m.name} {m.code} + +
+ {expanded === m.id && } +
+ ))} +
+
+ ) +} + +/** Lazily loads and edits one module's tail — only fetched once its row is expanded. */ +function ModuleTailEditor(props: { code: string }) { + const tail = useData(() => getModuleTemplate(props.code), [props.code]) + return ( +
+ {tail.error !== undefined ? + : tail.data === undefined ? : ( + setModuleTemplate(props.code, steps).then((saved) => { tail.reload(); return saved })} + /> + )} +
+ ) +} + +/** + * One editable ordered list of template steps — label-only rows (the key is auto-derived + * server-side from the label when blank); add / remove / reorder, staged locally until Save. + * Shared by the front editor and every per-module tail editor above. + */ +function StepListEditor(props: { steps: TemplateStep[]; onSave: (steps: TemplateStep[]) => Promise }) { + const toast = useToast() + const [rows, setRows] = useState(() => props.steps.map((s) => ({ ...s }))) + const [dirty, setDirty] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + useEffect(() => { setRows(props.steps.map((s) => ({ ...s }))); setDirty(false) }, [props.steps]) + + const setLabel = (i: number, label: string) => { + setRows((rs) => rs.map((r, j) => (j === i ? { ...r, label } : r))) + setDirty(true) + } + const addRow = () => { setRows((rs) => [...rs, { key: '', label: '' }]); setDirty(true) } + const removeRow = (i: number) => { setRows((rs) => rs.filter((_r, j) => j !== i)); setDirty(true) } + const move = (i: number, dir: -1 | 1) => { + const j = i + dir + if (j < 0 || j >= rows.length) return + setRows((rs) => { const next = [...rs]; [next[i], next[j]] = [next[j]!, next[i]!]; return next }) + setDirty(true) + } + const save = () => { + if (busy) return + if (rows.length === 0) { setError('Add at least one step'); return } + if (rows.some((r) => r.label.trim() === '')) { setError('Every step needs a label'); return } + setError(undefined); setBusy(true) + props.onSave(rows.map((r) => ({ key: r.key, label: r.label.trim() }))) + .then((saved) => { setRows(saved.map((s) => ({ ...s }))); setDirty(false); toast.ok('Onboarding steps saved') }) + .catch((e: Error) => { setError(e.message); toast.err(e.message) }) + .finally(() => setBusy(false)) + } + + return ( +
+ {rows.map((r, i) => ( +
+ setLabel(i, e.target.value)} /> + + + +
+ ))} + + + + + {error !== undefined && {error}} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 5d179d2..283ff24 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -69,8 +69,9 @@ import { clearLegacyCiphertext } from './migrate-plaintext' import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { - addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, - projectsPendingMilestone, setMilestone, stalledProjects, + addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail, + listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone, + setModuleTail, stalledProjects, } from './repos-milestones' import { commitImport, stageCsv, verificationReport } from './import-apex' import { documentHtml, documentHtmlSample } from './templates' @@ -481,6 +482,29 @@ export function apiRouter( } }) + // ---------- onboarding step templates (owner editor): shared front + per-module tail ---------- + r.get('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => { + try { + const code = String(req.params['code'] ?? '') + const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code) + if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return } + res.json({ ok: true, steps: await getModuleTail(db, code) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => { + try { + const code = String(req.params['code'] ?? '') + const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code) + if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return } + const body = req.body as { steps?: unknown } + res.json({ ok: true, steps: await setModuleTail(db, staffId(res), code, body.steps) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- module → client roster (spec §10, D-ROSTER) ---------- // Module directory (D28): every client on a module with its service fields, for review. r.get('/modules/:id/directory', requireAuth, async (req, res) => { @@ -764,6 +788,23 @@ export function apiRouter( res.json({ ok: true, projects: await stalledProjects(db, days, today) }) }) + // The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor). + r.get('/onboarding-template/front', requireAuth, requireOwner, async (_req, res) => { + try { + res.json({ ok: true, steps: await getFrontTemplate(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/onboarding-template/front', requireAuth, requireOwner, async (req, res) => { + try { + const body = req.body as { steps?: unknown } + res.json({ ok: true, steps: await setFrontTemplate(db, staffId(res), body.steps) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- client branches (D20) ---------- r.get('/clients/:id/branches', requireAuth, async (req, res) => { try { diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 931d23b..be5303d 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -1,5 +1,6 @@ import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' +import { setSetting } from './repos-reminders' import type { DB } from './db' /** @@ -62,18 +63,19 @@ function parseTemplate(value: string): TemplateEntry[] | undefined { } catch { return undefined } } -/** - * The checklist for a module: a shared "front" (same for every module — enquiry through - * quotation-approved) followed by a per-module "tail" (owner-editable per module via - * setting; global default; code fallback). Composed as [...front, ...tail]. - */ -export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { +/** The shared "front" alone — setting row, else the code fallback. */ +export async function getFrontTemplate(db: DB): Promise { const frontRow = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key='project.milestone_template.front'`) - const front = (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK + return (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK +} +/** One module's "tail" alone (not composed with the front): its own per-module setting, + * else the global tail setting, else the code fallback. `moduleCode` may be '' (no module + * context) — resolves straight to the global/fallback tail. */ +export async function getModuleTail(db: DB, moduleCode: string): Promise { let tail: TemplateEntry[] | undefined - if (moduleCode !== undefined && moduleCode !== '') { + if (moduleCode !== '') { const perModule = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) tail = perModule !== undefined ? parseTemplate(perModule.value) : undefined @@ -82,11 +84,67 @@ export async function milestoneTemplate(db: DB, moduleCode?: string): Promise(`SELECT value FROM setting WHERE key='project.milestone_template'`) tail = row !== undefined ? parseTemplate(row.value) : undefined } - tail ??= TAIL_FALLBACK + return tail ?? TAIL_FALLBACK +} +/** + * The checklist for a module: a shared "front" (same for every module — enquiry through + * quotation-approved) followed by a per-module "tail" (owner-editable per module via + * setting; global default; code fallback). Composed as [...front, ...tail]. + */ +export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { + const front = await getFrontTemplate(db) + const tail = await getModuleTail(db, moduleCode ?? '') return [...front, ...tail] } +/** Make a valid step key from a label: lowercase, non-alphanumeric -> `_`, collapse repeats, trim `_`. */ +function slugifyStepLabel(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '') +} + +/** + * Validate + normalize a template-editor PUT body's `steps` into `TemplateEntry[]`: a + * non-empty array, every label non-blank after trim, every key unique. A missing/blank key + * is derived by slugifying its label (bumping a numeric suffix on collision). Throws with a + * clear message otherwise — the caller turns that into a 400. + */ +export function normalizeTemplateSteps(rawSteps: unknown): TemplateEntry[] { + if (!Array.isArray(rawSteps) || rawSteps.length === 0) throw new Error('Provide at least one step') + const used = new Set() + const out: TemplateEntry[] = [] + for (const raw of rawSteps) { + const r = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown } + const label = typeof r.label === 'string' ? r.label.trim() : '' + if (label === '') throw new Error('Every step needs a non-blank label') + let key = typeof r.key === 'string' ? r.key.trim() : '' + if (key === '') { + const base = slugifyStepLabel(label) || 'step' + key = base + let n = 2 + while (used.has(key)) key = `${base}_${n++}` + } + if (used.has(key)) throw new Error(`Duplicate step key: "${key}"`) + used.add(key) + out.push({ key, label }) + } + return out +} + +/** Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`. */ +export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise { + const steps = normalizeTemplateSteps(rawSteps) + await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps)) + return steps +} + +/** Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`. */ +export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise { + const steps = normalizeTemplateSteps(rawSteps) + await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps)) + return steps +} + /** * Idempotently materialize the template's milestones for a project. New template rows are * added on later calls (so extending the standard set reaches existing projects); a