feat(inventory): INV-007 Ausgabe/Rückgabe von Material und Leihgeräten
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:
@@ -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",
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user