diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 27a5c2c..8a540fc 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -69,6 +69,26 @@ export interface Beladungsvorlage { status: string; } +export interface Bereich { + id: number; + name: string; + beschreibung: string | null; +} + +export interface Kategorie { + id: number; + bereich_id: number; + name: string; + ueberkategorie_id: number | null; +} + +export interface Objekttyp { + id: number; + bereich_id: number; + kategorie_id: number | null; + name: string; +} + export interface Me { id: number; name: string; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 641c48b..a855178 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,22 +1,28 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../api/client"; -import type { Beladungsvorlage, Material, Objekt, Standort } from "../api/types"; +import type { Bereich, Beladungsvorlage, Kategorie, Material, Objekt, Objekttyp, Standort } from "../api/types"; +import { BereichSection } from "./admin/BereichSection"; +import { KategorieSection } from "./admin/KategorieSection"; import { MaterialSection } from "./admin/MaterialSection"; import { ObjektSection } from "./admin/ObjektSection"; +import { ObjekttypSection } from "./admin/ObjekttypSection"; import { StandortSection } from "./admin/StandortSection"; +import { VorlageSection } from "./admin/VorlageSection"; /** - * Minimale Admin-Seite (Standort/Material/Objekt-Anlage), nachgezogen weil - * das Frontend bisher nur den Mitarbeiter-Kontrollflow (Sprint 7) abdeckte - * und Stammdaten/Objekte nur per API/Skript angelegt werden konnten. Bewusst - * schlank: keine Bereich-/Kategorie-/Vorlagen-CRUD, nur was für die - * Pilotphase sofort nötig ist (Vorlagen kommen aus dem Excel-Import). + * 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 (Standort/Material/Objekt) ist eine eigene Komponente unter - * pages/admin/ - hält diese Datei als reinen Orchestrator klein. + * 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 [bereiche, setBereiche] = useState([]); + const [kategorien, setKategorien] = useState([]); + const [objekttypen, setObjekttypen] = useState([]); const [standorte, setStandorte] = useState([]); const [vorlagen, setVorlagen] = useState([]); const [objekte, setObjekte] = useState([]); @@ -25,14 +31,20 @@ export function AdminPage() { const [laedt, setLaedt] = useState(true); async function ladeAlles() { - const [s, v, o, m] = await Promise.all([ + const [be, ka, ot, s, v, o, m] = await Promise.all([ + apiRequest("/bereiche"), + apiRequest("/kategorien"), + apiRequest("/objekttypen"), apiRequest("/standorte"), apiRequest("/vorlagen"), apiRequest("/objekte"), apiRequest("/materialien"), ]); + setBereiche(be); + setKategorien(ka); + setObjekttypen(ot); setStandorte(s); - setVorlagen(v.filter((vorlage) => vorlage.status === "aktiv")); + setVorlagen(v); setObjekte(o); setMaterialien(m); } @@ -56,11 +68,27 @@ export function AdminPage() {

Administration

{fehler &&
{fehler}
} - + + + + + v.status === "aktiv")} standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} diff --git a/frontend/src/pages/admin/BereichSection.tsx b/frontend/src/pages/admin/BereichSection.tsx new file mode 100644 index 0000000..03adc7c --- /dev/null +++ b/frontend/src/pages/admin/BereichSection.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Bereich } from "../../api/types"; + +interface Props { + bereiche: Bereich[]; + onGeaendert: () => Promise; + onFehler: (text: string) => void; +} + +export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) { + const [name, setName] = useState(""); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + + async function anlegen() { + setWirdAngelegt(true); + try { + await apiRequest("/bereiche", { method: "POST", body: { name } }); + setName(""); + await onGeaendert(); + } catch { + onFehler("Bereich konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + return ( +
+

Bereich anlegen

+
+
+ setName(e.target.value)} placeholder="Name" /> + +
+ {bereiche.length > 0 && ( +
    + {bereiche.map((b) => ( +
  • + {b.name} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/KategorieSection.tsx b/frontend/src/pages/admin/KategorieSection.tsx new file mode 100644 index 0000000..076ed04 --- /dev/null +++ b/frontend/src/pages/admin/KategorieSection.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Bereich, Kategorie } from "../../api/types"; + +interface Props { + bereiche: Bereich[]; + kategorien: Kategorie[]; + onGeaendert: () => Promise; + onFehler: (text: string) => void; +} + +export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }: Props) { + const [name, setName] = useState(""); + const [bereichId, setBereichId] = useState(""); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + + async function anlegen() { + if (bereichId === "") { + onFehler("Bereich auswählen."); + return; + } + setWirdAngelegt(true); + try { + await apiRequest("/kategorien", { method: "POST", body: { name, bereich_id: bereichId } }); + setName(""); + await onGeaendert(); + } catch { + onFehler("Kategorie konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + function bereichName(id: number) { + return bereiche.find((b) => b.id === id)?.name ?? `#${id}`; + } + + return ( +
+

Kategorie anlegen

+
+ {bereiche.length === 0 ? ( +

Erst einen Bereich anlegen.

+ ) : ( +
+
+ + setName(e.target.value)} placeholder="Name" /> + +
+
+ )} + {kategorien.length > 0 && ( +
    + {kategorien.map((k) => ( +
  • + {k.name} + {bereichName(k.bereich_id)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/ObjekttypSection.tsx b/frontend/src/pages/admin/ObjekttypSection.tsx new file mode 100644 index 0000000..e09a536 --- /dev/null +++ b/frontend/src/pages/admin/ObjekttypSection.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Bereich, Kategorie, Objekttyp } from "../../api/types"; + +interface Props { + bereiche: Bereich[]; + kategorien: Kategorie[]; + objekttypen: Objekttyp[]; + onGeaendert: () => Promise; + onFehler: (text: string) => void; +} + +export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaendert, onFehler }: Props) { + const [name, setName] = useState(""); + const [bereichId, setBereichId] = useState(""); + const [kategorieId, setKategorieId] = useState(""); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + + async function anlegen() { + if (bereichId === "") { + onFehler("Bereich auswählen."); + return; + } + setWirdAngelegt(true); + try { + await apiRequest("/objekttypen", { + method: "POST", + body: { name, bereich_id: bereichId, kategorie_id: kategorieId || null }, + }); + setName(""); + await onGeaendert(); + } catch { + onFehler("Objekttyp konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + return ( +
+

Objekttyp anlegen

+
+ {bereiche.length === 0 ? ( +

Erst einen Bereich anlegen.

+ ) : ( +
+
+ + +
+
+ setName(e.target.value)} placeholder="Name" /> + +
+
+ )} + {objekttypen.length > 0 && ( +
    + {objekttypen.map((o) => ( +
  • + {o.name} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/VorlageSection.tsx b/frontend/src/pages/admin/VorlageSection.tsx new file mode 100644 index 0000000..bf2c06f --- /dev/null +++ b/frontend/src/pages/admin/VorlageSection.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Beladungsvorlage, Material, Objekttyp } from "../../api/types"; + +interface PositionZeile { + materialId: number | ""; + fach: string; + sollmenge: string; +} + +const LEERE_ZEILE: PositionZeile = { materialId: "", fach: "", sollmenge: "1" }; + +interface Props { + vorlagen: Beladungsvorlage[]; + objekttypen: Objekttyp[]; + materialien: Material[]; + onGeaendert: () => Promise; + onFehler: (text: string) => void; +} + +export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert, onFehler }: Props) { + const [name, setName] = useState(""); + const [objekttypId, setObjekttypId] = useState(""); + const [zeilen, setZeilen] = useState([LEERE_ZEILE]); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + + function zeileAendern(index: number, zeile: Partial) { + setZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z))); + } + + async function anlegen() { + if (objekttypId === "") { + onFehler("Objekttyp auswählen."); + return; + } + const positionen = zeilen + .filter((z) => z.materialId !== "") + .map((z) => ({ material_id: z.materialId, fach: z.fach || null, sollmenge: z.sollmenge })); + + setWirdAngelegt(true); + try { + await apiRequest("/vorlagen", { + method: "POST", + body: { objekttyp_id: objekttypId, name, positionen }, + }); + setName(""); + setZeilen([LEERE_ZEILE]); + await onGeaendert(); + } catch { + onFehler("Vorlage konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + return ( +
+

Vorlage anlegen

+
+ {objekttypen.length === 0 ? ( +

Erst einen Objekttyp anlegen.

+ ) : ( +
+
+ + setName(e.target.value)} placeholder="Name" /> +
+ +

+ Positionen (optional, können auch später nachgepflegt werden) +

+ {zeilen.map((zeile, i) => ( +
+ + zeileAendern(i, { fach: e.target.value })} + placeholder="Fach" + /> + zeileAendern(i, { sollmenge: e.target.value })} + /> +
+ ))} + + + +
+ )} + + {vorlagen.length > 0 && ( +
    + {vorlagen.map((v) => ( +
  • + {v.name} + v{v.version} +
  • + ))} +
+ )} +
+
+ ); +}