feat(d22): P2 — onboarding checklist per project + Projects (Onboarding) board

Per client-module in the Modules tab: a MilestoneChecklist (tick with
date, + Add step), reloading in place. New Onboarding work page: Stats
header + per-milestone board (done/total, pending badge, warn/ok tint)
with click-through to the pending projects for each milestone (client
links to Client 360). Nav + /projects route. typecheck + web build clean,
web tests 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 3c2e51ae6c
commit c5480cf8cd

@ -277,6 +277,39 @@ export const revealModuleSecret = (id: string, key: string): Promise<string> =>
method: 'POST', body: JSON.stringify({ key }),
}).then((r) => r.value)
// ---------- onboarding milestones (D22) ----------
/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number }
/** The project's checklist; the server auto-seeds the standard template on first read. */
export const getMilestones = (clientModuleId: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones)
/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */
export const setMilestone = (
clientModuleId: string, key: string, done: boolean, doneOn?: string,
): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, {
method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }),
}).then((r) => r.milestones)
/** Add a project-specific step beyond the standard template. */
export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, {
method: 'POST', body: JSON.stringify({ label }),
}).then((r) => r.milestones)
/** One row of the onboarding board: a milestone's completion across ALL active projects. */
export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number }
export const getProjectBoard = (): Promise<MilestoneBoardRow[]> =>
apiFetch<{ board: MilestoneBoardRow[] }>('/projects/board').then((r) => r.board)
/** One active project still pending a given milestone — the board's drill-down. */
export interface PendingProject {
clientModuleId: string; clientId: string; clientName: string; moduleCode: string; moduleName: string
}
export const getPendingProjects = (key: string): Promise<PendingProject[]> =>
apiFetch<{ projects: PendingProject[] }>(`/projects/pending/${encodeURIComponent(key)}`).then((r) => r.projects)
// ---------- ticket desk + client branches (D20 APEX parity) ----------
export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped'

@ -22,6 +22,7 @@ import { Pipeline } from './pages/Pipeline'
import { Documents } from './pages/Documents'
import { Reminders } from './pages/Reminders'
import { Tickets } from './pages/Tickets'
import { Projects } from './pages/Projects'
function App() {
return (
@ -32,6 +33,7 @@ function App() {
<Route path="/" element={<Dashboard />} />
<Route path="/pipeline" element={<Pipeline />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/projects" element={<Projects />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />

@ -1,6 +1,6 @@
import {
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, Settings as SettingsIcon,
Upload, UserCog, Users, Wrench,
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks,
Settings as SettingsIcon, Upload, UserCog, Users, Wrench,
type LucideIcon,
} from 'lucide-react'
@ -19,6 +19,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/pipeline', label: 'Pipeline', icon: Filter },
{ to: '/reminders', label: 'Reminders', icon: BellRing },
{ to: '/projects', label: 'Onboarding', icon: ListChecks },
{ to: '/tickets', label: 'Tickets', icon: Wrench },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents', label: 'Documents', icon: Files, end: true },

@ -15,9 +15,10 @@ import {
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage,
getBranches, createBranch, patchBranch, getTickets,
getMilestones, setMilestone, addMilestone,
CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type FieldDef, type Interaction, type Outcome, type RecurringPlan, type ServiceDetail,
type ClientStatus, type FieldDef, type Interaction, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
@ -276,13 +277,15 @@ export function ClientDetail() {
)}
{(cms.data ?? []).length > 0 && <h3 style={{ marginTop: 20 }}>Service data</h3>}
{(cms.data ?? []).map((cm) => (
<Fragment key={cm.id}>
<ServiceDataCard
key={cm.id}
cm={cm}
name={moduleName(cm.moduleId)}
fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []}
onChanged={() => cms.reload()}
/>
<MilestoneChecklist clientModuleId={cm.id} name={moduleName(cm.moduleId)} />
</Fragment>
))}
</>
)}
@ -1033,3 +1036,98 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
</div>
)
}
/**
* Onboarding checklist (D22 per-project detail): the project's milestones as a row
* of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling
* on with no date uses today); "+ Add step" appends a project-specific milestone. Every
* change is one audited POST that returns the fresh list, so the card re-renders in place.
*/
function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId])
const [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('')
const [addBusy, setAddBusy] = useState(false)
const toggle = (m: Milestone, done: boolean, doneOn?: string) => {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload())
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey(''))
}
const addStep = () => {
if (addBusy || label.trim() === '') return
setAddBusy(true)
addMilestone(props.clientModuleId, label.trim())
.then(() => { toast.ok('Step added'); setLabel(''); setAdding(false); list.reload() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setAddBusy(false))
}
const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontWeight: 600 }}>{props.name} onboarding</span>
{ms !== undefined && ms.length > 0 && (
<Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>
)}
</div>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: ms === undefined ? <Skeleton rows={2} />
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{ms.map((m) => (
<div
key={m.key}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
border: '1px solid var(--border)', borderRadius: 6, padding: '4px 10px',
opacity: busyKey === m.key ? 0.6 : 1,
}}
>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox" checked={m.done} disabled={busyKey === m.key}
onChange={(e) => toggle(m, e.target.checked)}
/>
<span style={m.done ? { textDecoration: 'line-through', opacity: 0.7 } : undefined}>{m.label}</span>
</label>
{m.done && (
<>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>on</span>
<input
type="date" className="wf" style={{ width: 140 }} value={m.doneOn ?? ''}
disabled={busyKey === m.key}
onChange={(e) => toggle(m, true, e.target.value !== '' ? e.target.value : undefined)}
/>
</>
)}
</div>
))}
</div>
)}
<Toolbar>
{!adding
? <Button onClick={() => setAdding(true)}>+ Add step</Button>
: (
<>
<input
className="wf" style={{ maxWidth: 240 }} placeholder="Step label" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</Toolbar>
</div>
)
}

