merge: restore sticky table headers (bounded 70vh wrapper), dashboard loading state, ticket-type over-fetch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 1 day ago
commit 1717900e02

@ -70,7 +70,7 @@ header stacks instead of overlapping the title).
| `npm run typecheck` (root + workspaces) | clean, no errors | | `npm run typecheck` (root + workspaces) | clean, no errors |
| `npm test` (`vitest run`) | **528 tests pass across 97 files** (5 Postgres-gated skipped) | | `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, 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 renewals, cockpit, audit viewer, SMS gateway, module directory/portals/search, and
the D31/D32 plaintext-credential change with its own migration + review). Test files 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 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 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 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; } .hq-content { overflow-x: hidden; }
.wf-table-wrap { overflow-x: auto; }
/* Relationship-pulse ribbon (Client 360 overview). */ /* Relationship-pulse ribbon (Client 360 overview). */
.pulse { .pulse {

@ -52,6 +52,9 @@ export function ClientDetail() {
const id = useParams()['id'] ?? '' const id = useParams()['id'] ?? ''
const nav = useNavigate() const nav = useNavigate()
const [sp, setSp] = useSearchParams() 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 client = useData(() => getClient(id), [id])
const modules = useData(getModules, []) const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id]) const cms = useData(() => getClientModules(id), [id])
@ -66,7 +69,9 @@ export function ClientDetail() {
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. // 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 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.
@ -99,10 +104,6 @@ export function ClientDetail() {
const [linkingDoc, setLinkingDoc] = useState(false) const [linkingDoc, setLinkingDoc] = useState(false)
const [docLinkError, setDocLinkError] = useState<string | undefined>() 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 const c = client.data
if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} /> if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div> if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>

@ -146,11 +146,15 @@ export function Dashboard() {
</Card> </Card>
<Card <Card
title="Onboarding stalled" count={stalled.data?.length ?? 0} title="Onboarding stalled" count={stalled.isLoading ? undefined : (stalled.data?.length ?? 0)}
empty="Nothing stalled." empty="Nothing stalled."
// A fetch failure is not the same as a genuine empty result — show the error // Still fetching (data undefined, no error yet) is not the same as a genuine
// (with retry) instead of quietly reporting "Nothing stalled." on failure. // empty result — show the usual loading placeholder, not "Nothing stalled.",
isEmpty={stalled.error === undefined && (stalled.data ?? []).length === 0} // 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 {stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} /> ? <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. */ * scrollable list, or an empty-state line when there's nothing to show. */
function Card(props: { function Card(props: {
title: string; count?: number; link?: { to: string; label?: string } title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; children: ReactNode empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode
}) { }) {
return ( return (
<section className="dash-card"> <section className="dash-card">
@ -194,7 +198,9 @@ function Card(props: {
{props.count !== undefined && <span className="count">{props.count}</span>} {props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>} {props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div> </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> </section>
) )
} }

@ -194,6 +194,22 @@ input.wf:focus, select.wf:focus, textarea.wf:focus {
.wf-toolbar .spacer { flex: 1; } .wf-toolbar .spacer { flex: 1; }
/* ================= tables ================= */ /* ================= 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 { table.wf {
border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0; width: 100%;
background: var(--bg-raised); border: 1px solid var(--border); background: var(--bg-raised); border: 1px solid var(--border);

Loading…
Cancel
Save