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
parent
c5a524bab5
commit
eaecaedd3f
@ -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…
Reference in New Issue