You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/Dashboard.tsx

162 lines
8.1 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
import { getDashboard, sendReminder, dismissReminder, getReminderPreview, 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 && <ErrorState message={dash.error} onRetry={dash.reload} />}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined && dash.error === undefined ? <Skeleton rows={5} /> : v === undefined ? null : (
<>
<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)} {...(v.totals.failed > 0 ? { hint: 'needs attention', hintTone: 'err' as const } : {})} />
<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: '' },
]}
rowTone={(i) => {
const s = v.queue[i]?.status
return s === 'failed' ? 'err' : undefined
}}
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) })} />
<div className="dash-grid">
<section>
<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>
<section>
<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>
<section>
<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>
<section>
<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) })} />
</section>
</div>
</>
)}
</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 [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
const [loading, setLoading] = useState(false)
const run = (p: Promise<unknown>) => {
setBusy(true); props.onError('')
p.catch((e: Error) => props.onError(e.message))
// Reload even on failure: the server has already parked the reminder as
// 'failed' with its error, and the row must flip live.
.then(props.onDone)
.finally(() => setBusy(false))
}
const preview = () => {
if (mail !== undefined) { setMail(undefined); return } // toggle closed
setLoading(true); props.onError('')
getReminderPreview(props.rem.id)
.then(setMail)
.catch((e: Error) => props.onError(e.message))
.finally(() => setLoading(false))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6 }}>
{SENDABLE.has(props.rem.ruleKind) && (
<>
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
<Button tone="primary" onClick={() => run(sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
</>
)}
<Button onClick={() => run(dismissReminder(props.rem.id))}>Dismiss</Button>
</div>
{mail !== undefined && (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mail.subject}</div>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Body</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'inherit', fontSize: 13 }}>{mail.body}</pre>
</div>
)}
</div>
)
}