@ -0,0 +1,94 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
Badge, Button, DataTable, EmptyState, ErrorState, PageHeader, Skeleton, StatCard, Stats, Toolbar,
} from '@sims/ui'
import { getPendingProjects, getProjectBoard, type MilestoneBoardRow, type PendingProject } from '../api'
import { useData } from './Clients'
/**
* Onboarding board (D22): per-milestone completion across every active project. Each
* row is one checklist step with its done/total and a pending-count badge green when
* the whole book has cleared it, amber while anyone is still behind. Click a row (or
* "View pending") to drill into the projects still waiting on that step, each linking
* back to its client 360°. Per-project ticking lives on the client's Modules tab.
*/
export function Projects() {
const board = useData(getProjectBoard, [])
const [selected, setSelected] = useState<MilestoneBoardRow | undefined>()
const pending = useData<PendingProject[]>(
() => (selected !== undefined ? getPendingProjects(selected.key) : Promise.resolve([])),
[selected?.key],
)
const rows = board.data
const cleared = rows?.filter((r) => r.pending === 0 && r.total > 0).length ?? 0
const attention = rows?.filter((r) => r.pending > 0).length ?? 0
return (
<div className="wf-page">
<PageHeader
title="Onboarding"
desc="Every onboarding step across all active projects — how many have cleared it and who is still pending. Click a step to see the stragglers."
/>
<Stats>
<StatCard label="Steps tracked" value={rows !== undefined ? String(rows.length) : '…'} />
<StatCard label="Fully cleared" value={rows !== undefined ? String(cleared) : '…'} hint="every project done" hintTone="ok" />
<StatCard label="Needs attention" value={rows !== undefined ? String(attention) : '…'} hint="someone still pending" hintTone={attention > 0 ? 'warn' : 'ok'} />
</Stats>
{board.error !== undefined && <ErrorState message={board.error} onRetry={board.reload} />}
{board.error !== undefined ? null
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? <EmptyState>No active projects yet assign a module to a client to start tracking onboarding.</EmptyState> : (
<DataTable
columns={[
{ key: 'step', label: 'Step' },
{ key: 'done', label: 'Done', numeric: true },
{ key: 'pending', label: 'Pending', numeric: true },
{ key: 'act', label: '' },
]}
rowTone={(i) => (rows[i]!.pending > 0 ? 'warn' : 'ok')}
onRowClick={(_row, i) => setSelected(rows[i]!)}
rows={rows.map((r) => ({
step: r.label,
done: `${r.done} / ${r.total}`,
pending: r.pending > 0 ? <Badge tone="warn">{r.pending}</Badge> : <Badge tone="ok">0</Badge>,
act: (
// The <tr> selects on click; stop the button from double-firing.
<span onClick={(e) => e.stopPropagation()}>
<Button onClick={() => setSelected(r)}>View pending</Button>
</span>
),
}))}
/>
)}
{selected !== undefined && (
<>
<Toolbar>
<h3 style={{ margin: 0 }}>Pending: {selected.label}</h3>
<span style={{ flex: 1 }} />
{pending.data !== undefined && <Badge tone={pending.data.length > 0 ? 'warn' : 'ok'}>{pending.data.length} pending</Badge>}
<Button onClick={() => setSelected(undefined)}>Close</Button>
</Toolbar>
{pending.error !== undefined && <ErrorState message={pending.error} onRetry={pending.reload} />}
{pending.error !== undefined ? null
: pending.data === undefined ? <Skeleton rows={4} />
: pending.data.length === 0 ? <EmptyState>Every active project has cleared {selected.label}.</EmptyState> : (
<DataTable
columns={[
{ key: 'client', label: 'Client' },
{ key: 'module', label: 'Module' },
]}
rows={pending.data.map((p) => ({
client: <Link to={`/clients/${p.clientId}`}>{p.clientName}</Link>,
module: <><span className="mono">{p.moduleCode}</span> · {p.moduleName}</>,
}))}
/>
)}
</>
)}
</div>
)
}
Loading…
Cancel
Save