feat(shell): scrollable status tabs, per-page tab titles, skip-to-content, Czech MUI locale

- Shared Tabs: variant=scrollable + auto scroll buttons — the 5 status-filter
  tabs no longer clip unreachable at ~360px (desktop rendering unchanged).
- TitleSync: browser tab shows the active page ('Faktury · BOHA'; ambiguous
  labels qualified with their section, e.g. 'Záznam – Docházka · BOHA');
  index.html fallback now 'BOHA Admin'.
- Skip-to-content link (visually hidden, visible on focus) + id/tabIndex on
  <main> — keyboard users skip the 248px sidebar.
- MUI csCZ locale merged into the theme (kills built-in English strings like
  'No options'); Czech noOptionsText on Customer/Supplier pickers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-07-04 03:22:06 +02:00
parent 87e644eef8
commit 4d0ec53514
7 changed files with 322 additions and 223 deletions

View File

@@ -19,7 +19,7 @@
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<title>Admin</title> <title>BOHA Admin</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { menuSections, isItemActive } from "../ui/navData";
/**
* Null-rendering helper that keeps the browser tab title in sync with the
* active navigation item. Scans menuSections in order (first match wins) via
* the same isItemActive logic the sidebar uses, so detail routes covered by
* matchPrefix (e.g. /offers/123) inherit their section item's label.
* No cleanup on unmount — the last title simply persists.
*/
// Labels reused across sections ("Záznam", "Moje historie", "Správa",
// "Přehled") would produce identical tab titles — qualify those with their
// section label so /attendance and /trips tabs stay distinguishable.
const labelCounts = new Map<string, number>();
for (const section of menuSections) {
for (const item of section.items) {
labelCounts.set(item.label, (labelCounts.get(item.label) ?? 0) + 1);
}
}
export default function TitleSync() {
const { pathname } = useLocation();
useEffect(() => {
for (const section of menuSections) {
const item = section.items.find((candidate) =>
isItemActive(candidate, pathname),
);
if (item) {
const ambiguous = (labelCounts.get(item.label) ?? 0) > 1;
document.title = ambiguous
? `${item.label} ${section.label} · BOHA`
: `${item.label} · BOHA`;
return;
}
}
document.title = "BOHA Admin";
}, [pathname]);
return null;
}

View File

@@ -4,6 +4,7 @@ import {
type PaletteColor, type PaletteColor,
type PaletteColorChannel, type PaletteColorChannel,
} from "@mui/material/styles"; } from "@mui/material/styles";
import { csCZ } from "@mui/material/locale";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif"; const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif"; const FONT_HEADING = "'Urbanist', sans-serif";
@@ -25,7 +26,13 @@ export const FILLED_DARK_BG = {
info: { bg: "#1d4ed8", hover: "#1e40af" }, info: { bg: "#1d4ed8", hover: "#1e40af" },
} as const; } as const;
export const theme = createTheme({ // The theme composes MUI's Czech component localization (csCZ) as a second
// createTheme argument (the supported deep-merge form). It only injects
// component defaultProps — Czech built-in strings for Autocomplete
// ("Žádné možnosti"), TablePagination, Alert, Pagination, … — and does not
// touch the palette / cssVariables config.
export const theme = createTheme(
{
cssVariables: { cssVariables: {
colorSchemeSelector: "[data-theme='%s']", colorSchemeSelector: "[data-theme='%s']",
}, },
@@ -275,7 +282,9 @@ export const theme = createTheme({
}, },
}, },
}, },
}); },
csCZ,
);
export const fonts = { export const fonts = {
body: FONT_BODY, body: FONT_BODY,

View File

@@ -11,6 +11,7 @@ import { setLogoutAlert } from "../utils/api";
import SidebarNav from "./SidebarNav"; import SidebarNav from "./SidebarNav";
import LoadingState from "./LoadingState"; import LoadingState from "./LoadingState";
import ShortcutsHelp from "../components/ShortcutsHelp"; import ShortcutsHelp from "../components/ShortcutsHelp";
import TitleSync from "../components/TitleSync";
const DRAWER_WIDTH = 248; const DRAWER_WIDTH = 248;
@@ -98,6 +99,7 @@ export default function AppShell() {
return ( return (
<ScopedCssBaseline> <ScopedCssBaseline>
<TitleSync />
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={ animate={
@@ -110,6 +112,38 @@ export default function AppShell() {
ease: [0.4, 0, 0.2, 1], ease: [0.4, 0, 0.2, 1],
}} }}
> >
{/* Skip link: first focusable element; visually hidden (clipped)
until keyboard focus reveals it above the drawer. */}
<Box
component="a"
href="#main-content"
sx={(theme) => ({
position: "absolute",
top: 8,
left: 8,
zIndex: theme.zIndex.drawer + 2,
width: "1px",
height: "1px",
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
px: 2,
py: 1,
borderRadius: 1,
bgcolor: theme.vars!.palette.primary.main,
color: theme.vars!.palette.primary.contrastText,
textDecoration: "none",
fontWeight: 600,
"&:focus": {
width: "auto",
height: "auto",
overflow: "visible",
clipPath: "none",
},
})}
>
Přeskočit na obsah
</Box>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -209,10 +243,15 @@ export default function AppShell() {
</Box> </Box>
<Box <Box
component="main" component="main"
id="main-content"
tabIndex={-1}
sx={{ sx={{
flex: 1, flex: 1,
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 }, px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4, pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
// Skip-link target: focus lands here programmatically — the
// region-wide focus ring would be noise, not a signal.
"&:focus": { outline: "none" },
...(immersiveOnMobile && { ...(immersiveOnMobile && {
minHeight: 0, minHeight: 0,
overflow: { xs: "hidden", md: "visible" }, overflow: { xs: "hidden", md: "visible" },

View File

@@ -57,6 +57,7 @@ export default function CustomerPicker({
size="small" size="small"
fullWidth fullWidth
autoHighlight autoHighlight
noOptionsText="Žádní zákazníci"
renderOption={(props, c) => { renderOption={(props, c) => {
const { key, ...rest } = props as typeof props & { key: string }; const { key, ...rest } = props as typeof props & { key: string };
return ( return (

View File

@@ -57,6 +57,7 @@ export default function SupplierPicker({
size="small" size="small"
fullWidth fullWidth
autoHighlight autoHighlight
noOptionsText="Žádní dodavatelé"
renderOption={(props, s) => { renderOption={(props, s) => {
const { key, ...rest } = props as typeof props & { key: string }; const { key, ...rest } = props as typeof props & { key: string };
return ( return (

View File

@@ -29,6 +29,13 @@ export function Tabs({
<MuiTabs <MuiTabs
value={value} value={value}
onChange={(_, v) => onChange(v)} onChange={(_, v) => onChange(v)}
// Scrollable so many tabs (e.g. 5 status filters) stay reachable on
// ~360px phones instead of clipping. When everything fits (desktop),
// scroll buttons don't render and this looks identical to "standard" —
// callers' centered flex wrappers keep shrink-wrapping the bar.
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ sx={{
borderBottom: 1, borderBottom: 1,
borderColor: "divider", borderColor: "divider",