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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -47,7 +47,16 @@ Aktueller Stand vor Redesign: 39 Seiten-/Komponenten-Dateien, ~7.000 Zeilen, ein
|
|||||||
- **Akzeptanzkriterien:** alle bestehenden Routen weiter erreichbar, Suche findet
|
- **Akzeptanzkriterien:** alle bestehenden Routen weiter erreichbar, Suche findet
|
||||||
Objekte per Name/Code.
|
Objekte per Name/Code.
|
||||||
- **Tests:** keine neuen (reines Routing/UI).
|
- **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
|
## UI-003 — Zentrale Statuskomponenten
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import type { ReactNode } from "react";
|
|||||||
import { NavLink, useNavigate } from "react-router-dom";
|
import { NavLink, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
|
function paletteOeffnen() {
|
||||||
|
window.dispatchEvent(new Event("mabea:open-command-palette"));
|
||||||
|
}
|
||||||
|
|
||||||
/** Gemeinsamer Rahmen (Kopfzeile + Navigation) für alle eingeloggten Screens. */
|
/** Gemeinsamer Rahmen (Kopfzeile + Navigation) für alle eingeloggten Screens. */
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const { istAdmin, istVerantwortlich, logout } = useAuth();
|
const { istAdmin, istVerantwortlich, logout } = useAuth();
|
||||||
@@ -42,12 +47,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{istAdmin ? "Administration" : "Verwaltung"}
|
{istAdmin ? "Administration" : "Verwaltung"}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
<button className="btn btn-secondary" onClick={paletteOeffnen} title="Globale Suche (Strg/⌘+K)">
|
||||||
|
🔍 Suche
|
||||||
|
</button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button className="btn btn-secondary" onClick={handleLogout}>
|
<button className="btn btn-secondary" onClick={handleLogout}>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
<CommandPalette />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<Objekt[]>([]);
|
||||||
|
|
||||||
|
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<Objekt[]>("/objekte")
|
||||||
|
.then(setObjekte)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (!offen) setSuche("");
|
||||||
|
}, [offen, objekte.length]);
|
||||||
|
|
||||||
|
const aktionen = useMemo<Eintrag[]>(() => {
|
||||||
|
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<Eintrag[]>(() => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="command-palette-overlay"
|
||||||
|
onClick={() => setOffen(false)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Globale Suche"
|
||||||
|
>
|
||||||
|
<div className="command-palette" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="input input-search"
|
||||||
|
placeholder="Objekt suchen oder Aktion wählen…"
|
||||||
|
value={suche}
|
||||||
|
onChange={(e) => setSuche(e.target.value)}
|
||||||
|
/>
|
||||||
|
<ul className="command-palette-list">
|
||||||
|
{treffer.length === 0 && <li className="text-muted command-palette-empty">Keine Treffer.</li>}
|
||||||
|
{treffer.map((eintrag) => (
|
||||||
|
<li key={eintrag.id}>
|
||||||
|
<button className="command-palette-item" onClick={() => auswaehlen(eintrag)}>
|
||||||
|
<span>{eintrag.label}</span>
|
||||||
|
{eintrag.hinweis && <span className="text-muted">{eintrag.hinweis}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { apiRequest } from "../api/client";
|
import { apiRequest } from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -96,9 +97,16 @@ export function AdminPage() {
|
|||||||
const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]);
|
const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]);
|
||||||
const [fehler, setFehler] = useState<string | null>(null);
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
const [laedt, setLaedt] = useState(true);
|
const [laedt, setLaedt] = useState(true);
|
||||||
const [tab, setTab] = useState<Tab | null>(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;
|
const aktiverTab = tab && sichtbareTabs.some((t) => t.key === tab) ? tab : sichtbareTabs[0]?.key ?? null;
|
||||||
|
|
||||||
|
function setTab(neuerTab: Tab) {
|
||||||
|
setSuchparameter({ tab: neuerTab });
|
||||||
|
}
|
||||||
|
|
||||||
async function ladeAlles() {
|
async function ladeAlles() {
|
||||||
// /benutzer ist admin-only (Backend require_roles) - Promise.all würde bei
|
// /benutzer ist admin-only (Backend require_roles) - Promise.all würde bei
|
||||||
// Material-/Leitungsverantwortlichen mit 403 durchfallen und die komplette
|
// Material-/Leitungsverantwortlichen mit 403 durchfallen und die komplette
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { apiRequest, ApiError } from "../api/client";
|
||||||
import type { Objekt } from "../api/types";
|
import type { Objekt } from "../api/types";
|
||||||
@@ -16,6 +16,17 @@ export function ObjektListPage() {
|
|||||||
const [ladefehler, setLadefehler] = useState<string | null>(null);
|
const [ladefehler, setLadefehler] = useState<string | null>(null);
|
||||||
const [laedt, setLaedt] = useState(true);
|
const [laedt, setLaedt] = useState(true);
|
||||||
const [scannerOffen, setScannerOffen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
apiRequest<Objekt[]>("/objekte")
|
apiRequest<Objekt[]>("/objekte")
|
||||||
|
|||||||
@@ -462,6 +462,61 @@ select.input:focus {
|
|||||||
color: var(--color-text);
|
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 {
|
.alert {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user