import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' /** * Onboarding tracker (D22). Each client_module (a "project") carries a checklist of * milestones — advance paid, setup, install, go-live, balance, AMC — seeded from the * dated `project.milestone_template` setting. Ticking one stamps the date. Reportable * across the whole book: "who's pending go-live", "who hasn't paid the advance". */ export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number } interface TemplateEntry { key: string; label: string } const FALLBACK_TEMPLATE: TemplateEntry[] = [ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, { key: 'installed', label: 'Installation done' }, { key: 'go_live', label: 'Go-live' }, { key: 'balance_paid', label: 'Balance payment received' }, { key: 'amc_start', label: 'AMC / renewal start' }, ] /** The standard checklist (owner-editable via the setting; fallback to the code default). */ export async function milestoneTemplate(db: DB): Promise { const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) if (row === undefined) return FALLBACK_TEMPLATE try { const parsed = JSON.parse(row.value) as TemplateEntry[] return Array.isArray(parsed) && parsed.length > 0 ? parsed : FALLBACK_TEMPLATE } catch { return FALLBACK_TEMPLATE } } /** * 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 * project's own custom milestones and any ticked state are never touched. */ export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { const template = await milestoneTemplate(db) const existing = new Set( (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) .map((r) => r.key), ) await db.transaction(async () => { let sort = 0 for (const t of template) { if (!existing.has(t.key)) { await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?, ?, ?, ?, 0, NULL, ?)`, uuidv7(), clientModuleId, t.key, t.label, sort, ) } sort++ } }) } export async function listProjectMilestones(db: DB, clientModuleId: string): Promise { const rows = await db.all( `SELECT key, label, done, done_on, sort FROM project_milestone WHERE client_module_id=? ORDER BY sort, label`, clientModuleId, ) return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort })) } /** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */ export async function setMilestone( db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, ): Promise { return db.transaction(async () => { const before = await db.get( `SELECT key, label, done, done_on, sort FROM project_milestone WHERE client_module_id=? AND key=?`, clientModuleId, key, ) if (before === undefined) throw new Error('Milestone not found on this project') if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { throw new Error('doneOn must be YYYY-MM-DD') } const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null await db.run( `UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`, done ? 1 : 0, stamp, clientModuleId, key, ) await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp }) return listProjectMilestones(db, clientModuleId) }) } /** Add a project-specific milestone (beyond the standard template). Audited. */ export async function addProjectMilestone( db: DB, userId: string, clientModuleId: string, label: string, ): Promise { return db.transaction(async () => { if (label.trim() === '') throw new Error('Milestone label is required') const key = `custom_${uuidv7().slice(0, 8)}` const maxSort = (await db.get<{ m: number }>( `SELECT COALESCE(MAX(sort), -1) AS m FROM project_milestone WHERE client_module_id=?`, clientModuleId, ))!.m await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?, ?, ?, ?, 0, NULL, ?)`, uuidv7(), clientModuleId, key, label.trim(), maxSort + 1, ) await writeAudit(db, userId, 'add_milestone', 'client_module', clientModuleId, undefined, { key, label: label.trim() }) return listProjectMilestones(db, clientModuleId) }) } // ---------- cross-project reporting (the board) ---------- export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number } /** * Per-milestone completion across ALL active projects — the onboarding board. Ordered by * the standard template; ad-hoc custom milestones are aggregated too, after the standard set. */ export async function milestoneBoard(db: DB): Promise { const rows = await db.all<{ key: string; label: string; total: number; done: number; minsort: number }>( `SELECT pm.key, MIN(pm.label) AS label, COUNT(*) AS total, COALESCE(SUM(pm.done), 0) AS done, MIN(pm.sort) AS minsort FROM project_milestone pm JOIN client_module cm ON cm.id = pm.client_module_id WHERE cm.active = 1 GROUP BY pm.key ORDER BY minsort, label`, ) return rows.map((r) => ({ key: r.key, label: r.label, total: r.total, done: r.done, pending: r.total - r.done })) } export interface PendingProject { clientModuleId: string; clientId: string; clientName: string; moduleCode: string; moduleName: string } /** Active projects where the given milestone is NOT yet done — the drill-down. */ export async function projectsPendingMilestone(db: DB, key: string): Promise { return db.all( `SELECT pm.client_module_id AS "clientModuleId", cm.client_id AS "clientId", c.name AS "clientName", m.code AS "moduleCode", m.name AS "moduleName" FROM project_milestone pm JOIN client_module cm ON cm.id = pm.client_module_id JOIN client c ON c.id = cm.client_id JOIN module m ON m.id = cm.module_id WHERE cm.active = 1 AND pm.key = ? AND pm.done = 0 ORDER BY c.name`, key, ) }