feat(inventory): INV-007 Ausgabe/Rückgabe von Material und Leihgeräten
CI / backend-tests (push) Successful in 2m1s
CI / frontend-build (push) Successful in 18s

Personenbezogene Ausgabe ergänzt die reine Standort-/Lagerplatz-Sicht
um "wer hat das Ding gerade": neues Modell ausgabe (Material +
optional geraet_instanz_id), POST /ausgaben, GET /ausgaben (Filter
status/empfaenger), POST /ausgaben/{id}/rueckgabe mit Schutz vor
Doppel-Rueckgabe. Frontend als Admin-Tab "Ausgabe/Rückgabe" +
Command-Palette-Eintrag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 13:19:31 +02:00
co-authored by Claude Sonnet 5
parent 16a42c6b85
commit b6ada2b5a1
13 changed files with 495 additions and 7 deletions
+13
View File
@@ -130,6 +130,19 @@ export interface Materialbewegung {
grund: string | null;
}
export interface Ausgabe {
id: string;
material_id: number;
geraet_instanz_id: string | null;
menge: string;
empfaenger_id: number;
ausgegeben_von: number;
ausgegeben_am: string;
rueckgabe_am: string | null;
status: "offen" | "zurueck";
zweck: string | null;
}
export interface Fehlbestand {
id: string;
objekt_id: number;
@@ -69,6 +69,7 @@ export function CommandPalette() {
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",
+10
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiRequest } from "../api/client";
import { AusgabeSection } from "./admin/AusgabeSection";
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";
@@ -37,6 +38,7 @@ type Tab =
| "personal"
| "lagerbewegungen"
| "lager"
| "ausgabe"
| "rollen";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
@@ -69,6 +71,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
{
key: "ausgabe",
label: "Ausgabe/Rückgabe",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
];
@@ -237,6 +244,9 @@ export function AdminPage() {
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
)}
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "ausgabe" && (
<AusgabeSection materialien={materialien} benutzer={benutzer} onFehler={setFehler} />
)}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Ausgabe, Benutzer, Material } from "../../api/types";
interface Props {
materialien: Material[];
benutzer: Benutzer[];
onFehler: (text: string) => void;
}
// INV-007: personenbezogene Ausgabe/Rückgabe - ergänzt die reine Standort-
// Sicht (Lagerbewegung/Materialbewegung) um "wer hat das Ding gerade".
export function AusgabeSection({ materialien, benutzer, onFehler }: Props) {
const [ausgaben, setAusgaben] = useState<Ausgabe[]>([]);
const [nurOffene, setNurOffene] = useState(true);
const [laedt, setLaedt] = useState(true);
const [neuMaterial, setNeuMaterial] = useState<number | "">("");
const [neuMenge, setNeuMenge] = useState("1");
const [neuEmpfaenger, setNeuEmpfaenger] = useState<number | "">("");
const [neuZweck, setNeuZweck] = useState("");
function materialName(id: number): string {
const m = materialien.find((x) => x.id === id);
return m ? m.name : `#${id}`;
}
function benutzerName(id: number): string {
const b = benutzer.find((x) => x.id === id);
return b ? b.name : `#${id}`;
}
async function laden() {
setLaedt(true);
try {
const query = nurOffene ? "?status_filter=offen" : "";
setAusgaben(await apiRequest<Ausgabe[]>(`/ausgaben${query}`));
} catch {
onFehler("Ausgaben konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nurOffene]);
async function ausgeben() {
if (neuMaterial === "" || neuEmpfaenger === "" || !neuMenge) {
onFehler("Material, Menge und Empfänger angeben.");
return;
}
try {
await apiRequest("/ausgaben", {
method: "POST",
body: {
material_id: neuMaterial,
menge: neuMenge,
empfaenger_id: neuEmpfaenger,
zweck: neuZweck || null,
},
});
setNeuMenge("1");
setNeuZweck("");
await laden();
} catch {
onFehler("Ausgabe fehlgeschlagen.");
}
}
async function rueckgabe(id: string) {
try {
await apiRequest(`/ausgaben/${id}/rueckgabe`, { method: "POST" });
await laden();
} catch {
onFehler("Rückgabe fehlgeschlagen.");
}
}
return (
<section className="section">
<h2>Ausgabe / Rückgabe</h2>
<div className="card">
<h3 style={{ marginTop: 0 }}>Neue Ausgabe</h3>
<div className="row" style={{ flexWrap: "wrap" }}>
<select className="input" value={neuMaterial} onChange={(e) => setNeuMaterial(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={neuMenge}
onChange={(e) => setNeuMenge(e.target.value)}
/>
<select className="input" value={neuEmpfaenger} onChange={(e) => setNeuEmpfaenger(Number(e.target.value) || "")}>
<option value="">Empfänger wählen</option>
{benutzer.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
<input
className="input"
placeholder="Zweck (optional)"
value={neuZweck}
onChange={(e) => setNeuZweck(e.target.value)}
/>
<button className="btn btn-primary" onClick={ausgeben}>
Ausgeben
</button>
</div>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="row-between" style={{ marginBottom: "0.75rem" }}>
<h3 style={{ margin: 0 }}>Ausgaben</h3>
<label className="row" style={{ gap: "0.4rem", fontSize: "0.9rem" }}>
<input type="checkbox" checked={nurOffene} onChange={(e) => setNurOffene(e.target.checked)} />
nur offene
</label>
</div>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && ausgaben.length === 0 && <p className="text-muted">Keine Ausgaben.</p>}
{!laedt && ausgaben.length > 0 && (
<div style={{ overflowX: "auto" }}>
<table className="table">
<thead>
<tr>
<th>Ausgegeben am</th>
<th>Material</th>
<th>Menge</th>
<th>Empfänger</th>
<th>Zweck</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{ausgaben.map((a) => (
<tr key={a.id}>
<td>{new Date(a.ausgegeben_am).toLocaleString("de-DE")}</td>
<td>{materialName(a.material_id)}</td>
<td>{a.menge}</td>
<td>{benutzerName(a.empfaenger_id)}</td>
<td>{a.zweck ?? ""}</td>
<td>
<span className={a.status === "offen" ? "badge badge-warning" : "badge badge-neutral"}>
{a.status === "offen" ? "offen" : "zurück"}
</span>
</td>
<td>
{a.status === "offen" && (
<button className="btn btn-secondary" onClick={() => rueckgabe(a.id)}>
Rückgabe
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}