diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index a8f8618..c6bd3f0 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -925,6 +925,8 @@ export interface SmsBalanceRow { cmId: string; clientId: string; clientName: string; clientCode: string balanceText: string | null; balance: number | null; low: boolean reseller: string | null; status: string + provider: string | null; hasCreds: boolean + checkStatus: string | null; checkMessage: string | null; checkedAt: string | null } export interface SmsBalances { rows: SmsBalanceRow[]; threshold: number @@ -933,6 +935,16 @@ export interface SmsBalances { export const getSmsBalances = (): Promise => apiFetch('/reports/sms-balances') +// D28: live balance fetch from the gateway (managerial — decrypts credentials). +export interface SmsRefreshSummary { + attempted: number; ok: number; failed: number; changed: number; skipped: number +} +export const refreshSmsBalances = (): Promise => + apiFetch<{ summary: SmsRefreshSummary }>('/reports/sms-balances/refresh', { method: 'POST', body: '{}' }).then((r) => r.summary) +export const refreshOneSmsBalance = (cmId: string): Promise<{ ok: boolean; balance: number | null; message: string }> => + apiFetch<{ result: { ok: boolean; balance: number | null; message: string } }>( + `/client-modules/${cmId}/refresh-sms-balance`, { method: 'POST', body: '{}' }).then((r) => r.result) + // D27: audit log viewer (owner-only). export interface AuditRow { id: string; at_wall: string; user_id: string; action: string diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index 74b8cc5..08665ea 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -1,14 +1,31 @@ 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 { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui' +import { displayName, getDashboard, getSmsBalances, 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). */ +/** '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 @@ -16,27 +33,52 @@ export function Dashboard() { 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 [err, setErr] = useState('') const v = dash.data + const name = displayName() return (
- - - - - ), - } : {})} - /> {dash.error !== undefined && } {err !== '' && {err}} - {v === undefined && dash.error === undefined ? : v === undefined ? null : ( + {v === undefined && dash.error === undefined ? : v === undefined ? null : ( <> + {/* Today hero — date, greeting, the headline overdue figure, scope + quick actions. */} +
+
+
{longDate(v.today)}
+

Welcome back{name !== '' ? `, ${name}` : ''}

+

Here’s what needs you today — dues to chase, renewals landing, and the reminder queue.

+ {managerial && ( +
+ + +
+ )} +
+
+
+
Overdue
+
{inr(v.totals.overduePaise)}
+
+
+ + +
+
+
+ + {/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */} + {smsLow.data !== undefined && smsLow.data.lowCount > 0 && ( + + + {smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance — review & top up → + + + )} + + {/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */} @@ -44,69 +86,78 @@ export function Dashboard() { -

- Reminder queue - View all → -

- {v.queue.length === 0 ? Nothing waiting — all clear. : ( - { - 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: {rem.status}, - error: rem.error ?? '—', - act: , - }))} - /> - )} - {v.queueTotal > v.queue.length && ( - // No silent caps (rule 8): the table is the first page only — say so. -

- Showing {v.queue.length} of {v.queueTotal} — see the full queue. -

- )} + {/* Reminder queue — full send/preview/dismiss actions preserved. */} +
+
+

Reminder queue

+ {v.queueTotal} + View all → +
+ {v.queue.length === 0 ? Nothing waiting — all clear. : ( + (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: {rem.status}, + error: rem.error ?? '—', + act: , + }))} + /> + )} + {v.queueTotal > v.queue.length && ( + // No silent caps (rule 8): the table is the first page only — say so. +

+ Showing {v.queue.length} of {v.queueTotal} — see the full queue. +

+ )} +
-
nav(`/documents/${v.overdue[i]!.docId}`)} - map={(o) => ({ no: o.docNo ?? '—', client: o.clientName, days: o.daysOverdue, due: inr(o.outstandingPaise) })} /> + {/* Bento — the day's read-only sections. */} +
+ + {v.overdue.map((o) => ( + nav(`/documents/${o.docId}`)} + title={{o.docNo ?? '—'}} sub={o.clientName} + right={inr(o.outstandingPaise)} rightSub={`${o.daysOverdue}d overdue`} /> + ))} + -
-
-
({ client: d.clientName, on: d.nextRun, amt: d.amountPaise !== null ? inr(d.amountPaise) : 'from price book' })} /> -
+ + {v.dueThisWeek.map((d) => ( + nav(`/clients/${d.clientId}`)} + title={d.clientName} sub={`runs ${shortDate(d.nextRun)}`} + {...(d.amountPaise !== null ? { right: inr(d.amountPaise) } : { rightSub: 'from price book' })} /> + ))} + -
-
nav(`/clients/${v.renewalsThisMonth[i]!.clientId}`)} - map={(r) => ({ client: r.clientName, on: r.nextRenewal })} /> -
+ + {v.renewalsThisMonth.map((r) => ( + nav(`/clients/${r.clientId}`)} + title={r.clientName} rightSub={shortDate(r.nextRenewal)} /> + ))} + -
-
nav(`/clients/${v.followUpsToday[i]!.clientId}`)} - map={(f) => ({ client: f.clientName, on: f.followUpOn, notes: f.notes !== '' ? f.notes : '—' })} /> -
+ + {v.followUpsToday.map((f) => ( + nav(`/clients/${f.clientId}`)} + title={f.clientName} sub={f.notes !== '' ? f.notes : '—'} /> + ))} + -
-
({ client: p.clientName, on: p.receivedOn, mode: p.mode, amt: inr(p.amountPaise) })} /> -
+ + {v.recentPayments.map((p) => ( + nav(`/clients/${p.clientId}`)} + title={p.clientName} sub={`${shortDate(p.receivedOn)} · ${p.mode}`} + right={inr(p.amountPaise)} /> + ))} +
)} @@ -114,23 +165,44 @@ export function Dashboard() { ) } -/** A titled table with an empty state — keeps the dashboard body declarative. */ -function Section(props: { - title: string; empty: string; rows: T[] - columns: { key: string; label: string; numeric?: boolean }[] - map: (row: T) => Record; onRowClick?: (i: number) => void +/** 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 ( +
+
+

{props.title}

+ {props.count !== undefined && {props.count}} + {props.link !== undefined && {props.link.label ?? 'All'} →} +
+ {props.isEmpty ?
{props.empty}
:
{props.children}
} +
+ ) +} + +/** One list row — a title/sub on the left, an optional amount/sub on the right. + * Given `onClick`, it renders as a keyboard-operable + :
{body}
} - diff --git a/apps/hq-web/src/pages/SmsBalances.tsx b/apps/hq-web/src/pages/SmsBalances.tsx index ae13f97..bf58f56 100644 --- a/apps/hq-web/src/pages/SmsBalances.tsx +++ b/apps/hq-web/src/pages/SmsBalances.tsx @@ -3,11 +3,26 @@ import { Link, useNavigate } from 'react-router-dom' import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast, } from '@sims/ui' -import { getSmsBalances, generateModuleRenewalQuote, type SmsBalanceRow } from '../api' +import { + getSmsBalances, generateModuleRenewalQuote, refreshSmsBalances, isManagerial, type SmsBalanceRow, +} from '../api' import { useData } from './Clients' type Scope = 'low' | 'all' | 'unknown' +/** "3h ago" / "2d ago" from an ISO timestamp — compact freshness for the Checked column. */ +function ago(iso: string | null): string { + if (iso === null) return 'never' + const ms = Date.now() - Date.parse(iso) + if (Number.isNaN(ms)) return '—' + const mins = Math.floor(ms / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.floor(hrs / 24)}d ago` +} + /** * SMS credit review (D28): every SMS client with its balance, lowest first, so the * ones about to run dry are at the top. "Low" (below the threshold) is the default @@ -20,8 +35,21 @@ export function SmsBalances() { const toast = useToast() const [scope, setScope] = useState('low') const [busy, setBusy] = useState() + const [refreshing, setRefreshing] = useState(false) const list = useData(getSmsBalances, []) + const refreshAll = () => { + if (refreshing) return + setRefreshing(true) + refreshSmsBalances() + .then((s) => { + toast.ok(`Checked ${s.attempted}: ${s.ok} ok, ${s.changed} updated${s.failed > 0 ? `, ${s.failed} failed` : ''}${s.skipped > 0 ? `, ${s.skipped} no login` : ''}`) + list.reload() + }) + .catch((e: Error) => toast.err(e.message)) + .finally(() => setRefreshing(false)) + } + const raiseTopup = (r: SmsBalanceRow) => { if (busy !== undefined) return setBusy(r.cmId) @@ -43,6 +71,9 @@ export function SmsBalances() { {refreshing ? 'Checking…' : 'Refresh from gateway'} + : undefined} /> {data !== undefined && data.lowCount > 0 && scope !== 'low' && ( @@ -75,7 +106,8 @@ export function SmsBalances() { columns={[ { key: 'client', label: 'Client' }, { key: 'balance', label: 'Balance', numeric: true }, - { key: 'reseller', label: 'Reseller' }, + { key: 'provider', label: 'Provider' }, + { key: 'checked', label: 'Checked' }, { key: 'actions', label: '' }, ]} rowTone={(i) => (shown[i]!.low ? 'err' : undefined)} @@ -88,7 +120,12 @@ export function SmsBalances() { {r.balance.toLocaleString('en-IN')} ), - reseller: r.reseller ?? '—', + provider: r.provider ?? '—', + checked: r.checkStatus === 'error' + ? ⚠ {ago(r.checkedAt)} + : !r.hasCreds + ? no login + : {ago(r.checkedAt)}, actions: ( e.stopPropagation()}>