|
|
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>Here’s 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 & 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>
|
|
|
}
|