fix(client-360): audit fixes for onboarding status line, tickets, dashboard

Pre-ship audit swarm findings on the Client Detail redesign:

- StatusLine's isPaymentStep now also matches the legacy advance_paid/
  balance_paid keys (the generic fallback template's vocabulary on modules
  with no seeded per-module tail — WHATSAPP, ATM, AMC, ...), so ticking the
  payment step there opens the picker instead of silently stamping a date.
- Every milestone-tick path (plain toggle, payment link, credentials,
  document link) now reloads the client-modules list so the module block
  header, summary table and status select never go stale after a tick.
- A done payment/document milestone step now surfaces its link (linked
  document -> /documents/<id>; linked payment -> date/amount from the
  loaded ledger) instead of showing no evidence the link landed.
- Tickets: guard TICKET_STATUS_LABEL lookups against non-status chip
  values (e.g. the synthetic "overdue" chip) that were throwing and
  blanking the whole console.
- Dashboard: distinguish a failed "Onboarding stalled" fetch from a
  genuine empty result instead of reporting "Nothing stalled." on error.
- Client 360 tickets tab: add the Type column, same rendering as the
  Tickets desk.
- Drop the redundant read-only status badge next to the editable
  ClientModuleStatusSelect in the module block header.
- Only mount NewTicketDialog while actually open, so it stops firing
  getModules/getTicketTypes on every Client 360 load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 24 hours ago
parent b146de990f
commit 0c3f6c2e3a

@ -512,6 +512,13 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; }
.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 */
.cdr-node .ref {
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;
text-overflow: ellipsis; white-space: nowrap;
}
.cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */
.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; }
.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }

