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-pipeline.ts

189 lines
8.9 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 }
}