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

225 lines
11 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, Skeleton, StatCard, Stats } from '@sims/ui'
import { displayName, getDashboard, getSmsBalances, getStalledProjects, isManagerial } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients'
const inr = (p: number) => formatINR(p)
/** 'YYYY-MM-DD' → 'Sunday 19 July 2026' (falls back to the raw string if unparseable). */
const longDate = (iso: string): string => {
const d = new Date(`${iso}T00:00:00`)
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
}
/** 'YYYY-MM-DD' → '19 Jul' (falls back to the raw string if unparseable). */
const shortDate = (iso: string): string => {
const d = new Date(`${iso}T00:00:00`)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
/**
* Dashboard home — the "cockpit" (D29): a Today hero strip, the four KPI stat
* cards, the reminder queue, then a bento grid of the day's sections. Low-colour
* on purpose — ink + neutrals + one teal accent, red only for real failures.
*/
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 smsLow = useData(getSmsBalances, []) // D28: low-balance alert, seen even without opening the SMS screen
const stalled = useData(getStalledProjects, []) // parked onboarding — active projects with no recent progress
const [err, setErr] = useState('')
const v = dash.data
const name = displayName()
return (
<div className="wf-page">
{dash.error !== undefined && <ErrorState message={dash.error} onRetry={dash.reload} />}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined && dash.error === undefined ? <Skeleton rows={6} /> : v === undefined ? null : (
<>
{/* Today hero — date, greeting, the headline overdue figure, scope + quick actions. */}
<section className="dash-hero">
<div className="dash-hero-l">
<div className="dash-hero-eyebrow">{longDate(v.today)}</div>
<h1>Welcome back{name !== '' ? `, ${name}` : ''}</h1>
<p>Heres what needs you today dues to chase, renewals landing, and the reminder queue.</p>
{managerial && (
<div className="dash-seg" role="group" aria-label="Scope">
<button type="button" className={!mine ? 'on' : ''} onClick={() => setMine(false)}>Everyone</button>
<button type="button" className={mine ? 'on' : ''} onClick={() => setMine(true)}>Mine</button>
</div>
)}
</div>
<div className="dash-hero-r">
<div className="dash-money">
<div className="k">Overdue</div>
<div className="v num">{inr(v.totals.overduePaise)}</div>
</div>
<div className="dash-hero-actions">
<button type="button" className="wf primary" onClick={() => nav('/documents/new')}>New document</button>
<button type="button" className="wf" onClick={() => nav('/clients?new=1')}>New client</button>
</div>
</div>
</section>
{/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */}
{smsLow.data !== undefined && smsLow.data.lowCount > 0 && (
<Notice tone="warn">
<Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}>
{smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up
</Link>
</Notice>
)}
{/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */}
<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>
{/* Reminder queue — full send/preview/dismiss actions preserved. */}
<section className="dash-queue">
<div className="dash-head">
<h3>Reminder queue</h3>
<span className="count">{v.queueTotal}</span>
<Link to="/reminders">View all </Link>
</div>
{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) => (v.queue[i]?.status === '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 className="dash-cap">
Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>.
</p>
)}
</section>
{/* Bento — the day's read-only sections. */}
<div className="dash-bento">
<Card title="Overdue invoices" count={v.overdue.length} empty="No overdue invoices." isEmpty={v.overdue.length === 0}>
{v.overdue.map((o) => (
<Row key={o.docId} onClick={() => nav(`/documents/${o.docId}`)}
title={<span className="mono">{o.docNo ?? '—'}</span>} sub={o.clientName}
right={inr(o.outstandingPaise)} rightSub={`${o.daysOverdue}d overdue`} />
))}
</Card>
<Card title="Due this week" count={v.dueThisWeek.length} empty="No recurring bills due this week." isEmpty={v.dueThisWeek.length === 0}>
{v.dueThisWeek.map((d) => (
<Row key={d.planId} onClick={() => nav(`/clients/${d.clientId}`)}
title={d.clientName} sub={`runs ${shortDate(d.nextRun)}`}
{...(d.amountPaise !== null ? { right: inr(d.amountPaise) } : { rightSub: 'from price book' })} />
))}
</Card>
<Card title="Renewals this month" count={v.renewalsThisMonth.length} link={{ to: '/renewals' }} empty="No renewals this month." isEmpty={v.renewalsThisMonth.length === 0}>
{v.renewalsThisMonth.map((r) => (
<Row key={r.clientModuleId} onClick={() => nav(`/clients/${r.clientId}`)}
title={r.clientName} rightSub={shortDate(r.nextRenewal)} />
))}
</Card>
<Card
title="Onboarding stalled" count={stalled.data?.length ?? 0}
empty="Nothing stalled."
// A fetch failure is not the same as a genuine empty result show the error
// (with retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={stalled.error === undefined && (stalled.data ?? []).length === 0}
>
{stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} />
: (stalled.data ?? []).map((p) => (
<Row key={p.clientModuleId} onClick={() => nav(`/clients/${p.clientId}?tab=modules`)}
title={p.clientName} sub={p.moduleName} />
))}
</Card>
<Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}>
{v.followUpsToday.map((f) => (
<Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)}
title={f.clientName} sub={f.notes !== '' ? f.notes : '—'} />
))}
</Card>
<Card title="Recent payments" count={v.recentPayments.length} empty="No payments recorded yet." isEmpty={v.recentPayments.length === 0}>
{v.recentPayments.map((p) => (
<Row key={p.id} onClick={() => nav(`/clients/${p.clientId}`)}
title={p.clientName} sub={`${shortDate(p.receivedOn)} · ${p.mode}`}
right={inr(p.amountPaise)} />
))}
</Card>
</div>
</>
)}
</div>
)
}
/** One bento card: an uppercase-labelled header (+ count and optional link) over a
* scrollable list, or an empty-state line when there's nothing to show. */
function Card(props: {
title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; children: ReactNode
}) {
return (
<section className="dash-card">
<div className="dash-head">
<h3>{props.title}</h3>
{props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div>
{props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section>
)
}
/** One list row — a title/sub on the left, an optional amount/sub on the right.
* Given `onClick`, it renders as a keyboard-operable <button>. */
function Row(props: {
title: ReactNode; sub?: ReactNode; right?: ReactNode; rightSub?: ReactNode; onClick?: () => void
}) {
const body = (
<>
<span className="rc">
<span className="t">{props.title}</span>
{props.sub !== undefined && <span className="s">{props.sub}</span>}
</span>
{(props.right !== undefined || props.rightSub !== undefined) && (
<span className="r">
{props.right !== undefined && <span className="amt">{props.right}</span>}
{props.rightSub !== undefined && <span className="sub">{props.rightSub}</span>}
</span>
)}
</>
)
return props.onClick !== undefined
? <button type="button" className="dash-row" onClick={props.onClick}>{body}</button>
: <div className="dash-row">{body}</div>
}