feat(onboarding): owner editor for shared front + per-module tail templates

Adds owner-only, audited routes for the two config settings milestoneTemplate
composes (project.milestone_template.front and project.milestone_template:<CODE>),
plus a Modules page section to edit them: staged label rows with add/remove/
reorder/Save, keys auto-derived from labels when blank, uniqueness enforced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 1 day ago
parent 45cfb83d03
commit 4e747427d2

@ -328,6 +328,20 @@ export const addPrice = (moduleId: string, body: Record<string, unknown>): 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<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front').then((r) => r.steps)
export const setFrontTemplate = (steps: TemplateStep[]): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front', { method: 'PUT', body: JSON.stringify({ steps }) })
.then((r) => r.steps)
export const getModuleTemplate = (code: string): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`).then((r) => r.steps)
export const setModuleTemplate = (code: string, steps: TemplateStep[]): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`, {
method: 'PUT', body: JSON.stringify({ steps }),
}).then((r) => r.steps)
export const getClientModules = (clientId: string): Promise<ClientModule[]> =>
apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules)
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>

@ -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 && <OnboardingTemplatesSection modules={modules.data} />}
</div>
)
}
@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod
</Dialog>
)
}
/**
* 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<string | undefined>()
return (
<section style={{ marginTop: 28 }}>
<h3>Onboarding steps</h3>
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 14, fontSize: 13, color: 'var(--text-dim)', lineHeight: 1.5 }}>
Every project's onboarding checklist is this shared <strong style={{ color: 'var(--text)' }}>front</strong> followed
by that module's own <strong style={{ color: 'var(--text)' }}>tail</strong> 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.
</div>
<h4>Shared front <span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-dim)' }}> applies to every module</span></h4>
{front.error !== undefined ? <ErrorState message={front.error} onRetry={front.reload} />
: front.data === undefined ? <Skeleton rows={3} /> : (
<div className="wf-card" style={{ padding: 14, marginBottom: 20 }}>
<StepListEditor
steps={front.data}
onSave={(steps) => setFrontTemplate(steps).then((saved) => { front.reload(); return saved })}
/>
</div>
)}
<h4>Per-module tail</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{props.modules.map((m) => (
<div key={m.id} className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span><strong>{m.name}</strong> <span className="mono" style={{ fontSize: 12, color: 'var(--text-dim)' }}>{m.code}</span></span>
<Button onClick={() => setExpanded((v) => (v === m.id ? undefined : m.id))}>
{expanded === m.id ? 'Close' : 'Edit onboarding steps'}
</Button>
</div>
{expanded === m.id && <ModuleTailEditor code={m.code} />}
</div>
))}
</div>
</section>
)
}
/** 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 (
<div style={{ marginTop: 10 }}>
{tail.error !== undefined ? <ErrorState message={tail.error} onRetry={tail.reload} />
: tail.data === undefined ? <Skeleton rows={3} /> : (
<StepListEditor
steps={tail.data}
onSave={(steps) => setModuleTemplate(props.code, steps).then((saved) => { tail.reload(); return saved })}
/>
)}
</div>
)
}
/**
* 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<TemplateStep[]> }) {
const toast = useToast()
const [rows, setRows] = useState<TemplateStep[]>(() => props.steps.map((s) => ({ ...s })))
const [dirty, setDirty] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | undefined>()
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input className="wf" style={{ flex: 1 }} placeholder="Step label…"
value={r.label} onChange={(e) => setLabel(i, e.target.value)} />
<Button disabled={busy || i === 0} aria-label="Move step up" onClick={() => move(i, -1)}></Button>
<Button disabled={busy || i === rows.length - 1} aria-label="Move step down" onClick={() => move(i, 1)}></Button>
<Button tone="danger" disabled={busy} aria-label="Remove step" onClick={() => removeRow(i)}></Button>
</div>
))}
<Toolbar>
<Button disabled={busy} onClick={addRow}>+ Add step</Button>
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}

@ -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 {

@ -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<TemplateEntry[]> {
/** The shared "front" alone — setting row, else the code fallback. */
export async function getFrontTemplate(db: DB): Promise<TemplateEntry[]> {
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<TemplateEntry[]> {
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<Te
const row = await db.get<{ value: string }>(`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<TemplateEntry[]> {
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<string>()
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<TemplateEntry[]> {
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<TemplateEntry[]> {
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

Loading…
Cancel
Save