You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-milestones.ts

155 lines
6.7 KiB
TypeScript

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<TemplateEntry[]> {
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<void> {
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<Milestone[]> {
const rows = await db.all<MilestoneRow>(
`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<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`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<Milestone[]> {
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<MilestoneBoardRow[]> {
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<PendingProject[]> {
return db.all<PendingProject>(
`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,
)
}