diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 5a8918e..349393f 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 => { + 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 => + apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document) diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index b260fd8..cff57a5 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index f98a45e..51affad 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -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 }, diff --git a/apps/hq-web/src/pages/Renewals.tsx b/apps/hq-web/src/pages/Renewals.tsx new file mode 100644 index 0000000..95c6072 --- /dev/null +++ b/apps/hq-web/src/pages/Renewals.tsx @@ -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() + 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 ( +
+ + + ({ key: w.key, label: w.label }))} active={win} onChange={setWin} /> + + {rows !== undefined && {rows.length} due · {formatINR(totalPaise)}} + + {list.error !== undefined ? + : rows === undefined ? + : rows.length === 0 ? Nothing due in the next {days} days. : ( + (rows[i]!.daysUntil <= 15 ? 'err' : rows[i]!.daysUntil <= 30 ? 'warn' : undefined)} + rows={rows.map((r) => ({ + client: {r.clientName}, + what: {r.label}, + 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' + ? ( + e.stopPropagation()}> + + + ) + : Open AMC, + }))} + /> + )} +
+ ) +}