|
|
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<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 toast = useToast()
|
|
|
const [filter, setFilter] = useState<PipelineFilter>('all')
|
|
|
const [owner, setOwner] = useState('')
|
|
|
const [page, setPage] = useState(1)
|
|
|
const [error, setError] = useState<string | undefined>()
|
|
|
// 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<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={() => setConverting({ docId: r.docId!, clientName: r.clientName })}>
|
|
|
Convert & send
|
|
|
</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 && <ErrorState message={list.error} onRetry={list.reload} />}
|
|
|
{list.error !== undefined ? null
|
|
|
: rows === undefined ? <Skeleton rows={6} />
|
|
|
: 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),
|
|
|
}))}
|
|
|
/>
|
|
|
{converting !== undefined && (
|
|
|
<ConfirmDialog
|
|
|
open
|
|
|
onClose={() => 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"
|
|
|
/>
|
|
|
)}
|
|
|
<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>
|
|
|
)
|
|
|
}
|