From 16a42c6b85987f86dc2f353656c2e5ca7f721c00 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 6 Sep 2026 13:09:56 +0200 Subject: [PATCH] feat(ui): UI-002 Command Palette und Deep-Links statt Nav-Umbau Globale Suche/Quick Actions per Strg/Cmd+K (CommandPalette): Objekt- Suche nach Name/Code, Sprung zu QR-Scan/Material/Lager/Personal/ Verwaltung. AdminPage-Tab jetzt im URL-Query (?tab=...) statt nur lokalem State, damit die Palette gezielt verlinken kann, ohne Routen zu duplizieren. Bewusst keine neuen Sidebar-Punkte fuer Fahrzeuge/ Kontrollen/Pruefungen - dafuer gibt es keine eigenstaendigen Listen- Seiten in MABEA. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/20_ui_redesign.md | 11 +- frontend/src/components/AppShell.tsx | 9 ++ frontend/src/components/CommandPalette.tsx | 143 +++++++++++++++++++++ frontend/src/pages/AdminPage.tsx | 10 +- frontend/src/pages/ObjektListPage.tsx | 13 +- frontend/src/styles/global.css | 55 ++++++++ 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/CommandPalette.tsx diff --git a/arbeitskacheln/20_ui_redesign.md b/arbeitskacheln/20_ui_redesign.md index 22e4c79..233ae2c 100644 --- a/arbeitskacheln/20_ui_redesign.md +++ b/arbeitskacheln/20_ui_redesign.md @@ -47,7 +47,16 @@ Aktueller Stand vor Redesign: 39 Seiten-/Komponenten-Dateien, ~7.000 Zeilen, ein - **Akzeptanzkriterien:** alle bestehenden Routen weiter erreichbar, Suche findet Objekte per Name/Code. - **Tests:** keine neuen (reines Routing/UI). -- **DoD:** offen. +- **DoD:** umgesetzt (2026-09-06). Sidebar/Mobile-Nav blieb wie in UI-001 vorgefunden + (bereits responsiv), stattdessen: `CommandPalette` (Strg/⌘+K, auch per Button + "🔍 Suche" erreichbar) mit Objekt-Suche (Name/Code) + Quick Actions + (QR scannen → `/objekte?scan=1`, Material/Lager/Personal/Verwaltung). + `AdminPage`-Tab jetzt in der URL (`?tab=...`) statt nur lokalem State, damit + die Palette gezielt auf einen Tab verlinken kann - keine Routen-Duplizierung. + "Fahrzeuge/Kontrollen/Prüfungen" bekamen bewusst keine eigenen Nav-Punkte: + es gibt dafür keine eigenständigen Listen-Seiten in MABEA (Fahrzeuge sind ein + Objekttyp unter "Objekte", Kontrolle/Prüfung laufen objektbezogen über die + Akte) - ein Nav-Punkt ohne Zielseite wäre Fake-Navigation. ## UI-003 — Zentrale Statuskomponenten diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d05f4c9..69b9288 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -2,8 +2,13 @@ import type { ReactNode } from "react"; import { NavLink, useNavigate } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; +import { CommandPalette } from "./CommandPalette"; import { ThemeToggle } from "./ThemeToggle"; +function paletteOeffnen() { + window.dispatchEvent(new Event("mabea:open-command-palette")); +} + /** Gemeinsamer Rahmen (Kopfzeile + Navigation) für alle eingeloggten Screens. */ export function AppShell({ children }: { children: ReactNode }) { const { istAdmin, istVerantwortlich, logout } = useAuth(); @@ -42,12 +47,16 @@ export function AppShell({ children }: { children: ReactNode }) { {istAdmin ? "Administration" : "Verwaltung"} )} + + {children} ); diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..eca61a1 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { apiRequest } from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import type { Objekt } from "../api/types"; + +interface Eintrag { + id: string; + label: string; + hinweis?: string; + gehe: () => void; +} + +/** + * UI-002 (Epic 20): globale Suche/Quick Actions per Ctrl+K bzw. ⌘+K. + * Öffnet sich über der ganzen App (AppShell), navigiert nur - keine eigene + * Fachlogik, nur Zusammenfassung bereits vorhandener Routen/Aktionen. + */ +export function CommandPalette() { + const navigate = useNavigate(); + const { istVerantwortlich, istAdmin } = useAuth(); + const [offen, setOffen] = useState(false); + const [suche, setSuche] = useState(""); + const [objekte, setObjekte] = useState([]); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOffen((v) => !v); + } + if (e.key === "Escape") setOffen(false); + } + function onOeffnenAnfrage() { + setOffen(true); + } + window.addEventListener("keydown", onKeyDown); + window.addEventListener("mabea:open-command-palette", onOeffnenAnfrage); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("mabea:open-command-palette", onOeffnenAnfrage); + }; + }, []); + + useEffect(() => { + if (offen && objekte.length === 0) { + apiRequest("/objekte") + .then(setObjekte) + .catch(() => undefined); + } + if (!offen) setSuche(""); + }, [offen, objekte.length]); + + const aktionen = useMemo(() => { + const liste: Eintrag[] = [ + { id: "nav-objekte", label: "Objekte", gehe: () => navigate("/objekte") }, + { id: "nav-maengel", label: "Mängel", gehe: () => navigate("/maengel") }, + { id: "action-scan", label: "QR scannen", hinweis: "Quick Action", gehe: () => navigate("/objekte?scan=1") }, + ]; + if (istVerantwortlich) { + liste.push( + { id: "nav-dashboard", label: "Dashboard", gehe: () => navigate("/dashboard") }, + { id: "nav-fehlbestaende", label: "Fehlbestände", gehe: () => navigate("/fehlbestaende") }, + { + id: "action-material", + label: "Material buchen (Lager)", + hinweis: "Quick Action", + gehe: () => navigate("/admin?tab=lager"), + }, + { id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") }, + { id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") }, + { + id: "nav-material", + label: "Material (Stammdaten)", + gehe: () => navigate(`/admin?tab=${istAdmin ? "material" : "objekte"}`), + }, + { id: "nav-admin", label: istAdmin ? "Administration" : "Verwaltung", gehe: () => navigate("/admin") } + ); + } + return liste; + }, [navigate, istVerantwortlich, istAdmin]); + + const objektTreffer = useMemo(() => { + if (suche.trim().length === 0) return []; + const s = suche.toLowerCase(); + return objekte + .filter((o) => o.name.toLowerCase().includes(s) || o.code.toLowerCase().includes(s)) + .slice(0, 8) + .map((o) => ({ + id: `objekt-${o.id}`, + label: o.name, + hinweis: o.code, + gehe: () => navigate(`/akte/objekt/${o.id}`), + })); + }, [suche, objekte, navigate]); + + const gefilterteAktionen = useMemo(() => { + if (suche.trim().length === 0) return aktionen; + const s = suche.toLowerCase(); + return aktionen.filter((a) => a.label.toLowerCase().includes(s)); + }, [suche, aktionen]); + + if (!offen) return null; + + const treffer = [...objektTreffer, ...gefilterteAktionen]; + + function auswaehlen(eintrag: Eintrag) { + eintrag.gehe(); + setOffen(false); + } + + return ( +
setOffen(false)} + role="dialog" + aria-modal="true" + aria-label="Globale Suche" + > +
e.stopPropagation()}> + setSuche(e.target.value)} + /> +
    + {treffer.length === 0 &&
  • Keine Treffer.
  • } + {treffer.map((eintrag) => ( +
  • + +
  • + ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 20c3560..d30a098 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { apiRequest } from "../api/client"; import { useAuth } from "../auth/AuthContext"; @@ -96,9 +97,16 @@ export function AdminPage() { const [qualifikationstypen, setQualifikationstypen] = useState([]); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); - const [tab, setTab] = useState(null); + // UI-002 (Epic 20): Tab im URL-Query statt nur lokalem State - erlaubt + // Deep-Links von der Command Palette (z.B. /admin?tab=material). + const [suchparameter, setSuchparameter] = useSearchParams(); + const tab = suchparameter.get("tab") as Tab | null; const aktiverTab = tab && sichtbareTabs.some((t) => t.key === tab) ? tab : sichtbareTabs[0]?.key ?? null; + function setTab(neuerTab: Tab) { + setSuchparameter({ tab: neuerTab }); + } + async function ladeAlles() { // /benutzer ist admin-only (Backend require_roles) - Promise.all würde bei // Material-/Leitungsverantwortlichen mit 403 durchfallen und die komplette diff --git a/frontend/src/pages/ObjektListPage.tsx b/frontend/src/pages/ObjektListPage.tsx index bf6d4b5..f006531 100644 --- a/frontend/src/pages/ObjektListPage.tsx +++ b/frontend/src/pages/ObjektListPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { apiRequest, ApiError } from "../api/client"; import type { Objekt } from "../api/types"; @@ -16,6 +16,17 @@ export function ObjektListPage() { const [ladefehler, setLadefehler] = useState(null); const [laedt, setLaedt] = useState(true); const [scannerOffen, setScannerOffen] = useState(false); + const [suchparameter, setSuchparameter] = useSearchParams(); + + // UI-002 (Epic 20): Quick Action "QR scannen" aus der Command Palette springt + // hierher mit ?scan=1 und öffnet den Scanner direkt, statt nur die Liste zu zeigen. + useEffect(() => { + if (suchparameter.get("scan") === "1") { + setScannerOffen(true); + setSuchparameter({}, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { apiRequest("/objekte") diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 4e8cb1a..a5ef15b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -462,6 +462,61 @@ select.input:focus { color: var(--color-text); } +/* UI-002 (Epic 20): Command Palette (Ctrl/Cmd+K) */ +.command-palette-overlay { + position: fixed; + inset: 0; + background: rgba(10, 14, 20, 0.5); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + z-index: 200; +} + +.command-palette { + width: min(90vw, 32rem); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + padding: 0.75rem; +} + +.command-palette-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + max-height: 60vh; + overflow-y: auto; +} + +.command-palette-empty { + padding: 0.6rem 0.5rem; +} + +.command-palette-item { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + text-align: left; + padding: 0.55rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.95rem; + color: var(--color-text); + cursor: pointer; + font-family: inherit; +} + +.command-palette-item:hover, +.command-palette-item:focus-visible { + background: var(--color-surface-sunken); +} + .alert { border-radius: var(--radius-sm); padding: 0.75rem 1rem;