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 client_status: 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. // Quote-less LOST clients ride the same arm as stage Lost (spec §9: Lost = latest // quotation lost OR client.status='lost') so the Lost filter can review them. const leads = db.prepare( `SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name, c.status AS client_status, c.owner_id FROM client c WHERE c.status IN ('lead', 'lost') 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) { const lost = l.client_status === 'lost' 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: lost ? 'lost' : 'enquiry', ageDays: null, nextAction: lost ? 'none' : '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 } }