_synchronisiere_fehlbestand suchte nur nach Fehlbeständen der eigenen
Kontroll-Sitzung. Ein bereits offener Fehlbestand aus einer früheren
Kontrolle für dasselbe Objekt+Material wurde nicht wiedergefunden -
es entstand ein zweiter, paralleler Fehlbestand mit eigener (neuerer)
Sollmenge, der alte blieb mit veralteter Sollmenge offen liegen.
Lookup jetzt über objekt_id+material_id+status=offen statt
kontrolle_id; kontrolle_id am gefundenen Datensatz bleibt unverändert,
damit ein Abbruch der aktuellen Kontrolle ihn nicht fälschlich
mitlöscht. Löst sich die Abweichung über eine andere Kontrolle als
die erzeugende auf, wird der Fehlbestand jetzt sauber als erledigt
markiert (inkl. Mindermenge-Abschluss) statt stillschweigend gelöscht.
Nebenbei: Kommentare mit Kachel-/Datums-Referenzen ("Bug-Fund
2026-09-06", "UI-00X (Epic 20)") aus dem Code dieser Session entfernt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
145 lines
4.9 KiB
TypeScript
145 lines
4.9 KiB
TypeScript
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;
|
|
}
|
|
|
|
/**
|
|
* 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-ausgabe", label: "Ausgabe/Rückgabe", gehe: () => navigate("/admin?tab=ausgabe") },
|
|
{ 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>
|
|
);
|
|
}
|