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 1 day 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.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 */
.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 */ /* outstanding panel */
.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; } .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); } .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, getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction, getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getCompanyProfile, getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets, getBranches, createBranch, patchBranch, getTickets, getTicketTypes,
getMilestones, setMilestone, addMilestone, getMilestones, setMilestone, addMilestone,
generateBulkSmsPurchase, generateBulkSmsPurchase,
CLIENT_STATUSES, KIND_LABEL, CLIENT_STATUSES, KIND_LABEL, TICKET_TYPE_FALLBACK,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, 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 ClientStatus, type Doc, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome,
type Payment, type RecurringPlan, type ServiceDetail, type Payment, type RecurringPlan, type ServiceDetail,
@ -25,7 +25,7 @@ import {
import { buildStatement } from '../statement' import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, ticketTypeLabel,
} from './Tickets' } from './Tickets'
import { import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect, AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
@ -65,6 +65,9 @@ export function ClientDetail() {
const branches = useData(() => getBranches(id), [id]) const branches = useData(() => getBranches(id), [id])
const [ticketPage, setTicketPage] = useState(1) const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage]) 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 // 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. // a module block ("Raise ticket for this module") rather than the Tickets tab button.
const [ticketDialog, setTicketDialog] = useState<{ moduleCode?: string } | undefined>() const [ticketDialog, setTicketDialog] = useState<{ moduleCode?: string } | undefined>()
@ -317,8 +320,9 @@ export function ClientDetail() {
<details key={cm.id} open className="mod-section"> <details key={cm.id} open className="mod-section">
<summary> <summary>
<span className="mod-name">{moduleName(cm.moduleId)}</span> <span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
<Badge>{KIND_LABEL[cm.kind]}</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 }}> <span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', gap: 6 }}>
<ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} /> <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />
{/* Support calls arrive per module — raise one without leaving the block. */} {/* Support calls arrive per module — raise one without leaving the block. */}
@ -340,9 +344,15 @@ export function ClientDetail() {
clientModuleId={cm.id} clientModuleId={cm.id}
name={moduleName(cm.moduleId)} name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion} refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []}
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 }) }}
// 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 <ModuleDocuments
cm={cm} cm={cm}
@ -372,7 +382,8 @@ export function ClientDetail() {
<DataTable <DataTable
columns={[ columns={[
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' }, { 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: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' }, { key: 'status', label: 'Status' }, { key: 'act', label: '' },
]} ]}
@ -383,6 +394,8 @@ export function ClientDetail() {
module: t.moduleCode !== null && t.moduleCode !== '' module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${id}?tab=modules`}>{t.moduleCode}</Link> ? <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 : '—', kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />, desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—', assigned: t.assignedName ?? '—',
@ -679,6 +692,9 @@ export function ClientDetail() {
.then(() => { .then(() => {
toast.ok('Payment linked') toast.ok('Payment linked')
setMilestonesVersion((v) => v + 1) 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) setPaymentPicker(undefined)
}) })
.catch((e: Error) => setLinkError(e.message)) .catch((e: Error) => setLinkError(e.message))
@ -703,6 +719,7 @@ export function ClientDetail() {
.then(() => { .then(() => {
toast.ok('Document linked') toast.ok('Document linked')
setMilestonesVersion((v) => v + 1) setMilestonesVersion((v) => v + 1)
cms.reload()
setDocPicker(undefined) setDocPicker(undefined)
}) })
.catch((e: Error) => setDocLinkError(e.message)) .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 {/* 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. */} module"), so it lives outside the tab switch the module code pre-fills. Mounted
<NewTicketDialog only while actually open it otherwise fires getModules + getTicketTypes on every
open={ticketDialog !== undefined} Client 360 load even though it's never opened. */}
onClose={() => setTicketDialog(undefined)} {ticketDialog !== undefined && (
onCreated={() => tickets.reload()} <NewTicketDialog
kinds={tickets.data?.kinds ?? []} open
initialClient={{ id: c.id, code: c.code, name: c.name }} onClose={() => setTicketDialog(undefined)}
{...(ticketDialog?.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})} 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 && ( {credsDialog !== undefined && (
<CredentialsDialog <CredentialsDialog
open open
@ -755,6 +776,7 @@ export function ClientDetail() {
.then(() => { .then(() => {
toast.ok('Step marked done') toast.ok('Step marked done')
setMilestonesVersion((v) => v + 1) setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined) setCredsDialog(undefined)
}) })
.catch((e: Error) => setCredsError(e.message)) .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 * dialog the parent (`ClientDetail`) owns, because each needs data this component does
* not hold: * not hold:
* - the **payment** step (`payment_received`, plus the legacy `advance_payment` / * - the **payment** step (`payment_received`, plus the legacy `advance_payment` /
* `balance_payment` keys on un-migrated projects) calls `onTickPayment` * `balance_payment` keys on un-migrated projects, AND the older `advance_paid` /
* `PaymentPickerDialog`, which links a real ledger payment * `balance_paid` keys the generic fallback template uses on modules with no seeded
* (`setMilestone(..., paymentId)`) rather than stamping a bare date; * 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` * - the `account_creation` **credentials** step calls `onTickCreds`
* `CredentialsDialog`, which captures the module's provider/username/password * `CredentialsDialog`, which captures the module's provider/username/password
* (managerial gate) at the moment the account actually exists; * (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 * `DocumentPickerDialog`, which links the quotation/proforma that was sent
* (`setMilestone(..., documentId)`) from the client's already-loaded ledger. * (`setMilestone(..., documentId)`) from the client's already-loaded ledger.
* 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
* 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 * "+ 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. * 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: { function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number clientModuleId: string; name: string; refreshToken: number
payments: Payment[]
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
/** 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 toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken]) const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken])
@ -1423,7 +1458,7 @@ function StatusLine(props: {
if (busyKey !== '') return if (busyKey !== '') return
setBusyKey(m.key) setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn) setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload()) .then(() => { list.reload(); props.onChanged() })
.catch((e: Error) => toast.err(e.message)) .catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey('')) .finally(() => setBusyKey(''))
} }
@ -1440,10 +1475,15 @@ function StatusLine(props: {
const done = ms?.filter((m) => m.done).length ?? 0 const done = ms?.filter((m) => m.done).length ?? 0
// current = first not-done step // current = first not-done step
const currentKey = ms?.find((m) => !m.done)?.key const currentKey = ms?.find((m) => !m.done)?.key
// `payment_received` is the key every shipped template uses; advance_/balance_payment are // `payment_received` is the key every shipped (seeded) template uses; `advance_payment` /
// legacy keys that still exist on projects not yet run through the onboarding migration. // `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) => const isPaymentStep = (key: string) =>
key === 'payment_received' || key === 'advance_payment' || key === 'balance_payment' key === 'payment_received' || key === 'advance_payment' || key === 'balance_payment'
|| key === 'advance_paid' || key === 'balance_paid'
const isCredsStep = (key: string) => key === 'account_creation' const isCredsStep = (key: string) => key === 'account_creation'
const isDocumentStep = (key: string) => key === 'quotation_sent' const isDocumentStep = (key: string) => key === 'quotation_sent'
@ -1506,6 +1546,27 @@ function StatusLine(props: {
</button> </button>
<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
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></div> </div></div>

@ -145,11 +145,19 @@ export function Dashboard() {
))} ))}
</Card> </Card>
<Card title="Onboarding stalled" count={stalled.data?.length ?? 0} empty="Nothing stalled." isEmpty={(stalled.data ?? []).length === 0}> <Card
{(stalled.data ?? []).map((p) => ( title="Onboarding stalled" count={stalled.data?.length ?? 0}
<Row key={p.clientModuleId} onClick={() => nav(`/clients/${p.clientId}?tab=modules`)} empty="Nothing stalled."
title={p.clientName} sub={p.moduleName} /> // 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>
<Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}> <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 === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? ( : rows.length === 0 ? (
<EmptyState> <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}` : ''}. {scope === 'mine' ? ' of yours' : ''}{q !== '' ? ` matching “${q}` : ''}.
</EmptyState> </EmptyState>
) : ( ) : (

Loading…
Cancel
Save