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",