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
This commit is contained in:
@@ -96,6 +96,40 @@ export interface Lagerbewegung {
|
||||
grund: string | null;
|
||||
}
|
||||
|
||||
export type LagerortTyp = "organisation" | "standort" | "gebaeude" | "raum" | "lager";
|
||||
|
||||
export interface Lagerort {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
typ: LagerortTyp;
|
||||
}
|
||||
|
||||
export interface Lagerplatz {
|
||||
id: number;
|
||||
lagerort_id: number;
|
||||
code: string;
|
||||
bezeichnung: string | null;
|
||||
}
|
||||
|
||||
export interface Bestand {
|
||||
id: string;
|
||||
lagerplatz_id: number;
|
||||
material_id: number;
|
||||
menge: string;
|
||||
}
|
||||
|
||||
export interface Materialbewegung {
|
||||
id: string;
|
||||
material_id: number;
|
||||
menge: string;
|
||||
von_lagerplatz_id: number | null;
|
||||
nach_lagerplatz_id: number | null;
|
||||
benutzer_id: number;
|
||||
zeitpunkt: string;
|
||||
grund: string | null;
|
||||
}
|
||||
|
||||
export interface Fehlbestand {
|
||||
id: string;
|
||||
objekt_id: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -34,6 +35,7 @@ type Tab =
|
||||
| "zustaendigkeit"
|
||||
| "personal"
|
||||
| "lagerbewegungen"
|
||||
| "lager"
|
||||
| "rollen";
|
||||
|
||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||
@@ -65,6 +67,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
||||
label: "Lagerbewegungen",
|
||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||
},
|
||||
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
|
||||
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
||||
];
|
||||
|
||||
@@ -225,6 +228,7 @@ export function AdminPage() {
|
||||
{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" && (
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { Bestand, Lagerort, LagerortTyp, Lagerplatz, Material, Materialbewegung } from "../../api/types";
|
||||
|
||||
interface Props {
|
||||
materialien: Material[];
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
const TYP_LABEL: Record<LagerortTyp, string> = {
|
||||
organisation: "Organisation",
|
||||
standort: "Standort",
|
||||
gebaeude: "Gebäude",
|
||||
raum: "Raum",
|
||||
lager: "Lager",
|
||||
};
|
||||
|
||||
// WH-001..004+007 (Warehouse-Kern): Lagerort-Hierarchie, Lagerplätze, Bestand
|
||||
// je Lagerplatz, Ein-/Auslagerung. Umlagerung (WH-005) und Inventur (WH-006)
|
||||
// bewusst als Folgeschritt zurückgestellt (arbeitskacheln/07_warehouse.md).
|
||||
export function LagerSection({ materialien, onFehler }: Props) {
|
||||
const [lagerorte, setLagerorte] = useState<Lagerort[]>([]);
|
||||
const [lagerplaetze, setLagerplaetze] = useState<Lagerplatz[]>([]);
|
||||
|
||||
const [neuerOrtName, setNeuerOrtName] = useState("");
|
||||
const [neuerOrtTyp, setNeuerOrtTyp] = useState<LagerortTyp>("lager");
|
||||
const [neuerOrtParent, setNeuerOrtParent] = useState<number | "">("");
|
||||
|
||||
const [neuerPlatzOrt, setNeuerPlatzOrt] = useState<number | "">("");
|
||||
const [neuerPlatzCode, setNeuerPlatzCode] = useState("");
|
||||
const [neuerPlatzBezeichnung, setNeuerPlatzBezeichnung] = useState("");
|
||||
|
||||
const [ausgewaehlterPlatz, setAusgewaehlterPlatz] = useState<number | "">("");
|
||||
const [bestand, setBestand] = useState<Bestand[]>([]);
|
||||
const [bewegungen, setBewegungen] = useState<Materialbewegung[]>([]);
|
||||
const [buchungMaterial, setBuchungMaterial] = useState<number | "">("");
|
||||
const [buchungMenge, setBuchungMenge] = useState("");
|
||||
const [buchungGrund, setBuchungGrund] = useState("");
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [orte, plaetze] = await Promise.all([
|
||||
apiRequest<Lagerort[]>("/lagerorte"),
|
||||
apiRequest<Lagerplatz[]>("/lagerplaetze"),
|
||||
]);
|
||||
setLagerorte(orte);
|
||||
setLagerplaetze(plaetze);
|
||||
} catch {
|
||||
onFehler("Lagerorte/-plätze konnten nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function ladeBestandUndBewegungen(platzId: number) {
|
||||
try {
|
||||
const [b, bw] = await Promise.all([
|
||||
apiRequest<Bestand[]>(`/lagerplaetze/${platzId}/bestand`),
|
||||
apiRequest<Materialbewegung[]>(`/materialbewegungen?lagerplatz_id=${platzId}`),
|
||||
]);
|
||||
setBestand(b);
|
||||
setBewegungen(bw);
|
||||
} catch {
|
||||
onFehler("Bestand konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ausgewaehlterPlatz !== "") void ladeBestandUndBewegungen(ausgewaehlterPlatz);
|
||||
}, [ausgewaehlterPlatz]);
|
||||
|
||||
function ortName(id: number | null): string {
|
||||
if (id === null) return "–";
|
||||
const o = lagerorte.find((x) => x.id === id);
|
||||
return o ? o.name : `#${id}`;
|
||||
}
|
||||
|
||||
function materialName(id: number): string {
|
||||
const m = materialien.find((x) => x.id === id);
|
||||
return m ? m.name : `#${id}`;
|
||||
}
|
||||
|
||||
function platzCode(id: number | null): string {
|
||||
if (id === null) return "extern";
|
||||
const p = lagerplaetze.find((x) => x.id === id);
|
||||
return p ? p.code : `#${id}`;
|
||||
}
|
||||
|
||||
async function lagerortAnlegen() {
|
||||
if (!neuerOrtName) {
|
||||
onFehler("Name eingeben.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiRequest("/lagerorte", {
|
||||
method: "POST",
|
||||
body: { name: neuerOrtName, typ: neuerOrtTyp, parent_id: neuerOrtParent === "" ? null : neuerOrtParent },
|
||||
});
|
||||
setNeuerOrtName("");
|
||||
setNeuerOrtParent("");
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Lagerort konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function lagerplatzAnlegen() {
|
||||
if (neuerPlatzOrt === "" || !neuerPlatzCode) {
|
||||
onFehler("Lagerort und Code eingeben.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiRequest("/lagerplaetze", {
|
||||
method: "POST",
|
||||
body: { lagerort_id: neuerPlatzOrt, code: neuerPlatzCode, bezeichnung: neuerPlatzBezeichnung || null },
|
||||
});
|
||||
setNeuerPlatzCode("");
|
||||
setNeuerPlatzBezeichnung("");
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Lagerplatz konnte nicht angelegt werden (Code evtl. schon vergeben).");
|
||||
}
|
||||
}
|
||||
|
||||
async function buchen(richtung: "einlagern" | "auslagern") {
|
||||
if (ausgewaehlterPlatz === "" || buchungMaterial === "" || !buchungMenge) {
|
||||
onFehler("Material und Menge angeben.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiRequest(`/lagerplaetze/${ausgewaehlterPlatz}/${richtung}`, {
|
||||
method: "POST",
|
||||
body: { material_id: buchungMaterial, menge: buchungMenge, grund: buchungGrund || null },
|
||||
});
|
||||
setBuchungMenge("");
|
||||
setBuchungGrund("");
|
||||
await ladeBestandUndBewegungen(ausgewaehlterPlatz);
|
||||
} catch {
|
||||
onFehler(richtung === "auslagern" ? "Nicht genug Bestand am Lagerplatz." : "Buchung fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>Lager</h2>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Lagerort anlegen</h3>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Name"
|
||||
value={neuerOrtName}
|
||||
onChange={(e) => setNeuerOrtName(e.target.value)}
|
||||
/>
|
||||
<select className="input" value={neuerOrtTyp} onChange={(e) => setNeuerOrtTyp(e.target.value as LagerortTyp)}>
|
||||
{Object.entries(TYP_LABEL).map(([wert, label]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={neuerOrtParent}
|
||||
onChange={(e) => setNeuerOrtParent(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Kein übergeordneter Lagerort</option>
|
||||
{lagerorte.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name} ({TYP_LABEL[o.typ]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={lagerortAnlegen}>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
|
||||
{lagerorte.map((o) => (
|
||||
<li key={o.id}>
|
||||
{o.name} <span className="text-muted">({TYP_LABEL[o.typ]}{o.parent_id !== null ? `, unter ${ortName(o.parent_id)}` : ""})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: "1rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Lagerplatz anlegen</h3>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={neuerPlatzOrt}
|
||||
onChange={(e) => setNeuerPlatzOrt(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Lagerort wählen…</option>
|
||||
{lagerorte.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Code (z. B. REGAL-A1)"
|
||||
value={neuerPlatzCode}
|
||||
onChange={(e) => setNeuerPlatzCode(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Bezeichnung (optional)"
|
||||
value={neuerPlatzBezeichnung}
|
||||
onChange={(e) => setNeuerPlatzBezeichnung(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={lagerplatzAnlegen}>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
|
||||
{lagerplaetze.map((p) => (
|
||||
<li key={p.id}>
|
||||
{p.code} <span className="text-muted">({ortName(p.lagerort_id)}{p.bezeichnung ? `, ${p.bezeichnung}` : ""})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: "1rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Bestand & Buchungen</h3>
|
||||
<select
|
||||
className="input"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
value={ausgewaehlterPlatz}
|
||||
onChange={(e) => setAusgewaehlterPlatz(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Lagerplatz wählen…</option>
|
||||
{lagerplaetze.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{ausgewaehlterPlatz !== "" && (
|
||||
<>
|
||||
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.75rem" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={buchungMaterial}
|
||||
onChange={(e) => setBuchungMaterial(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Material wählen…</option>
|
||||
{materialien.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="Menge"
|
||||
style={{ width: "6rem" }}
|
||||
value={buchungMenge}
|
||||
onChange={(e) => setBuchungMenge(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Grund (optional)"
|
||||
value={buchungGrund}
|
||||
onChange={(e) => setBuchungGrund(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={() => buchen("einlagern")}>
|
||||
Einlagern
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => buchen("auslagern")}>
|
||||
Auslagern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4>Bestand</h4>
|
||||
{bestand.length === 0 && <p className="text-muted">Kein Bestand an diesem Lagerplatz.</p>}
|
||||
<ul>
|
||||
{bestand.map((b) => (
|
||||
<li key={b.id}>
|
||||
{materialName(b.material_id)}: {b.menge}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4>Bewegungen</h4>
|
||||
{bewegungen.length === 0 && <p className="text-muted">Noch keine Bewegungen.</p>}
|
||||
<ul>
|
||||
{bewegungen.map((bw) => (
|
||||
<li key={bw.id}>
|
||||
{new Date(bw.zeitpunkt).toLocaleString("de-DE")} — {materialName(bw.material_id)}: {bw.menge}{" "}
|
||||
({platzCode(bw.von_lagerplatz_id)} → {platzCode(bw.nach_lagerplatz_id)})
|
||||
{bw.grund && <span className="text-muted"> · {bw.grund}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user