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
Thomas Joise 3 days ago
parent 02d6f13c95
commit 419dfebbaa

@ -885,3 +885,19 @@ export const previewSample = (body: unknown, signal?: AbortSignal): Promise<{ ht
postPreview<{ html: string }>('/previews/sample', body, signal)
export const getReminderPreview = (id: string): Promise<{ subject: string; body: string }> =>
apiFetch<{ subject: string; body: string }>(`/reminders/${id}/preview`)
// D27: renewals command center.
export interface RenewalRow {
kind: 'module' | 'amc'
id: string; clientId: string; clientName: string
label: string; dueOn: string; daysUntil: number; amountPaise: number | null
}
export const getRenewals = (from?: string, to?: string): Promise<RenewalRow[]> => {
const p = new URLSearchParams()
if (from !== undefined) p.set('from', from)
if (to !== undefined) p.set('to', to)
const qs = p.toString()
return apiFetch<{ rows: RenewalRow[] }>(`/renewals${qs !== '' ? `?${qs}` : ''}`).then((r) => r.rows)
}
export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document)

@ -24,6 +24,7 @@ import { Documents } from './pages/Documents'
import { Reminders } from './pages/Reminders'
import { Tickets } from './pages/Tickets'
import { Projects } from './pages/Projects'
import { Renewals } from './pages/Renewals'
function App() {
return (
@ -35,6 +36,7 @@ function App() {
<Route path="/pipeline" element={<Pipeline />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/projects" element={<Projects />} />
<Route path="/renewals" element={<Renewals />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />

@ -1,5 +1,5 @@
import {
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks,
BarChart3, BellRing, CalendarClock, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks,
Settings as SettingsIcon, Upload, UserCog, Users, Wrench,
type LucideIcon,
} from 'lucide-react'
@ -21,6 +21,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: '/reminders', label: 'Reminders', icon: BellRing },
{ to: '/projects', label: 'Onboarding', icon: ListChecks },
{ to: '/tickets', label: 'Tickets', icon: Wrench },
{ to: '/renewals', label: 'Renewals', icon: CalendarClock },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents', label: 'Documents', icon: Files, end: true },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 },

@ -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…
Cancel
Save