diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index eab9b47b..f8eaa900 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -14,6 +14,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { useWizard } from "./WizardProvider";
import { usePrefs } from "./PrefsProvider";
+import Tabs from "./Tabs";
import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
@@ -61,25 +62,18 @@ export default function SettingsPanel() {
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
-
+ ({
+ id: tabItem.id,
+ label: t(`settings.tabs.${tabItem.id}`),
+ icon: tabItem.icon,
+ testId: `settings-tab-${tabItem.id}`,
+ }))}
+ />
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
every tab in one grid cell made the whole panel as tall as the tallest tab —
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx
index 55ffec50..9bb4c9d0 100644
--- a/frontend/src/components/Stats.tsx
+++ b/frontend/src/components/Stats.tsx
@@ -10,6 +10,7 @@ import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { LoadingState, StateMessage } from "./QueryState";
import FilterChipGroup from "./FilterChipGroup";
+import Tabs from "./Tabs";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
@@ -31,18 +32,14 @@ export default function Stats() {
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
{isAdmin && (
-
+ ({
+ id,
+ label: t(`stats.tabs.${id}`),
+ }))}
+ />
)}
{tab === "system" && isAdmin ? : }
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx
index 833dd8a5..b2480f47 100644
--- a/frontend/src/components/Tabs.tsx
+++ b/frontend/src/components/Tabs.tsx
@@ -1,13 +1,17 @@
+import { type LucideIcon } from "lucide-react";
import { useAccountPersistedState } from "../lib/storage";
-// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
-// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
-// per the app's "persist UI state across reload" convention.
+// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
+// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
+// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
+// to localStorage so a reload keeps the user where they were.
export interface TabDef {
id: string;
label: string;
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
}
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
@@ -18,28 +22,38 @@ export default function Tabs({
tabs,
active,
onChange,
- className = "flex flex-wrap gap-1 mb-4",
+ orientation = "horizontal",
+ className,
}: {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
- // Override the row wrapper — e.g. the channel manager embeds the rail in a justify-between header
- // and drops the default bottom margin. Omit for the standalone-row default.
+ // "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row.
+ orientation?: "horizontal" | "vertical";
+ // Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between
+ // header, or the settings rail adds a width + right border. Omit for the orientation default.
className?: string;
}) {
+ const vertical = orientation === "vertical";
+ const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
return (
-