import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { formatINR } from '@sims/domain' import { Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast } 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 toast = useToast() const [filter, setFilter] = useState('all') const [owner, setOwner] = useState('') const [page, setPage] = useState(1) const [error, setError] = useState() // D18 WS-G: same one-click convert-and-send as the Documents register. const [converting, setConverting] = useState<{ docId: string; clientName: 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) => { 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 !== undefined ? null : rows === undefined ? : 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), }))} /> {converting !== undefined && ( setConverting(undefined)} onConfirm={() => documentAction(converting.docId, 'convert-and-send') .then((doc) => { toast.ok('Invoice issued & emailed'); nav(`/documents/${doc.id}`) }) .catch((e: Error) => { setError(e.message); setConverting(undefined) })} title="Convert to invoice & send" body={`Convert the accepted proforma for ${converting.clientName} to a tax invoice, assign its number and email it to the client?`} confirmLabel="Convert & send" /> )} Showing {from}–{to} of {data!.total} Page {data!.page} / {totalPages} )}
) }