From 4de152a7f464e83f72b28899c613bc256215a303 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Tue, 21 Jul 2026 00:20:00 +0530 Subject: [PATCH] fix(ui): restore sticky table headers, dashboard loading flash, ticket-type over-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/ui/src/tokens.css: bound .wf-table-wrap to a viewport-derived max-height and matching overflow-y so it's a real scroll container again. overflow-x: auto alone left overflow-y at its computed "auto" (per the CSS overflow spec, a visible/non-visible axis pairing promotes visible to auto) with no height bound, so its scrollTop was permanently 0 and table.wf th's `position: sticky` never had anything to stick against — the header just scrolled away with the page on every table screen. Chose the bounded-wrapper fix (over deleting the dead sticky rule) since it keeps the header pinned as advertised; short tables render unchanged, long ones scroll inside the box exactly like the existing .dash-list pattern. Also moved the .wf-table-wrap rule out of apps/hq-web/src/app.css into tokens.css next to the other table.wf rules, since it's part of the @sims/ui component. - apps/hq-web/src/pages/Dashboard.tsx: the "Onboarding stalled" card treated "still loading" the same as "genuinely empty", flashing "Nothing stalled." before the fetch resolved. Card now takes a loading prop and shows the usual skeleton placeholder while pending; the positive empty copy and error state are unchanged. - apps/hq-web/src/pages/ClientDetail.tsx: getTicketTypes was fetched unconditionally on every Client 360 load for the tickets tab's Type column. Gated it behind the tickets tab being active (moved the tab computation earlier so it's available to gate the loader); NewTicketDialog's own getTicketTypes call for the new-ticket pre-fill is untouched. - STATUS.md: prose under the verification table still said "The 416 figure..." while the table itself says 528 — updated the prose to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- STATUS.md | 2 +- apps/hq-web/src/app.css | 5 +++-- apps/hq-web/src/pages/ClientDetail.tsx | 11 ++++++----- apps/hq-web/src/pages/Dashboard.tsx | 18 ++++++++++++------ packages/ui/src/tokens.css | 16 ++++++++++++++++ 5 files changed, 38 insertions(+), 14 deletions(-) diff --git a/STATUS.md b/STATUS.md index 8c98217..96ce198 100644 --- a/STATUS.md +++ b/STATUS.md @@ -70,7 +70,7 @@ header stacks instead of overlapping the title). | `npm run typecheck` (root + workspaces) | clean, no errors | | `npm test` (`vitest run`) | **528 tests pass across 97 files** (5 Postgres-gated skipped) | -The 416 figure is the live `vitest run` count measured 2026-07-19; the suite has +The 528 figure is the live `vitest run` count measured 2026-07-19; the suite has grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields, renewals, cockpit, audit viewer, SMS gateway, module directory/portals/search, and the D31/D32 plaintext-credential change with its own migration + review). Test files diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index a1f0995..6c7ed05 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -275,9 +275,10 @@ button.dash-row:hover { background: var(--accent-soft); } own horizontal-scroll container from `overflow-x: auto` in Chromium, so at desktop widths a wide table (e.g. Tickets' 11 columns) was overflowing past the viewport and getting silently clipped by `.hq-content`'s overflow-x: hidden below, unreachable at - any width. The wrapper scrolls at every width now, not just <901px. */ + any width. The wrapper scrolls at every width now, not just <901px. + (`.wf-table-wrap` itself is styled in @sims/ui — packages/ui/src/tokens.css — next to + the other table.wf rules, since it's part of that component, not this app's shell.) */ .hq-content { overflow-x: hidden; } -.wf-table-wrap { overflow-x: auto; } /* Relationship-pulse ribbon (Client 360 overview). */ .pulse { diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 1400c96..cd255ed 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -52,6 +52,9 @@ export function ClientDetail() { const id = useParams()['id'] ?? '' const nav = useNavigate() const [sp, setSp] = useSearchParams() + const rawTab = sp.get('tab') ?? 'overview' + const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' + const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true }) const client = useData(() => getClient(id), [id]) const modules = useData(getModules, []) const cms = useData(() => getClientModules(id), [id]) @@ -66,7 +69,9 @@ export function ClientDetail() { 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, []) + // Only needed for that column, so only fetch it once the tickets tab is actually open — + // NewTicketDialog fetches its own copy for the new-ticket type picker, independent of this. + const ticketTypes = useData(() => (tab === 'tickets' ? getTicketTypes() : Promise.resolve(TICKET_TYPE_FALLBACK)), [tab === 'tickets']) 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. @@ -99,10 +104,6 @@ export function ClientDetail() { const [linkingDoc, setLinkingDoc] = useState(false) const [docLinkError, setDocLinkError] = useState() - const rawTab = sp.get('tab') ?? 'overview' - const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' - const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true }) - const c = client.data if (client.error !== undefined) return if (c === undefined) return
diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index e39fbf9..8f7d94e 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -146,11 +146,15 @@ export function Dashboard() { {stalled.error !== undefined ? @@ -185,7 +189,7 @@ export function Dashboard() { * 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 + empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode }) { return (
@@ -194,7 +198,9 @@ function Card(props: { {props.count !== undefined && {props.count}} {props.link !== undefined && {props.link.label ?? 'All'} →} - {props.isEmpty ?
{props.empty}
:
{props.children}
} + {props.loading === true + ?
+ : props.isEmpty ?
{props.empty}
:
{props.children}
}
) } diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 76bab4d..63499c8 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -194,6 +194,22 @@ input.wf:focus, select.wf:focus, textarea.wf:focus { .wf-toolbar .spacer { flex: 1; } /* ================= tables ================= */ +/* Horizontal-scroll wrapper (emitted by DataTable) so a wide table scrolls sideways + inside its own box instead of silently losing its rightmost columns off-page at + any width (rule 8 — no silent caps). Bounded to a fraction of the viewport height + and given a matching `overflow-y` so it is a real, self-contained scroll box: + `overflow-x: auto` alone would still leave `overflow-y` at its initial `visible`, + but per the CSS overflow spec a `visible`/non-`visible` pairing computes the + `visible` side to `auto` too — so without this the wrapper silently becomes an + unbounded vertical scroll container (height: auto, scrollTop always 0) and steals + `table.wf th`'s sticky-positioning context from `.hq-content` (the page's real + scroller) for nothing in return: the header can never offset and just scrolls out + of view with the rest of the page. Giving the wrapper an actual bound restores a + working scrollport, so the sticky header sticks again — now against this box + rather than the page. A short table never reaches the bound (renders exactly as + before, no inner scrollbar); a long one scrolls inside it, same pattern already + used for `.dash-list`. */ +.wf-table-wrap { overflow: auto; max-height: 70vh; } table.wf { border-collapse: separate; border-spacing: 0; width: 100%; background: var(--bg-raised); border: 1px solid var(--border);