feat(lager): Lagerbewegung-Protokoll (Roadmap Phase 4, Modul Lager)
CI / backend-tests (push) Failing after 1m47s
CI / frontend-build (push) Successful in 17s

Objekt bleibt der Materialträger (Rucksack/Gerät/Fahrzeug, Karte 11) -
"Material verschieben" heißt: das Objekt bekommt einen neuen Standort, jede
Bewegung wird append-only protokolliert (Wer/Wann/Von/Nach/Grund, Nutzer-
Vorgabe Modul 5).

POST /objekte/{id}/lagerbewegungen setzt Standort + protokolliert in einem
Zug, GET /lagerbewegungen (Filter objekt_id/standort_id) für die Historie.
Rollen wie beim Mangel-Modul: Materialverantwortliche+Leitung+Admin dürfen
verschieben, alle Eingeloggten dürfen lesen.

Frontend: "Verschieben"-Aktion in ObjektSection, neuer Admin-Tab
"Lagerbewegungen" (reine Anzeige, analog HistorieSection).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 13:46:33 +02:00
co-authored by Claude Sonnet 5
parent 8fbb356c34
commit 417df2beb6
12 changed files with 467 additions and 1 deletions
+10
View File
@@ -73,6 +73,16 @@ export interface Mangel {
erledigt_am: string | null;
}
export interface Lagerbewegung {
id: string;
objekt_id: number;
von_standort_id: number | null;
nach_standort_id: number;
benutzer_id: number;
zeitpunkt: string;
grund: string | null;
}
export interface Fehlbestand {
id: string;
objekt_id: number;
+11 -1
View File
@@ -10,6 +10,7 @@ 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 { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
import { MaterialSection } from "./admin/MaterialSection";
import { ObjektSection } from "./admin/ObjektSection";
@@ -30,7 +31,8 @@ type Tab =
| "eskalation"
| "kontrollverantwortung"
| "zustaendigkeit"
| "personal";
| "personal"
| "lagerbewegungen";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
@@ -56,6 +58,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
},
{ key: "eskalation", label: "Eskalation", rollen: ["administration"] },
{ key: "personal", label: "Personal", rollen: ["administration"] },
{
key: "lagerbewegungen",
label: "Lagerbewegungen",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
];
/**
@@ -212,6 +219,9 @@ export function AdminPage() {
<KontrollverantwortungSection objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
)}
{aktiverTab === "historie" && <HistorieSection onFehler={setFehler} />}
{aktiverTab === "lagerbewegungen" && (
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
)}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Lagerbewegung, Objekt, Standort } from "../../api/types";
interface Props {
objekte: Objekt[];
standorte: Standort[];
onFehler: (text: string) => void;
}
// Roadmap Phase 4 (Modul Lager): reine Anzeige des append-only Bewegungs-
// protokolls, analog HistorieSection. Verschieben selbst passiert direkt in
// ObjektSection (Aktion am Objekt, nicht hier).
export function LagerbewegungSection({ objekte, standorte, onFehler }: Props) {
const [bewegungen, setBewegungen] = useState<Lagerbewegung[]>([]);
const [standortFilter, setStandortFilter] = useState<number | "">("");
const [laedt, setLaedt] = useState(true);
function objektName(id: number): string {
const o = objekte.find((x) => x.id === id);
return o ? `${o.name} (${o.code})` : `#${id}`;
}
function standortName(id: number | null): string {
if (id === null) return "";
const s = standorte.find((x) => x.id === id);
return s ? s.name : `#${id}`;
}
async function laden() {
setLaedt(true);
try {
const query = standortFilter !== "" ? `?standort_id=${standortFilter}` : "";
const daten = await apiRequest<Lagerbewegung[]>(`/lagerbewegungen${query}`);
setBewegungen(daten);
} catch {
onFehler("Lagerbewegungen konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [standortFilter]);
return (
<section className="section">
<h2>Lagerbewegungen</h2>
<div className="card">
<div className="field" style={{ maxWidth: "20rem" }}>
<label>Standort</label>
<select
className="input"
value={standortFilter}
onChange={(e) => setStandortFilter(Number(e.target.value) || "")}
>
<option value="">Alle</option>
{standorte.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && bewegungen.length === 0 && <p className="text-muted">Keine Bewegungen.</p>}
{!laedt && bewegungen.length > 0 && (
<div style={{ overflowX: "auto" }}>
<table className="table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Objekt</th>
<th>Von</th>
<th>Nach</th>
<th>Grund</th>
</tr>
</thead>
<tbody>
{bewegungen.map((b) => (
<tr key={b.id}>
<td>{new Date(b.zeitpunkt).toLocaleString("de-DE")}</td>
<td>{objektName(b.objekt_id)}</td>
<td>{standortName(b.von_standort_id)}</td>
<td>{standortName(b.nach_standort_id)}</td>
<td>{b.grund ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}
@@ -54,6 +54,36 @@ export function ObjektSection({
const [suche, setSuche] = useState("");
const [detailsOffenId, setDetailsOffenId] = useState<number | null>(null);
const [details, setDetails] = useState<Partial<Fahrzeugdetails>>({});
const [verschiebenId, setVerschiebenId] = useState<number | null>(null);
const [zielStandortId, setZielStandortId] = useState<number | "">("");
const [verschiebenGrund, setVerschiebenGrund] = useState("");
function verschiebenOeffnen(o: Objekt) {
if (verschiebenId === o.id) {
setVerschiebenId(null);
return;
}
setVerschiebenId(o.id);
setZielStandortId("");
setVerschiebenGrund("");
}
async function verschieben(objektId: number) {
if (zielStandortId === "") {
onFehler("Zielstandort auswählen.");
return;
}
try {
await apiRequest(`/objekte/${objektId}/lagerbewegungen`, {
method: "POST",
body: { nach_standort_id: zielStandortId, grund: verschiebenGrund || null },
});
setVerschiebenId(null);
await onGeaendert();
} catch {
onFehler("Objekt konnte nicht verschoben werden.");
}
}
async function statusAendern(o: Objekt, status: string) {
try {
@@ -356,6 +386,11 @@ export function ObjektSection({
{detailsOffenId === o.id ? "Schließen" : "Fahrzeugdetails"}
</button>
)}
{!nurPflegen && (
<button className="btn btn-secondary" onClick={() => verschiebenOeffnen(o)}>
{verschiebenId === o.id ? "Schließen" : "Verschieben"}
</button>
)}
<button
className="btn btn-secondary"
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
@@ -431,6 +466,33 @@ export function ObjektSection({
</button>
</div>
)}
{verschiebenId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<select
className="input"
value={zielStandortId}
onChange={(e) => setZielStandortId(Number(e.target.value) || "")}
>
<option value="">Zielstandort wählen</option>
{standorte
.filter((s) => s.id !== o.standort_id)
.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<input
className="input"
value={verschiebenGrund}
onChange={(e) => setVerschiebenGrund(e.target.value)}
placeholder="Grund (optional)"
/>
<button className="btn btn-primary" onClick={() => verschieben(o.id)}>
Verschieben
</button>
</div>
)}
{fahrzeugBearbeitenId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<select