From c5480cf8cd8ae3e3a1ceb96b5df71a7fd75af670 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 22:39:34 +0530 Subject: [PATCH] =?UTF-8?q?feat(d22):=20P2=20=E2=80=94=20onboarding=20chec?= =?UTF-8?q?klist=20per=20project=20+=20Projects=20(Onboarding)=20board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/api.ts | 33 +++++++ apps/hq-web/src/main.tsx | 2 + apps/hq-web/src/nav.ts | 5 +- apps/hq-web/src/pages/ClientDetail.tsx | 114 +++++++++++++++++++++++-- apps/hq-web/src/pages/Projects.tsx | 94 ++++++++++++++++++++ 5 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 apps/hq-web/src/pages/Projects.tsx diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 7aff614..9e96354 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -277,6 +277,39 @@ export const revealModuleSecret = (id: string, key: string): Promise => 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 => + 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 => + 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 => + 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 => + 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 => + 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' diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index f84e783..acb69f5 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 549260b..f98a45e 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -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 }, diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 8567f45..bff60fd 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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 &&

Service data

} {(cms.data ?? []).map((cm) => ( - m.id === cm.moduleId)?.fieldSpec ?? []} - onChanged={() => cms.reload()} - /> + + m.id === cm.moduleId)?.fieldSpec ?? []} + onChanged={() => cms.reload()} + /> + + ))} )} @@ -1033,3 +1036,98 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo ) } + +/** + * 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 ( +
+
+ {props.name} — onboarding + {ms !== undefined && ms.length > 0 && ( + {done}/{ms.length} + )} +
+ {list.error !== undefined ? + : ms === undefined ? + : ms.length === 0 ? No steps yet. : ( +
+ {ms.map((m) => ( +
+ + {m.done && ( + <> + on + toggle(m, true, e.target.value !== '' ? e.target.value : undefined)} + /> + + )} +
+ ))} +
+ )} + + {!adding + ? + : ( + <> + setLabel(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} + /> + + + + )} + +
+ ) +} diff --git a/apps/hq-web/src/pages/Projects.tsx b/apps/hq-web/src/pages/Projects.tsx new file mode 100644 index 0000000..c21758b --- /dev/null +++ b/apps/hq-web/src/pages/Projects.tsx @@ -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() + const pending = useData( + () => (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 ( +
+ + + + + 0 ? 'warn' : 'ok'} /> + + + {board.error !== undefined && } + {board.error !== undefined ? null + : rows === undefined ? + : rows.length === 0 ? No active projects yet — assign a module to a client to start tracking onboarding. : ( + (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 ? {r.pending} : 0, + act: ( + // The selects on click; stop the button from double-firing. + e.stopPropagation()}> + + + ), + }))} + /> + )} + + {selected !== undefined && ( + <> + +

Pending: {selected.label}

+ + {pending.data !== undefined && 0 ? 'warn' : 'ok'}>{pending.data.length} pending} + +
+ {pending.error !== undefined && } + {pending.error !== undefined ? null + : pending.data === undefined ? + : pending.data.length === 0 ? Every active project has cleared “{selected.label}”. : ( + ({ + client: {p.clientName}, + module: <>{p.moduleCode} · {p.moduleName}, + }))} + /> + )} + + )} +
+ ) +}