feat(hq-web): dashboard money view with manual reminder queue as default route

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 06b5eb532c
commit 9e3886d0be

@ -4,7 +4,8 @@ import { Badge, ThemeSwitcher } from '@sims/ui'
import { clearSession, displayName, getEmailStatus, hasSession } from './api' import { clearSession, displayName, getEmailStatus, hasSession } from './api'
const NAV = [ const NAV = [
{ to: '/', label: 'Clients' }, { to: '/', label: 'Dashboard' },
{ to: '/clients', label: 'Clients' },
{ to: '/modules', label: 'Modules' }, { to: '/modules', label: 'Modules' },
{ to: '/documents/new', label: 'New Document' }, { to: '/documents/new', label: 'New Document' },
] ]

@ -194,6 +194,88 @@ export const recordPayment = (body: Record<string, unknown>): Promise<Payment> =
export const getLedger = (clientId: string): Promise<Ledger> => export const getLedger = (clientId: string): Promise<Ledger> =>
apiFetch<Ledger>(`/clients/${clientId}/ledger`) apiFetch<Ledger>(`/clients/${clientId}/ledger`)
// ---------- HQ-2 shapes ----------
export type Cadence = 'monthly' | 'yearly'
export interface RecurringPlan {
id: string; clientId: string; clientModuleId: string | null; cadence: Cadence
amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean
}
export type AmcPaidStatus = 'paid' | 'unpaid' | 'unbilled'
export interface AmcContract {
id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string
amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null
invoiceDocId: string | null; active: boolean; paidStatus: AmcPaidStatus
}
export interface InteractionType { code: string; label: string }
export type Outcome = 'positive' | 'neutral' | 'negative'
export interface Interaction {
id: string; clientId: string; typeCode: string; onDate: string; staffId: string
notes: string; outcome: Outcome | null; followUpOn: string | null; createdAt: string
}
export type ReminderRuleKind =
| 'invoice_overdue' | 'renewal_due' | 'amc_expiring' | 'follow_up' | 'recurring_generated' | 'email_bounced'
export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed'
export interface Reminder {
id: string; ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string; clientId: string
docId: string | null; status: ReminderStatus; policyApplied: 'auto' | 'manual'
error: string | null; createdAt: string; sentAt: string | null
}
export interface DashboardView {
today: string
overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[]
dueThisWeek: { planId: string; clientId: string; clientName: string; nextRun: string; amountPaise: number | null }[]
renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[]
followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[]
recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[]
queue: (Reminder & { clientName: string; docNo: string | null })[]
totals: { overduePaise: number; queued: number; failed: number }
}
// ---------- HQ-2 calls ----------
export const getDashboard = (): Promise<DashboardView> =>
apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view)
export const getReminders = (status?: ReminderStatus): Promise<Reminder[]> =>
apiFetch<{ reminders: Reminder[] }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`).then((r) => r.reminders)
export const sendReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
export const dismissReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/dismiss`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
export const getRecurringPlans = (clientId: string): Promise<RecurringPlan[]> =>
apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans)
export const createRecurringPlan = (clientId: string, body: Record<string, unknown>): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan)
export const updateRecurringPlan = (id: string, body: Record<string, unknown>): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan)
export const deactivateRecurringPlan = (id: string): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.plan)
export const getAmc = (clientId: string): Promise<AmcContract[]> =>
apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts)
export const createAmc = (clientId: string, body: Record<string, unknown>): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract)
export const updateAmc = (id: string, body: Record<string, unknown>): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract)
export const deactivateAmc = (id: string): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract)
export const generateAmcRenewalInvoice = (id: string): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/amc/${id}/renewal-invoice`, { method: 'POST', body: '{}' }).then((r) => r.document)
export const getInteractionTypes = (): Promise<InteractionType[]> =>
apiFetch<{ types: InteractionType[] }>('/interaction-types').then((r) => r.types)
export const getInteractions = (clientId: string): Promise<Interaction[]> =>
apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions)
export const createInteraction = (clientId: string, body: Record<string, unknown>): Promise<Interaction> =>
apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction)
export const updateInteraction = (id: string, body: Record<string, unknown>): Promise<Interaction> =>
apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction)
/** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */ /** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */
export async function fetchPdfBlob(documentId: string): Promise<Blob> { export async function fetchPdfBlob(documentId: string): Promise<Blob> {
const token = localStorage.getItem(TOKEN_KEY) ?? '' const token = localStorage.getItem(TOKEN_KEY) ?? ''

@ -7,6 +7,7 @@ import './app.css'
import { initTheme } from '@sims/ui' import { initTheme } from '@sims/ui'
import { Layout } from './Layout' import { Layout } from './Layout'
import { Login } from './Login' import { Login } from './Login'
import { Dashboard } from './pages/Dashboard'
import { Clients } from './pages/Clients' import { Clients } from './pages/Clients'
import { ClientDetail } from './pages/ClientDetail' import { ClientDetail } from './pages/ClientDetail'
import { Modules } from './pages/Modules' import { Modules } from './pages/Modules'
@ -19,7 +20,8 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<Clients />} /> <Route path="/" element={<Dashboard />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} /> <Route path="/modules" element={<Modules />} />
<Route path="/documents/new" element={<NewDocument />} /> <Route path="/documents/new" element={<NewDocument />} />

@ -0,0 +1,120 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats } from '@sims/ui'
import { getDashboard, sendReminder, dismissReminder, type Reminder } from '../api'
import { useData } from './Clients'
const inr = (p: number) => formatINR(p)
const RULE_LABEL: Record<string, string> = {
invoice_overdue: 'Overdue invoice', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring',
follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced',
}
const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated'])
const toneFor = (status: string): 'ok' | 'warn' | 'err' =>
status === 'failed' ? 'err' : status === 'sent' ? 'ok' : 'warn'
/** Dashboard home — today's money + the manual reminder queue (spec §3). */
export function Dashboard() {
const nav = useNavigate()
const dash = useData(getDashboard, [])
const [err, setErr] = useState('')
const v = dash.data
return (
<div className="wf-page">
<PageHeader title="Dashboard" desc="Todays money — dues, renewals, follow-ups, and the reminder queue." />
{dash.error !== undefined && <Notice tone="err">{dash.error}</Notice>}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined ? <EmptyState>Loading</EmptyState> : (
<>
<Stats>
<StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} />
<StatCard label="Queued reminders" value={String(v.totals.queued)} />
<StatCard label="Failed sends" value={String(v.totals.failed)} />
<StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
</Stats>
<h3>Reminder queue</h3>
{v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : (
<DataTable
columns={[
{ key: 'kind', label: 'Kind' }, { key: 'client', label: 'Client' },
{ key: 'ref', label: 'Reference' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' }, { key: 'act', label: '' },
]}
rows={v.queue.map((rem) => ({
kind: RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={toneFor(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))}
/>
)}
<Section title="Overdue" empty="No overdue invoices." rows={v.overdue}
columns={[{ key: 'no', label: 'Invoice' }, { key: 'client', label: 'Client' }, { key: 'days', label: 'Days', numeric: true }, { key: 'due', label: 'Outstanding', numeric: true }]}
onRowClick={(i) => nav(`/documents/${v.overdue[i]!.docId}`)}
map={(o) => ({ no: o.docNo ?? '—', client: o.clientName, days: o.daysOverdue, due: inr(o.outstandingPaise) })} />
<Section title="Due this week" empty="No recurring bills due this week." rows={v.dueThisWeek}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Runs on' }, { key: 'amt', label: 'Amount', numeric: true }]}
map={(d) => ({ client: d.clientName, on: d.nextRun, amt: d.amountPaise !== null ? inr(d.amountPaise) : 'from price book' })} />
<Section title="Renewals this month" empty="No renewals this month." rows={v.renewalsThisMonth}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Renews on' }]}
onRowClick={(i) => nav(`/clients/${v.renewalsThisMonth[i]!.clientId}`)}
map={(r) => ({ client: r.clientName, on: r.nextRenewal })} />
<Section title="Follow-ups today" empty="No follow-ups due." rows={v.followUpsToday}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Due' }, { key: 'notes', label: 'Notes' }]}
onRowClick={(i) => nav(`/clients/${v.followUpsToday[i]!.clientId}`)}
map={(f) => ({ client: f.clientName, on: f.followUpOn, notes: f.notes !== '' ? f.notes : '—' })} />
<Section title="Recent payments" empty="No payments recorded yet." rows={v.recentPayments}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' }, { key: 'amt', label: 'Amount', numeric: true }]}
map={(p) => ({ client: p.clientName, on: p.receivedOn, mode: p.mode, amt: inr(p.amountPaise) })} />
</>
)}
</div>
)
}
/** A titled table with an empty state — keeps the dashboard body declarative. */
function Section<T>(props: {
title: string; empty: string; rows: T[]
columns: { key: string; label: string; numeric?: boolean }[]
map: (row: T) => Record<string, ReactNode>; onRowClick?: (i: number) => void
}) {
return (
<>
<h3 style={{ marginTop: 20 }}>{props.title}</h3>
{props.rows.length === 0 ? <EmptyState>{props.empty}</EmptyState> : (
<DataTable
columns={props.columns}
{...(props.onRowClick !== undefined ? { onRowClick: (_r: unknown, i: number) => props.onRowClick!(i) } : {})}
rows={props.rows.map(props.map)}
/>
)}
</>
)
}
function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone: () => void; onError: (m: string) => void }) {
const [busy, setBusy] = useState(false)
const run = (p: Promise<unknown>) => {
setBusy(true); props.onError('')
p.then(props.onDone).catch((e: Error) => props.onError(e.message)).finally(() => setBusy(false))
}
return (
<div style={{ display: 'flex', gap: 6 }}>
{SENDABLE.has(props.rem.ruleKind) && (
<Button tone="primary" onClick={() => run(sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
)}
<Button onClick={() => run(dismissReminder(props.rem.id))}>Dismiss</Button>
</div>
)
}
Loading…
Cancel
Save