diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index be54391..cc4d19e 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -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); } diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 70994a0..a8ffd25 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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() {
{moduleName(cm.moduleId)} - {cm.status} {KIND_LABEL[cm.kind]} + {/* 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). */} e.stopPropagation()} style={{ display: 'inline-flex', gap: 6 }}> 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')} /> {t.moduleCode} : '—', + // Same data + rendering as the Tickets desk (D-ticket-type parity). + type: ticketTypeLabel(ticketTypeOptions, t.type), kind: t.kind !== '' ? t.kind : '—', desc: , 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. */} - 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 && ( + 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 && ( { 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: { {m.label} {m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'} + {/* 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 && ( + + )} + {m.done && m.paymentId !== null && (() => { + const p = props.payments.find((x) => x.id === m.paymentId) + return ( + + ) + })()} ))} diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index cbf29c2..e39fbf9 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -145,11 +145,19 @@ export function Dashboard() { ))} - - {(stalled.data ?? []).map((p) => ( - nav(`/clients/${p.clientId}?tab=modules`)} - title={p.clientName} sub={p.moduleName} /> - ))} + + {stalled.error !== undefined + ? + : (stalled.data ?? []).map((p) => ( + nav(`/clients/${p.clientId}?tab=modules`)} + title={p.clientName} sub={p.moduleName} /> + ))} diff --git a/apps/hq-web/src/pages/Tickets.tsx b/apps/hq-web/src/pages/Tickets.tsx index 69dae20..2085c7b 100644 --- a/apps/hq-web/src/pages/Tickets.tsx +++ b/apps/hq-web/src/pages/Tickets.tsx @@ -380,7 +380,9 @@ export function Tickets() { : rows === undefined ? : rows.length === 0 ? ( - 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}”` : ''}. ) : (