Files
MABEA/frontend/src/pages/AdminPage.tsx
T
patrickandClaude Sonnet 5 f7367f6df3
CI / backend-tests (push) Successful in 1m56s
CI / frontend-build (push) Successful in 18s
feat(lager): Warehouse-Epic Kern (WH-001..004+007)
Lagerort-Hierarchie (organisation/standort/gebaeude/raum/lager, self-referenzierend),
Lagerplätze mit eindeutigem Code, Bestand je Lagerplatz+Material, Ein-/Auslagerung
mit lückenlosem Materialbewegungsprotokoll (append-only, analog Lagerbewegung
auf Objekt-Ebene, hier aber Material-/Lagerplatz-fein).

- Migration 0019: 4 neue Tabellen (lagerort, lagerplatz, bestand, materialbewegung)
- Backend: CRUD Lagerort/Lagerplatz, Bestandsabfrage, Ein-/Auslagern-Endpunkte,
  Negativbestand vorab abgefangen (kein DB-Constraint-Exception-Pfad, gleiches
  Muster wie objekte.py-Fix vom 2026-09-05)
- Frontend: neuer Admin-Tab "Lager" (Lagerort/-platz-Verwaltung, Bestand+Buchung
  je Lagerplatz)
- 2 neue Tests, 139 Tests weiterhin grün

WH-005 (Umlagerung) und WH-006 (Inventur) bewusst zurückgestellt - Kern zuerst,
laut Nutzer-Entscheidung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-05 21:42:37 +02:00

249 lines
10 KiB
TypeScript

