merge: readable linked payment/document chip + SMS alert loading/error state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/hq-web/src/pages/Dashboard.tsx
main
Thomas Joise 1 day ago
commit a267cd07e6

@ -517,11 +517,15 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.cdr-node.done .nm { color: var(--text-dim); } .cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; } .cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; } .cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; }
/* linked payment/document reference under a done step's date */ /* Linked payment/document reference under a done step's date. It WRAPS (up to two short
lines) instead of being ellipsised to one 104px line: the reference exists to show the
payment's amount / the document's number, and a single mono line at this width cut
exactly that off. Wrapping grows the node downwards only, so the track keeps its 112px
columns and its horizontal scroll on narrow screens. */
.cdr-node .ref { .cdr-node .ref {
font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none; font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none;
padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px; overflow: hidden; padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px;
text-overflow: ellipsis; white-space: nowrap; white-space: normal; overflow-wrap: anywhere; line-height: 1.3; text-align: center;
} }
.cdr-node .ref:hover { text-decoration: underline; } .cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */ /* outstanding panel */

@ -346,6 +346,7 @@ export function ClientDetail() {
name={moduleName(cm.moduleId)} name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion} refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []} payments={ledger.data?.payments ?? []}
docs={ledger.data?.documents ?? []}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }} onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, key, label }) }} onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, key, label }) }}
@ -1428,7 +1429,10 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* Un-ticking any of them toggles directly (the server clears the link). * Un-ticking any of them toggles directly (the server clears the link).
* A done step carrying `paymentId`/`documentId` also renders a small reference under its * A done step carrying `paymentId`/`documentId` also renders a small reference under its
* date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a * date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a
* bare-toggled one. * bare-toggled one. That reference is resolved against the client's ledger (`payments` /
* `docs`) so it reads as the real date + amount / document number, wraps to a second line
* inside the node rather than being ellipsised, and carries the full detail in its
* tooltip + accessible name.
* "+ Add step" appends a project-specific milestone. Every change is one audited POST * "+ Add step" appends a project-specific milestone. Every change is one audited POST
* that returns the fresh list; `refreshToken` lets the parent force a refetch after a * that returns the fresh list; `refreshToken` lets the parent force a refetch after a
* link lands from outside this component. `onChanged` additionally reloads the parent's * link lands from outside this component. `onChanged` additionally reloads the parent's
@ -1438,7 +1442,10 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
*/ */
function StatusLine(props: { function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number clientModuleId: string; name: string; refreshToken: number
/** The client's ledger — used to render the linked payment / document by its real
* date + amount (and to fill the reference's tooltip), not a generic "linked" label. */
payments: Payment[] payments: Payment[]
docs: Doc[]
onTickPayment: (key: string, label: string) => void onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void onTickCreds: (key: string, label: string) => void
onTickDocument: (key: string, label: string) => void onTickDocument: (key: string, label: string) => void
@ -1548,20 +1555,33 @@ function StatusLine(props: {
<span className="nm">{m.label}</span> <span className="nm">{m.label}</span>
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span> <span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
{/* The linked payment/document itself otherwise a done payment/quotation {/* The linked payment/document itself otherwise a done payment/quotation
step shows only a date, with no evidence the link actually landed. */} step shows only a date, with no evidence the link actually landed. The
{m.done && m.documentId !== null && ( reference wraps inside the node (it used to be ellipsised to one 104px
line, which cut the amount the one thing it exists to show), and the
tooltip/accessible name carries the full detail rather than static text. */}
{m.done && m.documentId !== null && (() => {
const d = props.docs.find((x) => x.id === m.documentId)
const detail = d === undefined
? 'Linked document — open it'
: `${docLabel(d)} ${d.docNo ?? '(draft)'} · ${d.docDate} · ${inr(d.payablePaise)} — open it`
return (
<button <button
type="button" className="ref" title="Open the linked document" type="button" className="ref" title={detail} aria-label={detail}
onClick={() => props.onOpenDocument(m.documentId!)} onClick={() => props.onOpenDocument(m.documentId!)}
> >
view document {d === undefined ? 'view document' : d.docNo ?? `${docLabel(d)} (draft)`}
</button> </button>
)} )
})()}
{m.done && m.paymentId !== null && (() => { {m.done && m.paymentId !== null && (() => {
const p = props.payments.find((x) => x.id === m.paymentId) const p = props.payments.find((x) => x.id === m.paymentId)
const detail = p === undefined
? 'Linked payment — open Payments & plans'
: `Payment ${p.receivedOn} · ${inr(p.amountPaise)}`
+ `${p.reference !== '' ? ` · ${p.reference}` : ''} — open Payments & plans`
return ( return (
<button <button
type="button" className="ref" title="The linked payment — open Payments & plans" type="button" className="ref" title={detail} aria-label={detail}
onClick={() => props.onOpenPayments()} onClick={() => props.onOpenPayments()}
> >
{p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'} {p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'}

@ -70,13 +70,27 @@ export function Dashboard() {
</div> </div>
</section> </section>
{/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */} {/* SMS low-balance alert (D28) surfaced here so it's seen without opening the SMS
{smsLow.data !== undefined && smsLow.data.lowCount > 0 && ( screen. Three states, never collapsed into one: while the check is in flight a
skeleton stands in; a FAILED check says so with a retry (rendering nothing there
would read as "no client is low" the dangerous direction); and only a loaded,
genuinely-empty result gets the positive line. `unknownCount` is spelled out in
that line because balances the gateway couldn't return aren't "fine" either. */}
{smsLow.error !== undefined ? (
<ErrorState message={`SMS balances could not be checked — ${smsLow.error}`} onRetry={smsLow.reload} />
) : smsLow.data === undefined ? (
<Skeleton rows={1} />
) : smsLow.data.lowCount > 0 ? (
<Notice tone="warn"> <Notice tone="warn">
<Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}> <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 {smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up
</Link> </Link>
</Notice> </Notice>
) : (
<p className="dash-cap">
SMS balances checked none low
{smsLow.data.unknownCount > 0 ? ` (${smsLow.data.unknownCount} could not be read)` : ''}.
</p>
)} )}
{/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */} {/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */}
@ -186,7 +200,9 @@ export function Dashboard() {
} }
/** One bento card: an uppercase-labelled header (+ count and optional link) over a /** 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. */ * scrollable list, or an empty-state line when there's nothing to show. Cards fed by
* their own request pass `loading` so a fetch still in flight shows a skeleton instead
* of the empty line "nothing to show" must mean the data loaded and was empty. */
function Card(props: { function Card(props: {
title: string; count?: number; link?: { to: string; label?: string } title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode

Loading…
Cancel
Save