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

137 lines
6.8 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 { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
import { getDashboard, isManagerial } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients'
const inr = (p: number) => formatINR(p)
/** Dashboard home — today's money + the manual reminder queue (spec §3). */
export function Dashboard() {
const nav = useNavigate()
// My Day (D18 WS-E): owner/manager toggle Mine ↔ Everyone; staff are always
// scoped to their own book server-side, so they get no toggle.
const managerial = isManagerial()
const [mine, setMine] = useState(false)
const dash = useData(() => getDashboard(mine), [mine])
const [err, setErr] = useState('')
const v = dash.data
return (
<div className="wf-page">
<PageHeader
title={managerial && mine ? 'My Day' : 'Dashboard'}
desc="Todays money — dues, renewals, follow-ups, and the reminder queue."
{...(managerial ? {
actions: (
<span style={{ display: 'flex', gap: 4 }}>
<button type="button" className={`wf${!mine ? ' active' : ''}`} onClick={() => setMine(false)}>Everyone</button>
<button type="button" className={`wf${mine ? ' active' : ''}`} onClick={() => setMine(true)}>Mine</button>
</span>
),
} : {})}
/>
{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 style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
Reminder queue
<Link to="/reminders" style={{ fontSize: 13, fontWeight: 500 }}>View all </Link>
</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: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))}
/>
)}
{v.queueTotal > v.queue.length && (
// No silent caps (rule 8): the table is the first page only — say so.
<p style={{ marginTop: 6, fontSize: 13, color: 'var(--text-muted, #666)' }}>
Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>.
</p>
)}
<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)}
/>
)}
</>
)
}