import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types";
import { BenutzerSection } from "./admin/BenutzerSection";
import { BereichSection } from "./admin/BereichSection";
import { EinheitSection } from "./admin/EinheitSection";
import { EskalationSection } from "./admin/EskalationSection";
import { FachSection } from "./admin/FachSection";
import { HistorieSection } from "./admin/HistorieSection";
import { KategorieSection } from "./admin/KategorieSection";
import { LagerbewegungSection } from "./admin/LagerbewegungSection";
import { LagerSection } from "./admin/LagerSection";
import { RolleSection } from "./admin/RolleSection";
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
import { MaterialSection } from "./admin/MaterialSection";
import { ObjektSection } from "./admin/ObjektSection";
import { ObjekttypSection } from "./admin/ObjekttypSection";
import { QualifikationSection } from "./admin/QualifikationSection";
import { StandortSection } from "./admin/StandortSection";
import { VorlageSection } from "./admin/VorlageSection";
import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
type Tab =
| "objekte"
| "vorlagen"
| "material"
| "struktur"
| "standorte"
| "benutzer"
| "historie"
| "eskalation"
| "kontrollverantwortung"
| "zustaendigkeit"
| "personal"
| "lagerbewegungen"
| "lager"
| "rollen";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
// dasselbe wie Mitarbeiter - hier je Tab, wer laut Backend tatsächlich
// etwas damit anfangen kann).
const TABS: { key: Tab; label: string; rollen: ("administration" | "materialverantwortlicher" | "leitungsverantwortlicher")[] }[] = [
{ key: "objekte", label: "Objekte", rollen: ["administration", "materialverantwortlicher"] },
{ key: "vorlagen", label: "Vorlagen", rollen: ["administration", "materialverantwortlicher"] },
{ key: "material", label: "Material", rollen: ["administration"] },
{ key: "struktur", label: "Struktur", rollen: ["administration"] },
{ key: "standorte", label: "Standorte", rollen: ["administration"] },
{ key: "benutzer", label: "Benutzer", rollen: ["administration"] },
{ key: "zustaendigkeit", label: "Zuständigkeit", rollen: ["administration"] },
{
key: "kontrollverantwortung",
label: "Kontrollverantwortung",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{
key: "historie",
label: "Änderungslog",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "eskalation", label: "Eskalation", rollen: ["administration"] },
{ key: "personal", label: "Personal", rollen: ["administration"] },
{
key: "lagerbewegungen",
label: "Lagerbewegungen",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
];
/**
* Admin-Seite (Stammdaten/Objekt-Anlage), nachgezogen weil das Frontend
* bisher nur den Mitarbeiter-Kontrollflow (Sprint 7) abdeckte und
* Stammdaten/Objekte nur per API/Skript angelegt werden konnten.
*
* Jeder Abschnitt ist eine eigene Komponente unter pages/admin/ - hält diese
* Datei als reinen Orchestrator klein (siehe Projekt-Vorgabe: keine großen
* Monolith-Dateien).
*/
export function AdminPage() {
const { rollen, istAdmin } = useAuth();
const sichtbareTabs = TABS.filter((t) => t.rollen.some((r) => rollen.includes(r)));
const [bereiche, setBereiche] = useState<Bereich[]>([]);
const [kategorien, setKategorien] = useState<Kategorie[]>([]);
const [objekttypen, setObjekttypen] = useState<Objekttyp[]>([]);
const [standorte, setStandorte] = useState<Standort[]>([]);
const [vorlagen, setVorlagen] = useState<Beladungsvorlage[]>([]);
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [materialien, setMaterialien] = useState<Material[]>([]);
const [benutzer, setBenutzer] = useState<Benutzer[]>([]);
const [faecher, setFaecher] = useState<Fach[]>([]);
const [einheiten, setEinheiten] = useState<Einheit[]>([]);
const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]);
const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true);
const [tab, setTab] = useState<Tab | null>(null);
const aktiverTab = tab && sichtbareTabs.some((t) => t.key === tab) ? tab : sichtbareTabs[0]?.key ?? null;
async function ladeAlles() {
// /benutzer ist admin-only (Backend require_roles) - Promise.all würde bei
// Material-/Leitungsverantwortlichen mit 403 durchfallen und die komplette
// Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll-
// verantwortung) trotzdem funktionieren sollen. allSettled + leerer
// Fallback statt hartem Fehler.
const [be, ka, ot, s, v, o, m, b, f, ei, qt] = await Promise.allSettled([
apiRequest<Bereich[]>("/bereiche"),
apiRequest<Kategorie[]>("/kategorien"),
apiRequest<Objekttyp[]>("/objekttypen"),
apiRequest<Standort[]>("/standorte"),
apiRequest<Beladungsvorlage[]>("/vorlagen"),
apiRequest<Objekt[]>("/objekte"),
apiRequest<Material[]>("/materialien"),
apiRequest<Benutzer[]>("/benutzer"),
apiRequest<Fach[]>("/faecher"),
apiRequest<Einheit[]>("/einheiten"),
apiRequest<Qualifikationstyp[]>("/qualifikationstypen"),
]);
const wert = <T,>(r: PromiseSettledResult<T[]>): T[] => (r.status === "fulfilled" ? r.value : []);
setBereiche(wert(be));
setKategorien(wert(ka));
setObjekttypen(wert(ot));
setStandorte(wert(s));
setVorlagen(wert(v));
setObjekte(wert(o));
setMaterialien(wert(m));
setBenutzer(wert(b));
setFaecher(wert(f));
setEinheiten(wert(ei));
setQualifikationstypen(wert(qt));
}
useEffect(() => {
ladeAlles()
.catch(() => setFehler("Stammdaten konnten nicht geladen werden."))
.finally(() => setLaedt(false));
}, []);
if (laedt) {
return (
<main className="page page-wide">
<p className="text-muted">Lade</p>
</main>
);
}
return (
<main className="page page-wide">
<h1>{istAdmin ? "Administration" : "Verwaltung"}</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
<nav className="row" style={{ marginBottom: "1.25rem", flexWrap: "wrap", gap: "0.5rem" }}>
{sichtbareTabs.map((t) => (
<button
key={t.key}
className={aktiverTab === t.key ? "btn btn-primary" : "btn btn-secondary"}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</nav>
{aktiverTab === "objekte" && (
<ObjektSection
objekte={objekte}
objekttypen={objekttypen}
vorlagen={vorlagen.filter((v) => v.status === "aktiv")}
standorte={standorte}
materialien={materialien}
nurPflegen={!istAdmin}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
)}
{aktiverTab === "vorlagen" && (
<VorlageSection
vorlagen={vorlagen}
objekttypen={objekttypen}
materialien={materialien}
faecher={faecher}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
)}
{aktiverTab === "material" && (
<MaterialSection materialien={materialien} onGeaendert={ladeAlles} onFehler={setFehler} />
)}
{aktiverTab === "struktur" && (
<>
<BereichSection bereiche={bereiche} onGeaendert={ladeAlles} onFehler={setFehler} />
<KategorieSection
bereiche={bereiche}
kategorien={kategorien}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
<ObjekttypSection
bereiche={bereiche}
kategorien={kategorien}
objekttypen={objekttypen}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
<FachSection
objekttypen={objekttypen}
faecher={faecher}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
</>
)}
{aktiverTab === "standorte" && (
<StandortSection standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} />
)}
{aktiverTab === "benutzer" && (
<BenutzerSection benutzer={benutzer} einheiten={einheiten} onGeaendert={ladeAlles} onFehler={setFehler} />
)}
{aktiverTab === "zustaendigkeit" && (
<ZustaendigkeitSection standorte={standorte} objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
)}
{aktiverTab === "kontrollverantwortung" && (
<KontrollverantwortungSection objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
)}
{aktiverTab === "historie" && <HistorieSection onFehler={setFehler} />}
{aktiverTab === "lagerbewegungen" && (
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
)}
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
<EinheitSection einheiten={einheiten} standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} />
<QualifikationSection
qualifikationstypen={qualifikationstypen}
benutzer={benutzer}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
</>
)}
{aktiverTab === null && <p className="text-muted">Keine Bereiche für deine Rolle verfügbar.</p>}
</main>
);
}