From eaecaedd3f44d5243562939d605492b87fcf0328 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 02:39:22 +0530 Subject: [PATCH] =?UTF-8?q?feat(pipeline):=20cross-client=20chase-list=20?= =?UTF-8?q?=E2=80=94=20derived=20stages,=20schedule-driven=20bands,=20role?= =?UTF-8?q?-gated,=20paginated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 (D-PIPE option B, spec §6b/§9): repos-pipeline.ts listPipeline unions the latest non-cancelled QUOTATION per client with bare leads (Enquiry); stage is never stored; age anchors on the first 'sent' document_event; next action + colour band read resolveSchedule('quote_followup') day_offsets (no hardcoded 3/7/14); staff are server-forced to their own rows via ownerScope; Lost hidden unless filtered; sorted actionable-oldest-first with LIMIT/OFFSET + honest total. GET /pipeline route plus the Pipeline page (filter chips, managerial owner dropdown, band row tones, lifecycle action buttons, pager) wired into nav right after Dashboard. Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/api.ts | 34 ++++ apps/hq-web/src/main.tsx | 2 + apps/hq-web/src/nav.ts | 5 +- apps/hq-web/src/pages/Pipeline.tsx | 177 +++++++++++++++++ apps/hq/src/api.ts | 23 +++ apps/hq/src/repos-pipeline.ts | 180 ++++++++++++++++++ apps/hq/test/pipeline.test.ts | 293 +++++++++++++++++++++++++++++ 7 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 apps/hq-web/src/pages/Pipeline.tsx create mode 100644 apps/hq/src/repos-pipeline.ts create mode 100644 apps/hq/test/pipeline.test.ts diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index e33fd93..7e251ba 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -250,6 +250,40 @@ export const resetEmployeePassword = (id: string, password: string): Promise r.employee) +// ---------- pipeline chase-list (D-PIPE; spec §6b/§9) ---------- + +export type PipelineStage = 'enquiry' | 'new_project' | 'quoted_waiting' | 'won' | 'lost' +export type PipelineBand = 'green' | 'amber' | 'red' +export type PipelineNextAction = + | 'send_quote' | 'waiting' | 'chase' | 'nudge' | 'final_nudge' | 'convert_invoice' | 'none' +export type PipelineFilter = 'all' | 'mine' | 'overdue' | 'lost' + +export interface PipelineRow { + clientId: string; clientCode: string; clientName: string + docId: string | null; docNo: string | null; docStatus: string | null + amountPaise: number | null + ownerId: string | null; ownerName: string | null + stage: PipelineStage; ageDays: number | null + nextAction: PipelineNextAction; band: PipelineBand | null +} + +export interface PipelinePage { + rows: PipelineRow[]; total: number; page: number; pageSize: number; dayOffsets: number[] +} + +/** Server-paginated, role-gated (staff forced to own rows server-side). */ +export const getPipeline = ( + opts: { filter?: PipelineFilter; owner?: string; page?: number; pageSize?: number } = {}, +): Promise => { + const p = new URLSearchParams() + if (opts.filter !== undefined) p.set('filter', opts.filter) + if (opts.owner !== undefined && opts.owner !== '') p.set('owner', opts.owner) + if (opts.page !== undefined) p.set('page', String(opts.page)) + if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) + const q = p.toString() + return apiFetch(`/pipeline${q === '' ? '' : `?${q}`}`) +} + // ---------- HQ-2 shapes ---------- export type Cadence = 'monthly' | 'yearly' diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index ec78293..db64a8a 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -16,6 +16,7 @@ import { DocumentTemplate } from './pages/DocumentTemplate' import { NewDocument } from './pages/NewDocument' import { DocumentView } from './pages/DocumentView' import { Employees } from './pages/Employees' +import { Pipeline } from './pages/Pipeline' function App() { return ( @@ -24,6 +25,7 @@ function App() { } /> }> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 3d7b219..132df61 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,17 +1,18 @@ import { - BarChart3, Boxes, FilePlus2, FileText, LayoutDashboard, UserCog, Users, + BarChart3, Boxes, FilePlus2, FileText, Filter, LayoutDashboard, UserCog, Users, type LucideIcon, } from 'lucide-react' export interface NavItem { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean } export interface NavGroup { label: string; items: NavItem[] } -/** Grouped sidebar (spec §3). Pipeline slots into WORK when the funnel spec lands. */ +/** Grouped sidebar (spec §3). Pipeline sits right after Dashboard (funnel spec §6b). */ export const NAV_GROUPS: NavGroup[] = [ { label: 'Work', items: [ { to: '/', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/pipeline', label: 'Pipeline', icon: Filter }, { to: '/clients', label: 'Clients', icon: Users }, { to: '/documents/new', label: 'New Document', icon: FilePlus2 }, ], diff --git a/apps/hq-web/src/pages/Pipeline.tsx b/apps/hq-web/src/pages/Pipeline.tsx new file mode 100644 index 0000000..c491ca0 --- /dev/null +++ b/apps/hq-web/src/pages/Pipeline.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { formatINR } from '@sims/domain' +import { Badge, Button, DataTable, EmptyState, FilterChips, Notice, PageHeader, Toolbar } from '@sims/ui' +import { + documentAction, getEmployees, getPipeline, isManagerial, + type Employee, type PipelineFilter, type PipelineRow, type PipelineStage, +} from '../api' +import { useData } from './Clients' + +const PAGE_SIZE = 50 + +const STAGE_LABEL: Record = { + enquiry: 'Enquiry', new_project: 'New Project', quoted_waiting: 'Quoted / Waiting', + won: 'Won', lost: 'Lost', +} +const STAGE_TONE: Record = { + enquiry: undefined, new_project: 'warn', quoted_waiting: 'accent', won: 'ok', lost: 'err', +} +const ACTION_LABEL: Record = { + send_quote: 'Send quote', waiting: 'Waiting', chase: 'Chase', nudge: 'Nudge', + final_nudge: 'Final nudge', convert_invoice: 'Convert → invoice', none: '—', +} +/** Row tint mirrors the server-derived band (green < offset₁, amber, red ≥ last offset). */ +const BAND_TONE = { green: 'ok', amber: 'warn', red: 'err' } as const + +/** + * Pipeline chase-list (spec §6b — D-PIPE option B): one cross-client ranked list, + * most-overdue on top. Stage/age/next-action all derive server-side from the same + * reminder_schedule offsets the follow-up engine uses; staff are server-forced to + * their own rows, so the owner dropdown only renders for owner/manager. + */ +export function Pipeline() { + const nav = useNavigate() + const [filter, setFilter] = useState('all') + const [owner, setOwner] = useState('') + const [page, setPage] = useState(1) + const [error, setError] = useState() + const managerial = isManagerial() + + const list = useData( + () => getPipeline({ filter, ...(owner !== '' ? { owner } : {}), page, pageSize: PAGE_SIZE }), + [filter, owner, page], + ) + // Owner dropdown is a managerial-only widening control; staff never fetch the roster here. + const employees = useData<{ employees: Employee[]; total: number } | undefined>( + () => (managerial ? getEmployees() : Promise.resolve(undefined)), [], + ) + + const act = (p: Promise) => { + setError(undefined) + p.then(() => list.reload()).catch((e: Error) => setError(e.message)) + } + + const actions = (r: PipelineRow) => { + if (r.stage === 'enquiry') { + return + } + if (r.stage === 'new_project') { + return + } + if (r.stage === 'quoted_waiting') { + // Chase happens off-app (call/mail); the buttons record the outcome via the + // existing lifecycle endpoint, then the list re-derives. + return ( + + + + + ) + } + if (r.nextAction === 'convert_invoice') { + return ( + + ) + } + return null + } + + const data = list.data + const rows = data?.rows + const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1 + const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0 + const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0 + + return ( +
+ + + { setFilter(k as PipelineFilter); setPage(1) }} + /> + {managerial && ( + + )} + {data !== undefined && ( + chase after {data.dayOffsets.join(' / ')} days + )} + + {error !== undefined && {error}} + {list.error !== undefined && {list.error}} + {list.error !== undefined ? null + : rows === undefined ? Loading… + : rows.length === 0 ? Nothing to chase — the pipeline is clear. : ( + <> + { + const band = rows[i]!.band + return band !== null ? BAND_TONE[band] : undefined + }} + rows={rows.map((r) => ({ + client: ( + + {r.clientName}{r.stage === 'enquiry' ? ' (lead)' : ''} + + ), + amount: r.amountPaise !== null ? formatINR(r.amountPaise) : '—', + owner: r.ownerName ?? '—', + stage: {STAGE_LABEL[r.stage]}, + age: r.ageDays !== null ? `${r.ageDays}d` : '—', + next: ACTION_LABEL[r.nextAction], + actions: actions(r), + }))} + /> + + Showing {from}–{to} of {data!.total} + + + Page {data!.page} / {totalPages} + + + + )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 7667286..15b7c7e 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -24,6 +24,7 @@ import { import { clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, } from './repos-payments' +import { listPipeline, PIPELINE_FILTERS, type ListPipelineOpts, type PipelineFilter } from './repos-pipeline' import { getShare, listShares, mintShare, revokeShare, type MintShareOpts } from './repos-shares' import { createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, @@ -203,6 +204,28 @@ export function apiRouter( } }) + // ---------- pipeline chase-list (D-PIPE; spec §6b/§9) ---------- + // Role gating lives inside the query: staff are server-forced to their own rows + // (viewer identity comes from res.locals.staff, never the request), owner/manager + // may narrow via ?owner= or filter=mine. Paginated with an honest total (rule 8). + r.get('/pipeline', requireAuth, (req, res) => { + const viewer = res.locals['staff'] as { id: string; role: string } + const rawFilter = typeof req.query['filter'] === 'string' ? req.query['filter'] : 'all' + if (!(PIPELINE_FILTERS as string[]).includes(rawFilter)) { + res.status(400).json({ ok: false, error: `filter must be one of: ${PIPELINE_FILTERS.join(', ')}` }) + return + } + const opts: ListPipelineOpts = { + filter: rawFilter as PipelineFilter, viewerRole: viewer.role, viewerId: viewer.id, + } + if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner'] + const page = Number(req.query['page']) + if (Number.isInteger(page) && page >= 1) opts.page = page + const pageSize = Number(req.query['pageSize']) + if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize + res.json({ ok: true, ...listPipeline(db, opts) }) + }) + // ---------- modules ---------- r.get('/modules', requireAuth, (_req, res) => { res.json({ ok: true, modules: listModules(db) }) diff --git a/apps/hq/src/repos-pipeline.ts b/apps/hq/src/repos-pipeline.ts new file mode 100644 index 0000000..1bd49bf --- /dev/null +++ b/apps/hq/src/repos-pipeline.ts @@ -0,0 +1,180 @@ +import { ownerScope } from './auth' +import type { DB } from './db' +import { resolveSchedule } from './repos-reminders' + +/** + * Pipeline chase-list (D-PIPE option B, spec §6b/§9) — one cross-client ranked list. + * Stage is NEVER stored: every row derives it from client.status + the client's + * latest non-cancelled QUOTATION. Rows are the union of latest-quote-per-client and + * bare leads (client.status='lead' with no live quotation). Age anchors on the FIRST + * 'sent' document_event (F15); the next-action ladder and colour band read the same + * resolveSchedule('quote_followup') day_offsets the reminder engine uses — a dated + * config row changes both, no code edit (rule 3, no hardcoded 3/7/14). + */ + +export type PipelineStage = 'enquiry' | 'new_project' | 'quoted_waiting' | 'won' | 'lost' +export type PipelineBand = 'green' | 'amber' | 'red' +export type PipelineNextAction = + | 'send_quote' | 'waiting' | 'chase' | 'nudge' | 'final_nudge' | 'convert_invoice' | 'none' +export type PipelineFilter = 'all' | 'mine' | 'overdue' | 'lost' +export const PIPELINE_FILTERS: PipelineFilter[] = ['all', 'mine', 'overdue', 'lost'] + +export interface PipelineRow { + clientId: string; clientCode: string; clientName: string + /** null for a bare-lead enquiry (no quotation yet). */ + docId: string | null; docNo: string | null; docStatus: string | null + amountPaise: number | null + /** document.created_by for quote rows (A3); client.owner_id for bare leads (A4). */ + ownerId: string | null; ownerName: string | null + stage: PipelineStage + /** Whole days since the first 'sent' event; null when never sent. */ + ageDays: number | null + nextAction: PipelineNextAction + /** Colour band for sent quotes: green < offset₁, amber offset₁–₃, red ≥ last offset. */ + band: PipelineBand | null +} + +export interface ListPipelineOpts { + filter?: PipelineFilter + /** Narrow to one owner — managerial viewers only; ignored (self-forced) for staff. */ + ownerId?: string + /** From res.locals.staff — never from the request body. */ + viewerRole: string + viewerId: string + page?: number + pageSize?: number + /** Injectable business date (YYYY-MM-DD) for deterministic tests. */ + today?: string +} + +export interface PipelinePage { + rows: PipelineRow[]; total: number; page: number; pageSize: number + /** The resolved quote_followup day offsets, echoed for the UI legend. */ + dayOffsets: number[] +} + +interface QuoteRow { + doc_id: string; doc_no: string | null; doc_status: string; payable_paise: number + created_by: string; first_sent: string | null + client_id: string; client_code: string; client_name: string; client_status: string +} + +interface LeadRow { client_id: string; client_code: string; client_name: string; owner_id: string | null } + +const DEFAULT_PAGE_SIZE = 50 +const MAX_PAGE_SIZE = 200 +/** Escalation ladder past each crossed offset; clamps at the last rung. */ +const LADDER: PipelineNextAction[] = ['chase', 'nudge', 'final_nudge'] + +/** Whole days between the first-sent timestamp's date and `today` (never negative). */ +function ageDaysOf(firstSent: string | null, today: string): number | null { + if (firstSent === null) return null + const ms = Date.parse(today) - Date.parse(firstSent.slice(0, 10)) + return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : null +} + +export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage { + const filter = opts.filter ?? 'all' + const today = opts.today ?? new Date().toISOString().slice(0, 10) + const page = Math.max(opts.page ?? 1, 1) + const pageSize = Math.min(Math.max(opts.pageSize ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE) + const dayOffsets = resolveSchedule(db, 'quote_followup', today).dayOffsets + const lastOffset = dayOffsets[dayOffsets.length - 1]! + + // Latest non-cancelled QUOTATION per client (uuidv7 ids sort by creation time, so + // MAX(id) is the newest — same trick as listDocuments). A cancelled quote is dead + // paper: it neither represents the client nor blocks the bare-lead Enquiry row. + const quotes = db.prepare( + `SELECT d.id AS doc_id, d.doc_no, d.status AS doc_status, d.payable_paise, d.created_by, + (SELECT MIN(e.at_wall) FROM document_event e + WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent, + c.id AS client_id, c.code AS client_code, c.name AS client_name, c.status AS client_status + FROM document d JOIN client c ON c.id = d.client_id + WHERE d.doc_type = 'QUOTATION' AND d.status != 'cancelled' + AND d.id = (SELECT MAX(d2.id) FROM document d2 + WHERE d2.client_id = d.client_id AND d2.doc_type = 'QUOTATION' + AND d2.status != 'cancelled')`, + ).all() as QuoteRow[] + + // Bare leads: client.status='lead' with no live quotation → derived stage Enquiry. + const leads = db.prepare( + `SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name, c.owner_id + FROM client c + WHERE c.status = 'lead' + AND NOT EXISTS (SELECT 1 FROM document d + WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION' + AND d.status != 'cancelled')`, + ).all() as LeadRow[] + + const staffNames = new Map( + (db.prepare(`SELECT id, display_name FROM staff_user`).all() as { id: string; display_name: string }[]) + .map((s) => [s.id, s.display_name]), + ) + const nameOf = (id: string | null): string | null => + id === null ? null : staffNames.get(id) ?? null + + const rows: PipelineRow[] = [] + for (const q of quotes) { + const stage: PipelineStage = + q.client_status === 'lost' || q.doc_status === 'lost' ? 'lost' + : q.doc_status === 'draft' ? 'new_project' + : q.doc_status === 'sent' ? 'quoted_waiting' + : 'won' // accepted | invoiced (part_paid/paid cannot occur on a quotation) + const age = ageDaysOf(q.first_sent, today) + let nextAction: PipelineNextAction = 'none' + let band: PipelineBand | null = null + if (stage === 'new_project') nextAction = 'send_quote' + else if (stage === 'won') nextAction = q.doc_status === 'accepted' ? 'convert_invoice' : 'none' + else if (stage === 'quoted_waiting') { + const crossed = age === null ? 0 : dayOffsets.filter((o) => age >= o).length + nextAction = crossed === 0 ? 'waiting' : LADDER[Math.min(crossed, LADDER.length) - 1]! + band = crossed === 0 ? 'green' : age !== null && age >= lastOffset ? 'red' : 'amber' + } + rows.push({ + clientId: q.client_id, clientCode: q.client_code, clientName: q.client_name, + docId: q.doc_id, docNo: q.doc_no, docStatus: q.doc_status, amountPaise: q.payable_paise, + ownerId: q.created_by, ownerName: nameOf(q.created_by), + stage, ageDays: age, nextAction, band, + }) + } + for (const l of leads) { + rows.push({ + clientId: l.client_id, clientCode: l.client_code, clientName: l.client_name, + docId: null, docNo: null, docStatus: null, amountPaise: null, + ownerId: l.owner_id, ownerName: nameOf(l.owner_id), + stage: 'enquiry', ageDays: null, nextAction: 'send_quote', band: null, + }) + } + + // Role gate: staff are server-forced to their own rows (ownerScope ignores any + // widening param); managerial viewers may narrow via ownerId or filter=mine. + const requested = filter === 'mine' ? opts.viewerId : opts.ownerId + const scopedOwner = ownerScope({ id: opts.viewerId, role: opts.viewerRole }, requested) + let filtered = scopedOwner === undefined ? rows : rows.filter((r) => r.ownerId === scopedOwner) + filtered = filter === 'lost' + ? filtered.filter((r) => r.stage === 'lost') + : filtered.filter((r) => r.stage !== 'lost') // Lost hidden unless filtered (spec §9) + if (filter === 'overdue') { + filtered = filtered.filter((r) => r.stage === 'quoted_waiting' && r.nextAction !== 'waiting') + } + + // Sort: sent quotes by age desc (oldest unanswered on top — Waiting sinks by its + // small age), then Enquiry/draft (needs a quote), then Won (convertible first), + // Lost last when shown. Name tie-break keeps pages deterministic. + const rank = (r: PipelineRow): number => + r.stage === 'quoted_waiting' ? 0 + : r.stage === 'enquiry' || r.stage === 'new_project' ? 1 + : r.stage === 'won' ? (r.nextAction === 'convert_invoice' ? 2 : 3) + : 4 + filtered.sort((a, b) => + rank(a) - rank(b) + || (b.ageDays ?? -1) - (a.ageDays ?? -1) + || a.clientName.localeCompare(b.clientName)) + + // LIMIT/OFFSET + honest total (rule 8 — no silent caps). The candidate set is + // bounded by the client book (~300), so deriving in memory then slicing keeps the + // date math and schedule lookup portable across SQLite/Postgres. + const total = filtered.length + const start = (page - 1) * pageSize + return { rows: filtered.slice(start, start + pageSize), total, page, pageSize, dayOffsets } +} diff --git a/apps/hq/test/pipeline.test.ts b/apps/hq/test/pipeline.test.ts new file mode 100644 index 0000000..b218a92 --- /dev/null +++ b/apps/hq/test/pipeline.test.ts @@ -0,0 +1,293 @@ +// apps/hq/test/pipeline.test.ts — Phase 5: pipeline chase-list (D-PIPE option B). +// Union of latest-quotation-per-client + bare leads; derived stage/age/next-action/band +// (bands from resolveSchedule — never hardcoded 3/7/14); staff server-forced to own rows; +// actionable-oldest-first sort; LIMIT/OFFSET pagination with total. +import express from 'express' +import { describe, it, expect, afterAll } from 'vitest' +import { openDb, type DB } from '../src/db' +import { createStaff } from '../src/auth' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, convertDocument, markStatus } from '../src/repos-documents' +import { listPipeline } from '../src/repos-pipeline' +import { apiRouter } from '../src/api' + +const TODAY = '2026-07-17' +const OWNER_VIEW = { viewerRole: 'owner', viewerId: 'boss', today: TODAY } as const + +function daysAgo(n: number): string { + return new Date(Date.parse(TODAY) - n * 86_400_000).toISOString() +} + +function setup(): { db: DB; moduleId: string } { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + return { db, moduleId: m.id } +} + +/** Draft QUOTATION for the client; optionally marked sent with the event backdated `ageDays`. */ +function quote(db: DB, clientId: string, moduleId: string, opts: { by?: string; sentAgeDays?: number } = {}) { + const by = opts.by ?? 'u1' + const d = createDraft(db, by, { + docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], + }) + if (opts.sentAgeDays !== undefined) { + markStatus(db, by, d.id, 'sent') + db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`) + .run(daysAgo(opts.sentAgeDays), d.id) + } + return d +} + +describe('listPipeline — stage derivation (spec §9)', () => { + it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', () => { + const { db, moduleId } = setup() + const emp = createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' }) + const lead = createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' }) + db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(emp.id, lead.id) + // an active client with no quotation is NOT pipeline material + createClient(db, 'u1', { name: 'Steady Customer', stateCode: '32', status: 'active' }) + // a lead WITH a quotation shows as its quote row, not a duplicate enquiry + const quotedLead = createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' }) + quote(db, quotedLead.id, moduleId) + + const out = listPipeline(db, { ...OWNER_VIEW }) + const enquiry = out.rows.find((r) => r.clientId === lead.id) + expect(enquiry).toMatchObject({ + stage: 'enquiry', nextAction: 'send_quote', docId: null, amountPaise: null, + ownerId: emp.id, ownerName: 'Vikram', ageDays: null, band: null, + }) + expect(out.rows.filter((r) => r.clientId === quotedLead.id)).toHaveLength(1) + expect(out.rows.find((r) => r.clientId === quotedLead.id)!.stage).toBe('new_project') + expect(out.rows.some((r) => r.clientName === 'Steady Customer')).toBe(false) + }) + + it('derives New Project / Quoted-Waiting / Won from the latest quotation status', () => { + const { db, moduleId } = setup() + const draft = createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' }) + quote(db, draft.id, moduleId) + const sent = createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' }) + quote(db, sent.id, moduleId, { sentAgeDays: 1 }) + const accepted = createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' }) + const qa = quote(db, accepted.id, moduleId, { sentAgeDays: 2 }) + markStatus(db, 'u1', qa.id, 'accepted') + const invoiced = createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' }) + const qi = quote(db, invoiced.id, moduleId, { sentAgeDays: 2 }) + markStatus(db, 'u1', qi.id, 'accepted') + convertDocument(db, 'u1', qi.id, 'INVOICE') + + const by = new Map(listPipeline(db, { ...OWNER_VIEW }).rows.map((r) => [r.clientName, r])) + expect(by.get('Draft Co')).toMatchObject({ stage: 'new_project', nextAction: 'send_quote' }) + expect(by.get('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 }) + expect(by.get('Sent Co')!.amountPaise).toBe(11_800_00) + expect(by.get('Accepted Co')).toMatchObject({ stage: 'won', nextAction: 'convert_invoice' }) + expect(by.get('Invoiced Co')).toMatchObject({ stage: 'won', nextAction: 'none' }) + }) + + it('the LATEST quotation wins when a client has several', () => { + const { db, moduleId } = setup() + const c = createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' }) + const old = quote(db, c.id, moduleId, { sentAgeDays: 40 }) + markStatus(db, 'u1', old.id, 'lost') + quote(db, c.id, moduleId, { sentAgeDays: 2 }) + const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 }) + }) + + it('Lost (quote lost or client lost) is hidden unless filtered', () => { + const { db, moduleId } = setup() + const lostQuote = createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' }) + const q = quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 }) + markStatus(db, 'u1', q.id, 'lost') + const lostClient = createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' }) + quote(db, lostClient.id, moduleId, { sentAgeDays: 5 }) + const alive = createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' }) + quote(db, alive.id, moduleId, { sentAgeDays: 1 }) + + const all = listPipeline(db, { ...OWNER_VIEW }) + expect(all.rows.map((r) => r.clientName)).toEqual(['Alive Co']) + expect(all.total).toBe(1) + const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' }) + expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost']) + expect(lost.total).toBe(2) + }) +}) + +describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => { + it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', () => { + const { db, moduleId } = setup() + const mk = (name: string, age: number) => { + const c = createClient(db, 'u1', { name, stateCode: '32' }) + quote(db, c.id, moduleId, { sentAgeDays: age }) + } + mk('Age2', 2); mk('Age5', 5); mk('Age8', 8); mk('Age15', 15) + const out = listPipeline(db, { ...OWNER_VIEW }) + expect(out.dayOffsets).toEqual([3, 7, 14]) + const by = new Map(out.rows.map((r) => [r.clientName, r])) + expect(by.get('Age2')).toMatchObject({ nextAction: 'waiting', band: 'green', ageDays: 2 }) + expect(by.get('Age5')).toMatchObject({ nextAction: 'chase', band: 'amber', ageDays: 5 }) + expect(by.get('Age8')).toMatchObject({ nextAction: 'nudge', band: 'amber', ageDays: 8 }) + expect(by.get('Age15')).toMatchObject({ nextAction: 'final_nudge', band: 'red', ageDays: 15 }) + }) + + it('a dated reminder_schedule row changes the bands without a code edit', () => { + const { db, moduleId } = setup() + db.prepare( + `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) + VALUES ('rs1', 'quote_followup', '2026-01-01', NULL, '2,4,6', NULL, NULL)`, + ).run() + const c = createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' }) + quote(db, c.id, moduleId, { sentAgeDays: 5 }) + const out = listPipeline(db, { ...OWNER_VIEW }) + expect(out.dayOffsets).toEqual([2, 4, 6]) + // age 5 crossed 2 and 4 but not 6 → nudge, amber (would be 'chase' under 3/7/14) + expect(out.rows[0]).toMatchObject({ nextAction: 'nudge', band: 'amber' }) + }) +}) + +describe('listPipeline — role gate (staff server-forced to own rows)', () => { + function team(db: DB) { + const a = createStaff(db, { email: 'a@x.co', displayName: 'Asha', role: 'staff', password: 'password2' }) + const b = createStaff(db, { email: 'b@x.co', displayName: 'Priya', role: 'staff', password: 'password2' }) + return { a: a.id, b: b.id } + } + + it('staff see only quotes they created and leads they own; widening params are ignored', () => { + const { db, moduleId } = setup() + const { a, b } = team(db) + const c1 = createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' }) + quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 }) + const c2 = createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' }) + quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 }) + const leadA = createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' }) + db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(a, leadA.id) + createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' }) + + const mine = listPipeline(db, { viewerRole: 'staff', viewerId: a, today: TODAY }) + expect(mine.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co']) + // a widening owner param cannot escape the self scope + const widened = listPipeline(db, { viewerRole: 'staff', viewerId: a, ownerId: b, today: TODAY }) + expect(widened.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co']) + // owner/manager see everything, including the unassigned lead (F13 safety net) + const boss = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', today: TODAY }) + expect(boss.total).toBe(4) + // ...and may narrow to one owner, or to themselves via filter=mine + const narrowed = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', ownerId: b, today: TODAY }) + expect(narrowed.rows.map((r) => r.clientName)).toEqual(['Priya Quote Co']) + const bossMine = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', filter: 'mine', today: TODAY }) + expect(bossMine.total).toBe(0) + }) +}) + +describe('listPipeline — sort, overdue filter, pagination', () => { + it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', () => { + const { db, moduleId } = setup() + const mkSent = (name: string, age: number) => { + const c = createClient(db, 'u1', { name, stateCode: '32' }) + return quote(db, c.id, moduleId, { sentAgeDays: age }) + } + mkSent('Mid 5d', 5) + mkSent('Oldest 15d', 15) + mkSent('Waiting 2d', 2) + createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' }) + const won = createClient(db, 'u1', { name: 'Won Co', stateCode: '32' }) + markStatus(db, 'u1', quote(db, won.id, moduleId, { sentAgeDays: 30 }).id, 'accepted') + + const names = listPipeline(db, { ...OWNER_VIEW }).rows.map((r) => r.clientName) + expect(names).toEqual(['Oldest 15d', 'Mid 5d', 'Waiting 2d', 'A Lead', 'Won Co']) + }) + + it('filter=overdue keeps only sent quotes past the first offset', () => { + const { db, moduleId } = setup() + const mkSent = (name: string, age: number) => { + const c = createClient(db, 'u1', { name, stateCode: '32' }) + quote(db, c.id, moduleId, { sentAgeDays: age }) + } + mkSent('Fresh 1d', 1) + mkSent('Over 9d', 9) + createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' }) + const out = listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' }) + expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d']) + expect(out.total).toBe(1) + }) + + it('paginates with LIMIT/OFFSET semantics and an honest total', () => { + const { db, moduleId } = setup() + for (let i = 0; i < 5; i += 1) { + const c = createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' }) + quote(db, c.id, moduleId, { sentAgeDays: 10 + i }) + } + const p1 = listPipeline(db, { ...OWNER_VIEW, page: 1, pageSize: 2 }) + expect(p1.total).toBe(5) + expect(p1.rows).toHaveLength(2) + expect(p1.rows[0]!.ageDays).toBe(14) // oldest first + const p3 = listPipeline(db, { ...OWNER_VIEW, page: 3, pageSize: 2 }) + expect(p3.rows).toHaveLength(1) + expect(p3.page).toBe(3) + expect(p3.pageSize).toBe(2) + // no overlap, nothing dropped + const all = [ + ...p1.rows, + ...listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 }).rows, + ...p3.rows, + ] + expect(new Set(all.map((r) => r.clientId)).size).toBe(5) + }) +}) + +describe('GET /pipeline', () => { + const { db, moduleId } = setup() + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) + const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id + const mineClient = createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' }) + quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 }) + const otherClient = createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' }) + quote(db, otherClient.id, moduleId, { sentAgeDays: 4 }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + afterAll(() => server.close()) + + const tokenOf = async (email: string, password: string) => + (await (await fetch(`${base}/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + })).json() as { token: string }).token + + const get = async (token: string, qs: string) => { + const res = await fetch(`${base}/pipeline${qs}`, { headers: { authorization: `Bearer ${token}` } }) + return { status: res.status, json: await res.json() as any } + } + + it('returns the derived rows with total/page/pageSize; staff are forced to their own rows', async () => { + const owner = await tokenOf('owner@test.in', 'owner-password') + const all = await get(owner, '') + expect(all.status).toBe(200) + expect(all.json.total).toBe(2) + expect(all.json.page).toBe(1) + expect(all.json.rows).toHaveLength(2) + + const staff = await tokenOf('staff@test.in', 'staff-password') + const own = await get(staff, '') + expect(own.json.total).toBe(1) + expect(own.json.rows[0].clientName).toBe('Staff Own Co') + // the widening ?owner= param is ignored server-side for staff + const widened = await get(staff, `?owner=someone-else`) + expect(widened.json.total).toBe(1) + expect(widened.json.rows[0].clientName).toBe('Staff Own Co') + }) + + it('rejects a bad filter; honours page/pageSize', async () => { + const owner = await tokenOf('owner@test.in', 'owner-password') + expect((await get(owner, '?filter=bogus')).status).toBe(400) + const paged = await get(owner, '?page=2&pageSize=1') + expect(paged.json.total).toBe(2) + expect(paged.json.rows).toHaveLength(1) + expect(paged.json.pageSize).toBe(1) + }) +})