|
|
|
@ -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="Today’s 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>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|