From 4d0ec53514372b6f1f034a298c47e55097891ed0 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 4 Jul 2026 03:22:06 +0200 Subject: [PATCH] feat(shell): scrollable status tabs, per-page tab titles, skip-to-content, Czech MUI locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
— 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 --- index.html | 2 +- src/admin/components/TitleSync.tsx | 42 +++ src/admin/theme.ts | 453 +++++++++++++++-------------- src/admin/ui/AppShell.tsx | 39 +++ src/admin/ui/CustomerPicker.tsx | 1 + src/admin/ui/SupplierPicker.tsx | 1 + src/admin/ui/Tabs.tsx | 7 + 7 files changed, 322 insertions(+), 223 deletions(-) create mode 100644 src/admin/components/TitleSync.tsx diff --git a/index.html b/index.html index 53ad3dc..5b98832 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ - Admin + BOHA Admin
diff --git a/src/admin/components/TitleSync.tsx b/src/admin/components/TitleSync.tsx new file mode 100644 index 0000000..02b39c3 --- /dev/null +++ b/src/admin/components/TitleSync.tsx @@ -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(); +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; +} diff --git a/src/admin/theme.ts b/src/admin/theme.ts index 3e2ed81..0e20c81 100644 --- a/src/admin/theme.ts +++ b/src/admin/theme.ts @@ -4,6 +4,7 @@ import { type PaletteColor, type PaletteColorChannel, } from "@mui/material/styles"; +import { csCZ } from "@mui/material/locale"; const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif"; const FONT_HEADING = "'Urbanist', sans-serif"; @@ -25,257 +26,265 @@ export const FILLED_DARK_BG = { info: { bg: "#1d4ed8", hover: "#1e40af" }, } as const; -export const theme = createTheme({ - cssVariables: { - colorSchemeSelector: "[data-theme='%s']", - }, - colorSchemes: { - light: { - palette: { - mode: "light", - primary: { main: "#c73030" }, - success: { main: "#15803d", contrastText: "#fff" }, - warning: { main: "#b45309", contrastText: "#fff" }, - error: { main: "#b91c1c", contrastText: "#fff" }, - info: { main: "#1d4ed8", contrastText: "#fff" }, - // Sidebar section hues (full objects so cssVariables emits *Channel - // tokens — the nav tiles use channel-alpha washes). - teal: { - main: "#0e8a7c", - light: "#3aa99c", - dark: "#0a675d", - contrastText: "#fff", - }, - violet: { - main: "#6a4cb4", - light: "#8a6fd0", - dark: "#54399a", - contrastText: "#fff", - }, - slate: { - main: "#5c6470", - light: "#7d8694", - dark: "#454c56", - contrastText: "#fff", - }, - background: { default: "#f4f3f1", paper: "#ffffff" }, - text: { primary: "#1a1a1a", secondary: "#555555" }, - divider: "rgba(0,0,0,0.1)", - }, +// 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: { + colorSchemeSelector: "[data-theme='%s']", }, - dark: { - palette: { - mode: "dark", - primary: { main: "#d63031" }, - success: { main: "#22c55e", contrastText: "#1a1a1a" }, - warning: { main: "#f59e0b", contrastText: "#1a1a1a" }, - error: { main: "#ef4444", contrastText: "#1a1a1a" }, - info: { main: "#3b82f6", contrastText: "#1a1a1a" }, - // Sidebar section hues — brighter mains so the glyphs read on dark. - teal: { - main: "#2dd4bf", - light: "#5eead4", - dark: "#14b8a6", - contrastText: "#1a1a1a", - }, - violet: { - main: "#a78bfa", - light: "#c4b5fd", - dark: "#8b5cf6", - contrastText: "#1a1a1a", - }, - slate: { - main: "#94a3b8", - light: "#cbd5e1", - dark: "#64748b", - contrastText: "#1a1a1a", - }, - background: { default: "#0f0f0f", paper: "#1a1a1a" }, - text: { primary: "#ffffff", secondary: "#a0a0a0" }, - divider: "rgba(255,255,255,0.08)", - }, - }, - }, - shape: { borderRadius: 10 }, - typography: { - fontFamily: FONT_BODY, - h1: { fontFamily: FONT_HEADING, fontWeight: 800 }, - h2: { fontFamily: FONT_HEADING, fontWeight: 800 }, - h3: { fontFamily: FONT_HEADING, fontWeight: 800 }, - h4: { - fontFamily: FONT_HEADING, - fontWeight: 700, - // Page/detail headlines: MUI's default 2.125rem overflows phone - // viewports (long document titles + number). Scale down on xs only. - "@media (max-width:600px)": { fontSize: "1.5rem" }, - }, - h5: { fontFamily: FONT_HEADING, fontWeight: 700 }, - h6: { fontFamily: FONT_HEADING, fontWeight: 700 }, - button: { textTransform: "none", fontWeight: 600 }, - }, - components: { - MuiButton: { - defaultProps: { disableElevation: true }, - styleOverrides: { - root: { - borderRadius: 999, - paddingInline: "0.95rem", - // Snappy press (150ms) + smooth color/shadow (250ms). - transition: [ - `background-color 250ms ${EASE}`, - `border-color 250ms ${EASE}`, - `color 200ms ${EASE}`, - `box-shadow 250ms ${EASE}`, - `transform 150ms ${EASE}`, - `filter 200ms ${EASE}`, - ].join(", "), - "&:active": { transform: "scale(0.97)" }, - "@media (prefers-reduced-motion: reduce)": { transition: "none" }, - }, - containedPrimary: { - backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)", - boxShadow: "0 5px 14px rgba(214,48,49,0.32)", - // The gradient can't transition, so animate lift + glow instead. - "&:hover": { - transform: "translateY(-1px)", - filter: "brightness(1.04)", - boxShadow: "0 8px 20px rgba(214,48,49,0.42)", + colorSchemes: { + light: { + palette: { + mode: "light", + primary: { main: "#c73030" }, + success: { main: "#15803d", contrastText: "#fff" }, + warning: { main: "#b45309", contrastText: "#fff" }, + error: { main: "#b91c1c", contrastText: "#fff" }, + info: { main: "#1d4ed8", contrastText: "#fff" }, + // Sidebar section hues (full objects so cssVariables emits *Channel + // tokens — the nav tiles use channel-alpha washes). + teal: { + main: "#0e8a7c", + light: "#3aa99c", + dark: "#0a675d", + contrastText: "#fff", }, - "&:active": { transform: "translateY(0) scale(0.97)" }, - // Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop - // the lift/press transform (glow + brightness stay, they don't move). - "@media (prefers-reduced-motion: reduce)": { - "&:hover": { transform: "none" }, - "&:active": { transform: "none" }, + violet: { + main: "#6a4cb4", + light: "#8a6fd0", + dark: "#54399a", + contrastText: "#fff", }, + slate: { + main: "#5c6470", + light: "#7d8694", + dark: "#454c56", + contrastText: "#fff", + }, + background: { default: "#f4f3f1", paper: "#ffffff" }, + text: { primary: "#1a1a1a", secondary: "#555555" }, + divider: "rgba(0,0,0,0.1)", }, - // Filled colored buttons: WHITE text in BOTH themes (was per-scheme - // contrastText → near-black text on colored fills in dark mode, which - // also clashed with the always-white primary — "black text on one red - // button, white on another"). In dark mode the fill drops to a darker - // shade so white stays legible. - containedError: ({ theme }) => ({ - color: "#fff", - ...theme.applyStyles("dark", { - "&:not(.Mui-disabled)": { - backgroundColor: FILLED_DARK_BG.error.bg, - "&:hover": { backgroundColor: FILLED_DARK_BG.error.hover }, - }, - }), - }), - containedSuccess: ({ theme }) => ({ - color: "#fff", - ...theme.applyStyles("dark", { - "&:not(.Mui-disabled)": { - backgroundColor: FILLED_DARK_BG.success.bg, - "&:hover": { backgroundColor: FILLED_DARK_BG.success.hover }, - }, - }), - }), - containedWarning: ({ theme }) => ({ - color: "#fff", - ...theme.applyStyles("dark", { - "&:not(.Mui-disabled)": { - backgroundColor: FILLED_DARK_BG.warning.bg, - "&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover }, - }, - }), - }), - containedInfo: ({ theme }) => ({ - color: "#fff", - ...theme.applyStyles("dark", { - "&:not(.Mui-disabled)": { - backgroundColor: FILLED_DARK_BG.info.bg, - "&:hover": { backgroundColor: FILLED_DARK_BG.info.hover }, - }, - }), - }), }, - }, - MuiCard: { - styleOverrides: { - root: { - borderRadius: 16, - boxShadow: - "0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)", - transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`, + dark: { + palette: { + mode: "dark", + primary: { main: "#d63031" }, + success: { main: "#22c55e", contrastText: "#1a1a1a" }, + warning: { main: "#f59e0b", contrastText: "#1a1a1a" }, + error: { main: "#ef4444", contrastText: "#1a1a1a" }, + info: { main: "#3b82f6", contrastText: "#1a1a1a" }, + // Sidebar section hues — brighter mains so the glyphs read on dark. + teal: { + main: "#2dd4bf", + light: "#5eead4", + dark: "#14b8a6", + contrastText: "#1a1a1a", + }, + violet: { + main: "#a78bfa", + light: "#c4b5fd", + dark: "#8b5cf6", + contrastText: "#1a1a1a", + }, + slate: { + main: "#94a3b8", + light: "#cbd5e1", + dark: "#64748b", + contrastText: "#1a1a1a", + }, + background: { default: "#0f0f0f", paper: "#1a1a1a" }, + text: { primary: "#ffffff", secondary: "#a0a0a0" }, + divider: "rgba(255,255,255,0.08)", }, }, }, - MuiChip: { - styleOverrides: { - root: { - borderRadius: 999, - fontWeight: 700, - transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`, - }, - // Filled colored chips follow the same rule as filled buttons: white - // label in both themes, darker fill in dark mode so white stays legible. - // (Chip has no per-color `filledX` key, so target the color class and - // scope to the filled variant.) - colorError: ({ theme }) => ({ - "&.MuiChip-filled": { + shape: { borderRadius: 10 }, + typography: { + fontFamily: FONT_BODY, + h1: { fontFamily: FONT_HEADING, fontWeight: 800 }, + h2: { fontFamily: FONT_HEADING, fontWeight: 800 }, + h3: { fontFamily: FONT_HEADING, fontWeight: 800 }, + h4: { + fontFamily: FONT_HEADING, + fontWeight: 700, + // Page/detail headlines: MUI's default 2.125rem overflows phone + // viewports (long document titles + number). Scale down on xs only. + "@media (max-width:600px)": { fontSize: "1.5rem" }, + }, + h5: { fontFamily: FONT_HEADING, fontWeight: 700 }, + h6: { fontFamily: FONT_HEADING, fontWeight: 700 }, + button: { textTransform: "none", fontWeight: 600 }, + }, + components: { + MuiButton: { + defaultProps: { disableElevation: true }, + styleOverrides: { + root: { + borderRadius: 999, + paddingInline: "0.95rem", + // Snappy press (150ms) + smooth color/shadow (250ms). + transition: [ + `background-color 250ms ${EASE}`, + `border-color 250ms ${EASE}`, + `color 200ms ${EASE}`, + `box-shadow 250ms ${EASE}`, + `transform 150ms ${EASE}`, + `filter 200ms ${EASE}`, + ].join(", "), + "&:active": { transform: "scale(0.97)" }, + "@media (prefers-reduced-motion: reduce)": { transition: "none" }, + }, + containedPrimary: { + backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)", + boxShadow: "0 5px 14px rgba(214,48,49,0.32)", + // The gradient can't transition, so animate lift + glow instead. + "&:hover": { + transform: "translateY(-1px)", + filter: "brightness(1.04)", + boxShadow: "0 8px 20px rgba(214,48,49,0.42)", + }, + "&:active": { transform: "translateY(0) scale(0.97)" }, + // Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop + // the lift/press transform (glow + brightness stay, they don't move). + "@media (prefers-reduced-motion: reduce)": { + "&:hover": { transform: "none" }, + "&:active": { transform: "none" }, + }, + }, + // Filled colored buttons: WHITE text in BOTH themes (was per-scheme + // contrastText → near-black text on colored fills in dark mode, which + // also clashed with the always-white primary — "black text on one red + // button, white on another"). In dark mode the fill drops to a darker + // shade so white stays legible. + containedError: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { - backgroundColor: FILLED_DARK_BG.error.bg, - "&.MuiChip-clickable:hover": { - backgroundColor: FILLED_DARK_BG.error.hover, + "&:not(.Mui-disabled)": { + backgroundColor: FILLED_DARK_BG.error.bg, + "&:hover": { backgroundColor: FILLED_DARK_BG.error.hover }, }, }), - }, - }), - colorSuccess: ({ theme }) => ({ - "&.MuiChip-filled": { + }), + containedSuccess: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { - backgroundColor: FILLED_DARK_BG.success.bg, - "&.MuiChip-clickable:hover": { - backgroundColor: FILLED_DARK_BG.success.hover, + "&:not(.Mui-disabled)": { + backgroundColor: FILLED_DARK_BG.success.bg, + "&:hover": { backgroundColor: FILLED_DARK_BG.success.hover }, }, }), - }, - }), - colorWarning: ({ theme }) => ({ - "&.MuiChip-filled": { + }), + containedWarning: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { - backgroundColor: FILLED_DARK_BG.warning.bg, - "&.MuiChip-clickable:hover": { - backgroundColor: FILLED_DARK_BG.warning.hover, + "&:not(.Mui-disabled)": { + backgroundColor: FILLED_DARK_BG.warning.bg, + "&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover }, }, }), - }, - }), - colorInfo: ({ theme }) => ({ - "&.MuiChip-filled": { + }), + containedInfo: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { - backgroundColor: FILLED_DARK_BG.info.bg, - "&.MuiChip-clickable:hover": { - backgroundColor: FILLED_DARK_BG.info.hover, + "&:not(.Mui-disabled)": { + backgroundColor: FILLED_DARK_BG.info.bg, + "&:hover": { backgroundColor: FILLED_DARK_BG.info.hover }, }, }), - }, - }), - }, - }, - MuiOutlinedInput: { - styleOverrides: { - root: { - // Soft brand focus ring fades in over 200ms. - transition: `box-shadow 200ms ${EASE}`, - "&.Mui-focused": { - boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)", - }, - "@media (prefers-reduced-motion: reduce)": { transition: "none" }, + }), }, - notchedOutline: { - transition: `border-color 200ms ${EASE}`, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 16, + boxShadow: + "0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)", + transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 999, + fontWeight: 700, + transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`, + }, + // Filled colored chips follow the same rule as filled buttons: white + // label in both themes, darker fill in dark mode so white stays legible. + // (Chip has no per-color `filledX` key, so target the color class and + // scope to the filled variant.) + colorError: ({ theme }) => ({ + "&.MuiChip-filled": { + color: "#fff", + ...theme.applyStyles("dark", { + backgroundColor: FILLED_DARK_BG.error.bg, + "&.MuiChip-clickable:hover": { + backgroundColor: FILLED_DARK_BG.error.hover, + }, + }), + }, + }), + colorSuccess: ({ theme }) => ({ + "&.MuiChip-filled": { + color: "#fff", + ...theme.applyStyles("dark", { + backgroundColor: FILLED_DARK_BG.success.bg, + "&.MuiChip-clickable:hover": { + backgroundColor: FILLED_DARK_BG.success.hover, + }, + }), + }, + }), + colorWarning: ({ theme }) => ({ + "&.MuiChip-filled": { + color: "#fff", + ...theme.applyStyles("dark", { + backgroundColor: FILLED_DARK_BG.warning.bg, + "&.MuiChip-clickable:hover": { + backgroundColor: FILLED_DARK_BG.warning.hover, + }, + }), + }, + }), + colorInfo: ({ theme }) => ({ + "&.MuiChip-filled": { + color: "#fff", + ...theme.applyStyles("dark", { + backgroundColor: FILLED_DARK_BG.info.bg, + "&.MuiChip-clickable:hover": { + backgroundColor: FILLED_DARK_BG.info.hover, + }, + }), + }, + }), + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + // Soft brand focus ring fades in over 200ms. + transition: `box-shadow 200ms ${EASE}`, + "&.Mui-focused": { + boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)", + }, + "@media (prefers-reduced-motion: reduce)": { transition: "none" }, + }, + notchedOutline: { + transition: `border-color 200ms ${EASE}`, + }, }, }, }, }, -}); + csCZ, +); export const fonts = { body: FONT_BODY, diff --git a/src/admin/ui/AppShell.tsx b/src/admin/ui/AppShell.tsx index bdcc533..1ac4be6 100644 --- a/src/admin/ui/AppShell.tsx +++ b/src/admin/ui/AppShell.tsx @@ -11,6 +11,7 @@ import { setLogoutAlert } from "../utils/api"; import SidebarNav from "./SidebarNav"; import LoadingState from "./LoadingState"; import ShortcutsHelp from "../components/ShortcutsHelp"; +import TitleSync from "../components/TitleSync"; const DRAWER_WIDTH = 248; @@ -98,6 +99,7 @@ export default function AppShell() { return ( + + {/* Skip link: first focusable element; visually hidden (clipped) + until keyboard focus reveals it above the drawer. */} + ({ + 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 + { const { key, ...rest } = props as typeof props & { key: string }; return ( diff --git a/src/admin/ui/SupplierPicker.tsx b/src/admin/ui/SupplierPicker.tsx index 0a54e9c..f0c1ca2 100644 --- a/src/admin/ui/SupplierPicker.tsx +++ b/src/admin/ui/SupplierPicker.tsx @@ -57,6 +57,7 @@ export default function SupplierPicker({ size="small" fullWidth autoHighlight + noOptionsText="Žádní dodavatelé" renderOption={(props, s) => { const { key, ...rest } = props as typeof props & { key: string }; return ( diff --git a/src/admin/ui/Tabs.tsx b/src/admin/ui/Tabs.tsx index 39cf1b8..0cc0010 100644 --- a/src/admin/ui/Tabs.tsx +++ b/src/admin/ui/Tabs.tsx @@ -29,6 +29,13 @@ export function Tabs({ 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={{ borderBottom: 1, borderColor: "divider",