Files
app/src/admin/ui/navData.tsx
BOHA fd2e613aa5 feat(ui): sidebar icon refresh + unified detail-page headers
Sidebar (user-approved design, docs/superpowers/specs/2026-06-12-sidebar-
icons-design.md): all 31 menu items get unique contextual stroke glyphs in
rounded-square tiles tinted per section (Docházka blue, Kniha jízd teal,
Administrativa amber, Sklad violet, Systém slate; new teal/violet/slate
palette entries with channel tokens in both schemes). Replaces the old set
where 13 items shared a glyph (3x people, 3x sliders, ...) and Faktury was a
dollar sign. Odin stays the only solid + animated tile. Uniqueness pinned by
navdata-icons.test.ts (tsconfig.test.json gains jsx for the .tsx import).

Headers: ProjectDetail and OrderDetail now match the Offer/Invoice shell —
number rendered in secondary color at regular weight next to the bold
entity word, and flexWrap on both left header boxes so the title drops
below Zpět on phones instead of overflowing the viewport (the reported
mobile bug on received orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:37:44 +02:00

556 lines
16 KiB
TypeScript

import { type ReactNode } from "react";
import OdinMark from "../components/odin/OdinMark";
/** Palette key driving a section's tile wash + glyph color (see theme.ts). */
export type NavHue =
| "primary"
| "info"
| "teal"
| "warning"
| "violet"
| "slate";
export interface MenuItem {
path: string;
label: string;
end?: boolean;
permission?: string | string[];
matchPrefix?: string;
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
/** Icon ships its own container (OdinMark) — SidebarNav skips the tile. */
rawIcon?: boolean;
}
export interface MenuSection {
label: string;
hue: NavHue;
items: MenuItem[];
}
/**
* One stroke glyph per item, every glyph unique within the set (pinned by
* navdata-icons.test.ts). 24-viewBox, stroke 2, round caps — rendered at
* 16px inside SidebarNav's 26px section-hue tile.
*/
function glyph(children: ReactNode) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
export const menuSections: MenuSection[] = [
{
label: "Přehled",
hue: "primary",
items: [
{
path: "/",
label: "Přehled",
end: true,
// tachometr — dashboard
icon: glyph(
<>
<circle cx="12" cy="13" r="8.5" />
<path d="M12 13l4-4.5" />
</>,
),
},
{
path: "/odin",
label: "Odin",
permission: "ai.use",
end: true,
icon: <OdinMark size={26} />,
rawIcon: true,
},
],
},
{
label: "Docházka",
hue: "info",
items: [
{
path: "/attendance",
label: "Záznam",
permission: "attendance.record",
end: true,
// píchačky — hodiny
icon: glyph(
<>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 15" />
</>,
),
},
{
path: "/attendance/history",
label: "Moje historie",
permission: "attendance.history",
// výkaz s hodinami — timesheet
icon: glyph(
<>
<path d="M14 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h6" />
<line x1="7" y1="8" x2="13" y2="8" />
<line x1="7" y1="12" x2="11" y2="12" />
<circle cx="16.5" cy="16" r="4.5" />
<polyline points="16.5 14 16.5 16 18 17" />
</>,
),
},
{
path: "/attendance/requests",
label: "Žádosti",
permission: "attendance.record",
// odeslaná žádost — vlaštovka
icon: glyph(
<>
<path d="M22 2L11 13" />
<path d="M22 2l-7 20-4-9-9-4z" />
</>,
),
},
{
path: "/attendance/approval",
label: "Schvalování",
permission: "attendance.approve",
// razítko
icon: glyph(
<>
<path d="M9.5 12V9.8C9.5 8.4 8 7.8 8 6a4 4 0 0 1 8 0c0 1.8-1.5 2.4-1.5 3.8V12" />
<path d="M5 16a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2H5z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
),
},
{
path: "/attendance/admin",
label: "Správa",
permission: "attendance.manage",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
// osoba + fajfka
icon: glyph(
<>
<circle cx="9" cy="8" r="3.5" />
<path d="M3 20a6 6 0 0 1 12 0" />
<path d="M15.5 11.5l2 2 4-4" />
</>,
),
},
{
path: "/attendance/balances",
label: "Správa bilancí",
permission: "attendance.balances",
// kniha plus/mínus — ruční korekce bilancí
icon: glyph(
<>
<rect x="4" y="3" width="16" height="18" rx="2" />
<line x1="9" y1="8.5" x2="15" y2="8.5" />
<line x1="12" y1="5.5" x2="12" y2="11.5" />
<line x1="9" y1="16" x2="15" y2="16" />
</>,
),
},
{
path: "/plan-work",
label: "Plán prací",
permission: ["attendance.manage", "attendance.record"],
// kalendář s plánovacím blokem
icon: glyph(
<>
<rect x="3" y="4" width="18" height="17" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="9.5" x2="21" y2="9.5" />
<path d="M8 14h3v3H8z" />
</>,
),
},
],
},
{
label: "Kniha jízd",
hue: "teal",
items: [
{
path: "/trips",
label: "Záznam",
permission: "trips.record",
end: true,
// trasa A→B
icon: glyph(
<>
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="5" r="2" />
<path d="M5 17C5 11 19 13 19 7" />
</>,
),
},
{
path: "/trips/history",
label: "Moje historie",
permission: "trips.history",
// navštívená místa — pin
icon: glyph(
<>
<path d="M12 21s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z" />
<circle cx="12" cy="11" r="2.5" />
</>,
),
},
{
path: "/trips/admin",
label: "Správa",
permission: "trips.manage",
// schránka s fajfkou — kniha jízd
icon: glyph(
<>
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M9 2h6v4H9z" />
<path d="M9 13l2 2 4-4" />
</>,
),
},
{
path: "/vehicles",
label: "Vozidla",
permission: "vehicles.manage",
// auto
icon: glyph(
<>
<path d="M5 17H3v-4l2-5h11l3 5h2v4h-2" />
<circle cx="7.5" cy="17" r="2" />
<circle cx="16.5" cy="17" r="2" />
<line x1="9.5" y1="17" x2="14.5" y2="17" />
</>,
),
},
],
},
{
label: "Administrativa",
hue: "warning",
items: [
{
path: "/offers",
label: "Nabídky",
permission: "offers.view",
matchPrefix: "/offers",
matchExclude: ["/offers/customers", "/offers/templates"],
// cenovka
icon: glyph(
<>
<path d="M20.6 13.4L11 3H4v7l9.6 10.4a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 0 0-2.8z" />
<circle cx="8" cy="8" r="1.5" />
</>,
),
},
{
path: "/orders",
label: "Objednávky",
permission: "orders.view",
matchPrefix: "/orders",
// košík
icon: glyph(
<>
<circle cx="9" cy="20" r="1.6" />
<circle cx="17" cy="20" r="1.6" />
<path d="M2 3h3l2.7 12h10.6l2.7-9H6" />
</>,
),
},
{
path: "/invoices",
label: "Faktury",
permission: "invoices.view",
matchPrefix: "/invoices",
// účtenka
icon: glyph(
<>
<path d="M6 2h12v20l-3-2-3 2-3-2-3 2z" />
<line x1="9" y1="7" x2="15" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</>,
),
},
{
path: "/projects",
label: "Projekty",
permission: "projects.view",
matchPrefix: "/projects",
// složka (NAS projektové složky)
icon: glyph(
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />,
),
},
{
path: "/offers/customers",
label: "Zákazníci",
permission: "customers.view",
// vizitka
icon: glyph(
<>
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="8.5" cy="10.5" r="2.2" />
<path d="M5 16.5a3.5 3.5 0 0 1 7 0" />
<line x1="14.5" y1="9.5" x2="19" y2="9.5" />
<line x1="14.5" y1="13.5" x2="19" y2="13.5" />
</>,
),
},
],
},
{
label: "Sklad",
hue: "violet",
items: [
{
path: "/warehouse",
label: "Přehled",
permission: "warehouse.view",
end: true,
// skladová hala
icon: glyph(
<>
<path d="M3 21V9l9-5 9 5v12" />
<path d="M7 21v-7h10v7" />
<line x1="7" y1="17.5" x2="17" y2="17.5" />
</>,
),
},
{
path: "/warehouse/items",
label: "Položky",
permission: "warehouse.view",
matchPrefix: "/warehouse/items",
// krabice
icon: glyph(
<>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</>,
),
},
{
path: "/warehouse/receipts",
label: "Příjmy",
permission: "warehouse.operate",
matchPrefix: "/warehouse/receipts",
// šipka do krabice
icon: glyph(
<>
<rect x="4" y="12" width="16" height="9" rx="1" />
<polyline points="8 6 12 10 16 6" />
<line x1="12" y1="2" x2="12" y2="10" />
</>,
),
},
{
path: "/warehouse/issues",
label: "Výdeje",
permission: "warehouse.operate",
matchPrefix: "/warehouse/issues",
// šipka z krabice ven
icon: glyph(
<>
<rect x="4" y="13" width="16" height="8" rx="1" />
<polyline points="8 6 12 2 16 6" />
<line x1="12" y1="2" x2="12" y2="11" />
</>,
),
},
{
path: "/warehouse/reservations",
label: "Rezervace",
permission: "warehouse.operate",
matchPrefix: "/warehouse/reservations",
// krabice se záložkou
icon: glyph(
<>
<rect x="3" y="8" width="13" height="12" rx="1" />
<path d="M16 3h5v9l-2.5-2L16 12z" />
</>,
),
},
{
path: "/warehouse/inventory",
label: "Inventura",
permission: "warehouse.inventory",
matchPrefix: "/warehouse/inventory",
// čárový kód — fyzický sken zásob
icon: glyph(
<>
<line x1="4" y1="6" x2="4" y2="18" />
<line x1="8" y1="6" x2="8" y2="18" />
<line x1="12" y1="6" x2="12" y2="18" />
<line x1="17" y1="6" x2="17" y2="18" />
<line x1="20" y1="6" x2="20" y2="18" />
</>,
),
},
{
path: "/warehouse/reports",
label: "Reporty",
permission: "warehouse.view",
end: true,
// graf v rámu
icon: glyph(
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<polyline points="7 14 11 10 14 13 17 8" />
</>,
),
},
{
path: "/warehouse/categories",
label: "Kategorie",
permission: "warehouse.manage",
matchPrefix: "/warehouse/categories",
// tvary — třídění do kategorií
icon: glyph(
<>
<circle cx="7.5" cy="7.5" r="3.5" />
<rect x="13.5" y="4" width="7" height="7" rx="1" />
<path d="M7.5 13.5l3.5 7h-7z" />
<rect x="13.5" y="13.5" width="7" height="7" rx="3.5" />
</>,
),
},
{
path: "/warehouse/locations",
label: "Lokace",
permission: "warehouse.manage",
matchPrefix: "/warehouse/locations",
// regál s krabicemi
icon: glyph(
<>
<path d="M3 21V4" />
<path d="M21 21V4" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="21" x2="21" y2="21" />
<rect x="7" y="8" width="4" height="4" />
<rect x="13" y="17" width="4" height="4" />
</>,
),
},
{
path: "/warehouse/suppliers",
label: "Dodavatelé",
permission: "warehouse.manage",
matchPrefix: "/warehouse/suppliers",
// kamion
icon: glyph(
<>
<rect x="1" y="6" width="13" height="10" rx="1" />
<path d="M14 9h4l3 4v3h-7" />
<circle cx="6" cy="18.5" r="2" />
<circle cx="17.5" cy="18.5" r="2" />
</>,
),
},
],
},
{
label: "Systém",
hue: "slate",
items: [
{
path: "/users",
label: "Uživatelé",
permission: "users.view",
// lidé — uživatelské účty
icon: glyph(
<>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>,
),
},
{
path: "/settings",
label: "Nastavení",
permission: [
"settings.company",
"settings.banking",
"settings.roles",
"settings.system",
"settings.templates",
],
// ozubené kolo
icon: glyph(
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</>,
),
},
{
path: "/audit-log",
label: "Audit log",
permission: "settings.audit",
// lupa nad záznamy
icon: glyph(
<>
<path d="M4 5h12" />
<path d="M4 10h6" />
<path d="M4 15h5" />
<circle cx="15" cy="14" r="4.5" />
<line x1="18.2" y1="17.2" x2="21.5" y2="20.5" />
</>,
),
},
],
},
];
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
export function isItemActive(item: MenuItem, pathname: string): boolean {
if (item.matchPrefix) {
let active = pathname.startsWith(item.matchPrefix);
if (active && item.matchExclude) {
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
}
if (!active && item.matchAlso) {
active = item.matchAlso.some((p) => pathname.startsWith(p));
}
return active;
}
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
return true;
}
if (item.end) return pathname === item.path;
// Boundary-safe prefix match: exact, or a child route under item.path (so a
// sibling like "/x-foo" can't spuriously activate "/x"). Avoids a double "/"
// when item.path is the root.
return (
pathname === item.path ||
pathname.startsWith(item.path === "/" ? "/" : `${item.path}/`)
);
}
/** Permission check: true if no permission, or any listed permission is held. */
export function hasItemPermission(
item: MenuItem,
hasPermission: (p: string) => boolean,
): boolean {
if (!item.permission) return true;
if (Array.isArray(item.permission)) {
return item.permission.some((p) => hasPermission(p));
}
return hasPermission(item.permission);
}