fix(ui): restore sticky table headers, dashboard loading flash, ticket-type over-fetch

- 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) <noreply@anthropic.com>
main
Thomas Joise 23 hours ago
parent b1e1420b4c
commit 4de152a7f4

@ -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

@ -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 {

@ -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<string | undefined>()
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 <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>

@ -146,11 +146,15 @@ export function Dashboard() {
</Card>
<Card
title="Onboarding stalled" count={stalled.data?.length ?? 0}
title="Onboarding stalled" count={stalled.isLoading ? undefined : (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}
// Still fetching (data undefined, no error yet) is not the same as a genuine
// empty result — show the usual loading placeholder, not "Nothing stalled.",
// until the request actually resolves one way or the other.
loading={stalled.isLoading}
// A fetch failure is likewise not the same as empty — show the error (with
// retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={!stalled.isLoading && stalled.error === undefined && (stalled.data ?? []).length === 0}
>
{stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} />
@ -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 (
<section className="dash-card">
@ -194,7 +198,9 @@ function Card(props: {
{props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div>
{props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
{props.loading === true
? <div className="dash-list"><Skeleton rows={2} /></div>
: props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section>
)
}

@ -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);

Loading…
Cancel
Save