@ -14,10 +14,10 @@ import {
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets,
getBranches, createBranch, patchBranch, getTickets, getTicketTypes,
getMilestones, setMilestone, addMilestone,
generateBulkSmsPurchase,
CLIENT_STATUSES, KIND_LABEL,
CLIENT_STATUSES, KIND_LABEL, TICKET_TYPE_FALLBACK,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type Doc, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome,
type Payment, type RecurringPlan, type ServiceDetail,
@ -25,7 +25,7 @@ import {
import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, ticketTypeLabel,
} from './Tickets'
import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
@ -65,6 +65,9 @@ export function ClientDetail() {
const branches = useData(() => getBranches(id), [id])
const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
// Same classification list the Tickets desk uses, so the Type column reads identically here.
const ticketTypes = useData(getTicketTypes, [])
const ticketTypeOptions = ticketTypes.data ?? TICKET_TYPE_FALLBACK
// Undefined = closed. `moduleCode` pre-fills the module when the ticket is raised from
// a module block ("Raise ticket for this module") rather than the Tickets tab button.
const [ticketDialog, setTicketDialog] = useState<{ moduleCode?: string } | undefined>()
@ -317,8 +320,9 @@ export function ClientDetail() {
<details key={cm.id} open className="mod-section">
<summary>
<span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
<Badge>{KIND_LABEL[cm.kind]}</Badge>
{/* The editable select below is the one status control a read-only badge
next to it would just repeat the same value (audit fix: no dual status). */}
<span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', gap: 6 }}>
<ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />
{/* Support calls arrive per module — raise one without leaving the block. */}
@ -340,9 +344,15 @@ export function ClientDetail() {
clientModuleId={cm.id}
name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, key, label }) }}
// The server auto-advances client_module.status on a tick — reload so the
// block header / summary table / status select never go stale (audit fix).
onChanged={() => cms.reload()}
onOpenDocument={(documentId) => nav(`/documents/${documentId}`)}
onOpenPayments={() => setTab('payments')}
/>
<ModuleDocuments
cm={cm}
@ -372,7 +382,8 @@ export function ClientDetail() {
<DataTable
columns={[
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'kind', label: 'Kind' },
{ key: 'module', label: 'Module', mono: true }, { key: 'type', label: 'Type' },
{ key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
@ -383,6 +394,8 @@ export function ClientDetail() {
module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${id}?tab=modules`}>{t.moduleCode}</Link>
: '—',
// Same data + rendering as the Tickets desk (D-ticket-type parity).
type: ticketTypeLabel(ticketTypeOptions, t.type),
kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—',
@ -679,6 +692,9 @@ export function ClientDetail() {
.then(() => {
toast.ok('Payment linked')
setMilestonesVersion((v) => v + 1)
// The server auto-advances client_module.status on a milestone tick — reload
// so the module block header / summary table / status select don't go stale.
cms.reload()
setPaymentPicker(undefined)
})
.catch((e: Error) => setLinkError(e.message))
@ -703,6 +719,7 @@ export function ClientDetail() {
.then(() => {
toast.ok('Document linked')
setMilestonesVersion((v) => v + 1)
cms.reload()
setDocPicker(undefined)
})
.catch((e: Error) => setDocLinkError(e.message))
@ -712,15 +729,19 @@ export function ClientDetail() {
/>
)}
{/* Raised either from the Tickets tab or from a module block ("Raise ticket for this
module"), so it lives outside the tab switch the module code pre-fills. */}
<NewTicketDialog
open={ticketDialog !== undefined}
onClose={() => setTicketDialog(undefined)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
{...(ticketDialog?.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})}
/>
module"), so it lives outside the tab switch the module code pre-fills. Mounted
only while actually open it otherwise fires getModules + getTicketTypes on every
Client 360 load even though it's never opened. */}
{ticketDialog !== undefined && (
<NewTicketDialog
open
onClose={() => setTicketDialog(undefined)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
{...(ticketDialog.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})}
/>
)}
{credsDialog !== undefined && (
<CredentialsDialog
open
@ -755,6 +776,7 @@ export function ClientDetail() {
.then(() => {
toast.ok('Step marked done')
setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
@ -1392,9 +1414,10 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* dialog the parent (`ClientDetail`) owns, because each needs data this component does
* not hold:
* - the **payment** step (`payment_received`, plus the legacy `advance_payment` /
* `balance_payment` keys on un-migrated projects) calls `onTickPayment`
* `PaymentPickerDialog`, which links a real ledger payment
* (`setMilestone(..., paymentId)`) rather than stamping a bare date;
* `balance_payment` keys on un-migrated projects, AND the older `advance_paid` /
* `balance_paid` keys the generic fallback template uses on modules with no seeded
* per-module tail) calls `onTickPayment` `PaymentPickerDialog`, which links a real
* ledger payment (`setMilestone(..., paymentId)`) rather than stamping a bare date;
* - the `account_creation` **credentials** step calls `onTickCreds`
* `CredentialsDialog`, which captures the module's provider/username/password
* (managerial gate) at the moment the account actually exists;
@ -1402,15 +1425,27 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* `DocumentPickerDialog`, which links the quotation/proforma that was sent
* (`setMilestone(..., documentId)`) from the client's already-loaded ledger.
* 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
* date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a
* bare-toggled one.
* "+ 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
* link lands from outside this component.
* link lands from outside this component. `onChanged` additionally reloads the parent's
* client-modules list after every tick (plain toggle included) so the server's
* auto-advanced `client_module.status` never goes stale in the block header / summary
* table / status select.
*/
function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number
payments: Payment[]
onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void
onTickDocument: (key: string, label: string) => void
/** Reloads the parent's client-modules list every tick path calls this so the module
* block header / summary table / status select never show a stale status (audit fix). */
onChanged: () => void
onOpenDocument: (documentId: string) => void
onOpenPayments: () => void
}) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken])
@ -1423,7 +1458,7 @@ function StatusLine(props: {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload())
.then(() => { list.reload(); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey(''))
}
@ -1440,10 +1475,15 @@ function StatusLine(props: {
const done = ms?.filter((m) => m.done).length ?? 0
// current = first not-done step
const currentKey = ms?.find((m) => !m.done)?.key
// `payment_received` is the key every shipped template uses; advance_/balance_payment are
// legacy keys that still exist on projects not yet run through the onboarding migration.
// `payment_received` is the key every shipped (seeded) template uses; `advance_payment` /
// `balance_payment` are legacy keys still on projects not yet run through the onboarding
// migration; `advance_paid` / `balance_paid` (note: `_paid`, not `_payment`) are the OLDER
// legacy vocabulary from the generic fallback template — the one modules with no seeded
// per-module tail (WHATSAPP, ATM, AMC, …) actually get. Without these two, ticking the
// payment step on those modules silently stamped a bare date instead of opening the picker.
const isPaymentStep = (key: string) =>
key === 'payment_received' || key === 'advance_payment' || key === 'balance_payment'
|| key === 'advance_paid' || key === 'balance_paid'
const isCredsStep = (key: string) => key === 'account_creation'
const isDocumentStep = (key: string) => key === 'quotation_sent'
@ -1506,6 +1546,27 @@ function StatusLine(props: {
</button>
<span className="nm">{m.label}</span>
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
{/* The linked payment/document itself otherwise a done payment/quotation
step shows only a date, with no evidence the link actually landed. */}
{m.done && m.documentId !== null && (
<button
type="button" className="ref" title="Open the linked document"
onClick={() => props.onOpenDocument(m.documentId!)}
>
view document
</button>
)}
{m.done && m.paymentId !== null && (() => {
const p = props.payments.find((x) => x.id === m.paymentId)
return (
<button
type="button" className="ref" title="The linked payment — open Payments & plans"
onClick={() => props.onOpenPayments()}
>
{p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'}
</button>
)
})()}
</div>
))}
</div></div>

@ -145,11 +145,19 @@ export function Dashboard() {
))}
</Card>
<Card title="Onboarding stalled" count={stalled.data?.length ?? 0} empty="Nothing stalled." isEmpty={(stalled.data ?? []).length === 0}>
{(stalled.data ?? []).map((p) => (
<Row key={p.clientModuleId} onClick={() => nav(`/clients/${p.clientId}?tab=modules`)}
title={p.clientName} sub={p.moduleName} />
))}
<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}>

@ -380,7 +380,9 @@ export function Tickets() {
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? (
<EmptyState>
No {status !== 'all' ? TICKET_STATUS_LABEL[status as TicketStatus].toLowerCase() : ''} tickets
{/* `status` can be 'overdue' (a synthetic chip, not a real TicketStatus) or 'all'
guarded with a raw-key fallback so an unmapped value never throws (was a white-screen bug). */}
No {status !== 'all' ? (TICKET_STATUS_LABEL[status as TicketStatus] ?? status).toLowerCase() : ''} tickets
{scope === 'mine' ? ' of yours' : ''}{q !== '' ? ` matching “${q}` : ''}.
</EmptyState>
) : (

Loading…
Cancel
Save