feat(ui): E31 S4b — the admin pages behind one rail row, Configuration as a sectioned list, Statistics as tiles and bars

- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a
  middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll
- sysconfig.SECTIONS orders the config groups under four headings (every group in exactly
  one, test-held); the page shows them as Settings' side list (Tabs `group` headings);
  the lone registration group reads "Registration" under "Access"
- StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the
  per-operation quota use as horizontal bars (a zero draws none)
This commit is contained in:
2026-10-02 00:07:00 +02:00
parent 25905aa786
commit be156fc52b
21 changed files with 753 additions and 223 deletions
+47 -32
View File
@@ -1,4 +1,4 @@
import { useId, useRef, type KeyboardEvent } from "react";
import { Fragment, useId, useRef, type KeyboardEvent } from "react";
import { panelId, tabId } from "./tabPanel";
import { type LucideIcon } from "lucide-react";
import CountBadge from "./CountBadge";
@@ -24,6 +24,9 @@ export interface TabDef {
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
testId?: string; // optional data-testid, to preserve existing e2e hooks
/** The vertical rail's heading this tab sits under (E31: Configuration's sixteen groups under four).
* Drawn above the first tab of each run; tabs of one heading stay together. */
group?: string;
}
export default function Tabs({
@@ -87,40 +90,52 @@ export default function Tabs({
onKeyDown={onKeyDown}
className={wrapCls}
>
{tabs.map((tb) => {
{tabs.map((tb, i) => {
const on = tb.id === active;
const Icon = tb.icon;
const heading = vertical && tb.group && tb.group !== tabs[i - 1]?.group ? tb.group : null;
return (
<button
key={tb.id}
ref={(node) => {
if (node) refs.current.set(tb.id, node);
else refs.current.delete(tb.id);
}}
type="button"
role="tab"
id={tabId(base, tb.id)}
aria-selected={on}
aria-controls={idBase ? panelId(idBase) : undefined}
tabIndex={tb.id === stop ? 0 : -1}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
className={
tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`
: `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
</button>
<Fragment key={tb.id}>
{/* A heading, not a tab: no role a tab list would have to own, its text still read. Only
on the side rail; the phone's scrolling row has no room for it. */}
{heading && (
<div
role="presentation"
className="hidden px-3 pt-3 pb-1 text-2xs font-semibold tracking-wide text-muted uppercase first:pt-1 sm:block"
>
{heading}
</div>
)}
<button
ref={(node) => {
if (node) refs.current.set(tb.id, node);
else refs.current.delete(tb.id);
}}
type="button"
role="tab"
id={tabId(base, tb.id)}
aria-selected={on}
aria-controls={idBase ? panelId(idBase) : undefined}
tabIndex={tb.id === stop ? 0 : -1}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
className={
tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`
: `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
</button>
</Fragment>
);
})}
</div>