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