feat(d27): #1 Renewals page (command center UI)
New WORK page /renewals: 30/60/90-day window chips, table of due module + AMC renewals (client, type badge, due date, days-until with red/amber tint, amount, total), one-click 'Renewal quote' on module rows (-> proforma draft) and 'Open AMC' on AMC rows. api: getRenewals + generateModuleRenewalQuote. typecheck + web build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
02d6f13c95
commit
419dfebbaa
@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { formatINR } from '@sims/domain'
|
||||||
|
import {
|
||||||
|
Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar, useToast,
|
||||||
|
} from '@sims/ui'
|
||||||
|
import { getRenewals, generateModuleRenewalQuote, type RenewalRow } from '../api'
|
||||||
|
import { useData } from './Clients'
|
||||||
|
|
||||||
|
const WINDOWS: { key: string; label: string; days: number }[] = [
|
||||||
|
{ key: '30', label: 'Next 30 days', days: 30 },
|
||||||
|
{ key: '60', label: 'Next 60 days', days: 60 },
|
||||||
|
{ key: '90', label: 'Next 90 days', days: 90 },
|
||||||
|
]
|
||||||
|
const iso = (d: Date): string => d.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renewals command center (D27): every module subscription + AMC coming due, earliest
|
||||||
|
* first, with amount and days-until. A module row raises next year's proforma in one click;
|
||||||
|
* an AMC row opens the client (its renewal invoice lives on the AMC tab).
|
||||||
|
*/
|
||||||
|
export function Renewals() {
|
||||||
|
const nav = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
|
const [win, setWin] = useState('90')
|
||||||
|
const [busy, setBusy] = useState<string | undefined>()
|
||||||
|
const days = WINDOWS.find((w) => w.key === win)!.days
|
||||||
|
const to = iso(new Date(Date.now() + days * 86_400_000))
|
||||||
|
const list = useData(() => getRenewals(undefined, to), [win])
|
||||||
|
|
||||||
|
const raiseQuote = (r: RenewalRow) => {
|
||||||
|
if (busy !== undefined) return
|
||||||
|
setBusy(r.id)
|
||||||
|
generateModuleRenewalQuote(r.id)
|
||||||
|
.then((doc) => { toast.ok('Renewal proforma created'); nav(`/documents/${doc.id}`) })
|
||||||
|
.catch((e: Error) => { toast.err(e.message); setBusy(undefined) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.data
|
||||||
|
const totalPaise = (rows ?? []).reduce((s, r) => s + (r.amountPaise ?? 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wf-page">
|
||||||
|
<PageHeader
|
||||||
|
title="Renewals"
|
||||||
|
desc="Module subscriptions and AMC contracts coming up for renewal — raise next year's paper before it lapses."
|
||||||
|
/>
|
||||||
|
<Toolbar>
|
||||||
|
<FilterChips chips={WINDOWS.map((w) => ({ key: w.key, label: w.label }))} active={win} onChange={setWin} />
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{rows !== undefined && <Badge>{rows.length} due · {formatINR(totalPaise)}</Badge>}
|
||||||
|
</Toolbar>
|
||||||
|
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
|
||||||
|
: rows === undefined ? <Skeleton rows={6} />
|
||||||
|
: rows.length === 0 ? <EmptyState>Nothing due in the next {days} days.</EmptyState> : (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'client', label: 'Client' },
|
||||||
|
{ key: 'what', label: 'What' },
|
||||||
|
{ key: 'due', label: 'Due' },
|
||||||
|
{ key: 'in', label: 'In', numeric: true },
|
||||||
|
{ key: 'amount', label: 'Amount', numeric: true },
|
||||||
|
{ key: 'actions', label: '' },
|
||||||
|
]}
|
||||||
|
rowTone={(i) => (rows[i]!.daysUntil <= 15 ? 'err' : rows[i]!.daysUntil <= 30 ? 'warn' : undefined)}
|
||||||
|
rows={rows.map((r) => ({
|
||||||
|
client: <Link to={`/clients/${r.clientId}`}>{r.clientName}</Link>,
|
||||||
|
what: <Badge tone={r.kind === 'amc' ? 'accent' : 'violet'}>{r.label}</Badge>,
|
||||||
|
due: r.dueOn,
|
||||||
|
in: r.daysUntil < 0 ? `${-r.daysUntil}d ago` : `${r.daysUntil}d`,
|
||||||
|
amount: r.amountPaise !== null ? formatINR(r.amountPaise) : '—',
|
||||||
|
actions: r.kind === 'module'
|
||||||
|
? (
|
||||||
|
<span onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button tone="primary" disabled={busy !== undefined} onClick={() => raiseQuote(r)}>
|
||||||
|
{busy === r.id ? '…' : 'Renewal quote'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: <Link to={`/clients/${r.clientId}?tab=amc`}>Open AMC</Link>,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue