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-web/src/pages/Pipeline.tsx

193 lines
8.0 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 { 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>
)
}