diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index d3f44a9..5d179d2 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -70,7 +70,7 @@ import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, - projectsPendingMilestone, setMilestone, + projectsPendingMilestone, setMilestone, stalledProjects, } from './repos-milestones' import { commitImport, stageCsv, verificationReport } from './import-apex' import { documentHtml, documentHtmlSample } from './templates' @@ -755,6 +755,14 @@ export function apiRouter( r.get('/projects/pending/:key', requireAuth, async (req, res) => { res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) }) }) + // Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile). + r.get('/projects/stalled', requireAuth, async (req, res) => { + const q = req.query['days'] + const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q)) + ? Number(q) : await getNumberSetting(db, 'project.stall_days', 30) + const today = new Date().toISOString().slice(0, 10) + res.json({ ok: true, projects: await stalledProjects(db, days, today) }) + }) // ---------- client branches (D20) ---------- r.get('/clients/:id/branches', requireAuth, async (req, res) => { diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 93e5e52..4623571 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -236,3 +236,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise { + const rows = await db.all( + `SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", + MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(m.name) AS "moduleName", + MAX(pm.done_on) AS last_done + 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 cm.status NOT IN ('live','expired','cancelled') + GROUP BY pm.client_module_id + HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, + ) + const cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10) + return rows + .filter((r) => (r.last_done ?? '0000-00-00') < cutoff) + .map(({ last_done: _last_done, ...rest }) => rest) +} diff --git a/apps/hq/test/stalled-onboarding.test.ts b/apps/hq/test/stalled-onboarding.test.ts new file mode 100644 index 0000000..ee8eb08 --- /dev/null +++ b/apps/hq/test/stalled-onboarding.test.ts @@ -0,0 +1,57 @@ +// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile). +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule } from '../src/repos-modules' +import { setMilestone, stalledProjects } from '../src/repos-milestones' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + // A module code with no per-module milestone template seeded (D22 seed.ts only carries + // SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done, + // installed, go_live, balance_paid, amc_start. + const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' }) + return { db, c, m } +} + +describe('stalledProjects (D22 — dashboard/MIS tile)', () => { + it('lists a project parked past the threshold', async () => { + const { db, c, m } = await world() + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + // Early steps done on an old date; later steps (incl. go_live) stay pending — project is + // still active (status stays 'quoted'/'installed', never 'live'). + await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') + await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') + // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked. + const stalled = await stalledProjects(db, 30, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id) + const row = stalled.find((p) => p.clientModuleId === cm.id)! + expect(row.clientName).toBe('Kothavara SCB') + expect(row.moduleCode).toBe('MISC') + }) + + it('excludes a recently-progressed project', async () => { + const { db, c, m } = await world() + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') + await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') + const stalled = await stalledProjects(db, 365, '2026-07-19') + expect(stalled).toHaveLength(0) + }) + + it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => { + const { db, c, m } = await world() + // Fully ticked project: nothing pending, so it should never appear regardless of age. + const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) { + await setMilestone(db, 'u1', done.id, key, true, '2026-01-01') + } + const stalled = await stalledProjects(db, 1, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id) + // 'live' status (from ticking go_live) also excludes it, which the loop above proves. + expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live') + }) +})