feat(pipeline): cross-client chase-list — derived stages, schedule-driven bands, role-gated, paginated

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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent c5a524bab5
commit eaecaedd3f

@ -250,6 +250,40 @@ export const resetEmployeePassword = (id: string, password: string): Promise<Emp
method: 'POST', body: JSON.stringify({ password }), method: 'POST', body: JSON.stringify({ password }),
}).then((r) => r.employee) }).then((r) => 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<PipelinePage> => {
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<PipelinePage>(`/pipeline${q === '' ? '' : `?${q}`}`)
}
// ---------- HQ-2 shapes ---------- // ---------- HQ-2 shapes ----------
export type Cadence = 'monthly' | 'yearly' export type Cadence = 'monthly' | 'yearly'

@ -16,6 +16,7 @@ import { DocumentTemplate } from './pages/DocumentTemplate'
import { NewDocument } from './pages/NewDocument' import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView' import { DocumentView } from './pages/DocumentView'
import { Employees } from './pages/Employees' import { Employees } from './pages/Employees'
import { Pipeline } from './pages/Pipeline'
function App() { function App() {
return ( return (
@ -24,6 +25,7 @@ function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/pipeline" element={<Pipeline />} />
<Route path="/clients" element={<Clients />} /> <Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} /> <Route path="/modules" element={<Modules />} />

@ -1,17 +1,18 @@
import { import {
BarChart3, Boxes, FilePlus2, FileText, LayoutDashboard, UserCog, Users, BarChart3, Boxes, FilePlus2, FileText, Filter, LayoutDashboard, UserCog, Users,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
export interface NavItem { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean } export interface NavItem { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean }
export interface NavGroup { label: string; items: NavItem[] } 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[] = [ export const NAV_GROUPS: NavGroup[] = [
{ {
label: 'Work', label: 'Work',
items: [ items: [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/pipeline', label: 'Pipeline', icon: Filter },
{ to: '/clients', label: 'Clients', icon: Users }, { to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 }, { to: '/documents/new', label: 'New Document', icon: FilePlus2 },
], ],

@ -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<PipelineStage, string> = {
enquiry: 'Enquiry', new_project: 'New Project', quoted_waiting: 'Quoted / Waiting',
won: 'Won', lost: 'Lost',
}
const STAGE_TONE: Record<PipelineStage, 'ok' | 'warn' | 'err' | 'accent' | undefined> = {
enquiry: undefined, new_project: 'warn', quoted_waiting: 'accent', won: 'ok', lost: 'err',
}
const ACTION_LABEL: Record<PipelineRow['nextAction'], string> = {
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<PipelineFilter>('all')
const [owner, setOwner] = useState('')
const [page, setPage] = useState(1)
const [error, setError] = useState<string | undefined>()
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<unknown>) => {
setError(undefined)
p.then(() => list.reload()).catch((e: Error) => setError(e.message))
}
const actions = (r: PipelineRow) => {
if (r.stage === 'enquiry') {
return <Button onClick={() => nav('/documents/new')}>Send quote</Button>
}
if (r.stage === 'new_project') {
return <Button onClick={() => nav(`/documents/${r.docId}`)}>Open draft</Button>
}
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 (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Button tone="primary" onClick={() => act(documentAction(r.docId!, 'status', { status: 'accepted' }))}>
Accepted
</Button>
<Button tone="danger" onClick={() => act(documentAction(r.docId!, 'status', { status: 'lost' }))}>
Lost
</Button>
</span>
)
}
if (r.nextAction === 'convert_invoice') {
return (
<Button tone="primary" onClick={() => act(documentAction(r.docId!, 'convert', { to: 'INVOICE' }))}>
Convert invoice
</Button>
)
}
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 (
<div className="wf-page">
<PageHeader
title="Pipeline"
desc="Every open enquiry and quote, most overdue on top. Lost is hidden — use the filter to review it."
/>
<Toolbar>
<FilterChips
chips={[
{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' },
{ key: 'overdue', label: 'Overdue' }, { key: 'lost', label: 'Lost' },
]}
active={filter}
onChange={(k) => { setFilter(k as PipelineFilter); setPage(1) }}
/>
{managerial && (
<select
className="wf" style={{ maxWidth: 200 }} value={owner} aria-label="Owner"
onChange={(e) => { setOwner(e.target.value); setPage(1) }}
>
<option value="">Owner: everyone</option>
{(employees.data?.employees ?? []).map((e) => (
<option key={e.id} value={e.id}>{e.displayName}</option>
))}
</select>
)}
{data !== undefined && (
<Badge>chase after {data.dayOffsets.join(' / ')} days</Badge>
)}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{list.error !== undefined && <Notice tone="err">{list.error}</Notice>}
{list.error !== undefined ? null
: rows === undefined ? <EmptyState>Loading</EmptyState>
: rows.length === 0 ? <EmptyState>Nothing to chase the pipeline is clear.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'client', label: 'Client' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'owner', label: 'Owner' },
{ key: 'stage', label: 'Stage' },
{ key: 'age', label: 'Age', numeric: true },
{ key: 'next', label: 'Next action' },
{ key: 'actions', label: '' },
]}
rowTone={(i) => {
const band = rows[i]!.band
return band !== null ? BAND_TONE[band] : undefined
}}
rows={rows.map((r) => ({
client: (
<Link to={`/clients/${r.clientId}`}>
{r.clientName}{r.stage === 'enquiry' ? ' (lead)' : ''}
</Link>
),
amount: r.amountPaise !== null ? formatINR(r.amountPaise) : '—',
owner: r.ownerName ?? '—',
stage: <Badge tone={STAGE_TONE[r.stage]}>{STAGE_LABEL[r.stage]}</Badge>,
age: r.ageDays !== null ? `${r.ageDays}d` : '—',
next: ACTION_LABEL[r.nextAction],
actions: actions(r),
}))}
/>
<Toolbar>
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />
<button
type="button" className="wf" disabled={data!.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data!.page} / {totalPages}</span>
<button
type="button" className="wf" disabled={data!.page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</Toolbar>
</>
)}
</div>
)
}

@ -24,6 +24,7 @@ import {
import { import {
clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput,
} from './repos-payments' } 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 { getShare, listShares, mintShare, revokeShare, type MintShareOpts } from './repos-shares'
import { import {
createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, 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 ---------- // ---------- modules ----------
r.get('/modules', requireAuth, (_req, res) => { r.get('/modules', requireAuth, (_req, res) => {
res.json({ ok: true, modules: listModules(db) }) res.json({ ok: true, modules: listModules(db) })

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

@ -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)
})
})
Loading…
Cancel
Save