feat(mangel): Mangel-Ticket-System (Roadmap Phase 2)
CI / backend-tests (push) Failing after 1m37s
CI / frontend-build (push) Successful in 18s

Eigener Workflow für Schäden/Defekte, getrennt von Fehlbestand (Mengen-
Abweichung Soll/Ist). Status: neu → in_bearbeitung → ersatzteil_bestellt/
reparatur_geplant → erledigt. entitaet_typ/entitaet_id optional (Objektposition
oder Geräteinstanz), analog zum bestehenden historie.entitaet_typ-Muster,
objekt_id immer gesetzt für Dashboard-Abfragen.

Ein offener Mangel mit prioritaet=hoch macht das Objekt "nicht einsatzbereit"
(neuer Grund mangel_kritisch_offen in dashboard.einsatzbereitschaft) -
niedrige/normale Mängel blockieren nicht.

Melden/Lesen: alle Mitarbeiter+. Status ändern: Materialverantwortliche+.
Neue Seite /maengel für alle eingeloggten Nutzer (Nav-Link "Mängel").

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:23:38 +02:00
co-authored by Claude Sonnet 5
parent d71d95e17f
commit eb20affd5d
14 changed files with 630 additions and 0 deletions
+9
View File
@@ -8,6 +8,7 @@ import { DashboardPage } from "./pages/DashboardPage";
import { FehlbestandListePage } from "./pages/FehlbestandListePage";
import { KontrollPage } from "./pages/KontrollPage";
import { LoginPage } from "./pages/LoginPage";
import { MangelListePage } from "./pages/MangelListePage";
import { ObjektListPage } from "./pages/ObjektListPage";
function GeschuetzteRoute({ children }: { children: ReactNode }) {
@@ -61,6 +62,14 @@ export default function App() {
</AdminRoute>
}
/>
<Route
path="/maengel"
element={
<GeschuetzteRoute>
<MangelListePage />
</GeschuetzteRoute>
}
/>
<Route
path="/fehlbestaende"
element={
+17
View File
@@ -45,6 +45,23 @@ export interface Kontrolle {
abbruch_grund: string | null;
}
export type MangelPrioritaet = "niedrig" | "normal" | "hoch";
export type MangelStatus = "neu" | "in_bearbeitung" | "ersatzteil_bestellt" | "reparatur_geplant" | "erledigt";
export interface Mangel {
id: string;
objekt_id: number;
entitaet_typ: string | null;
entitaet_id: string | null;
beschreibung: string;
prioritaet: MangelPrioritaet;
status: MangelStatus;
gemeldet_von: number;
gemeldet_am: string;
erledigt_von: number | null;
erledigt_am: string | null;
}
export interface Fehlbestand {
id: string;
objekt_id: number;
+3
View File
@@ -23,6 +23,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavLink to="/objekte" className={({ isActive }) => (isActive ? "active" : "")}>
Objekte
</NavLink>
<NavLink to="/maengel" className={({ isActive }) => (isActive ? "active" : "")}>
Mängel
</NavLink>
{istVerantwortlich && (
<NavLink to="/dashboard" className={({ isActive }) => (isActive ? "active" : "")}>
Dashboard
+1
View File
@@ -41,6 +41,7 @@ const GRUND_TEXT: Record<string, string> = {
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
pruefung_ueberfaellig: "Geräteprüfung überfällig",
kontrolle_laeuft: "Kontrolle läuft (gesperrt)",
mangel_kritisch_offen: "Kritischer Mangel offen",
mindermenge_genehmigt: "Mindermenge genehmigt",
};
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Mangel, MangelPrioritaet, MangelStatus, Objekt } from "../api/types";
const STATUS_LABEL: Record<MangelStatus, string> = {
neu: "Neu",
in_bearbeitung: "In Bearbeitung",
ersatzteil_bestellt: "Ersatzteil bestellt",
reparatur_geplant: "Reparatur geplant",
erledigt: "Erledigt",
};
const STATUS_OPTIONEN: MangelStatus[] = [
"neu",
"in_bearbeitung",
"ersatzteil_bestellt",
"reparatur_geplant",
"erledigt",
];
// Mangel-Ticket-System (Roadmap Phase 2): Schäden/Defekte melden und bis zur
// Erledigung verfolgen - jeder Mitarbeiter darf melden/lesen, Status ändern
// bleibt Materialverantwortlichen+ vorbehalten (Backend erzwingt das).
export function MangelListePage() {
const { istMaterialverantwortlich, istAdmin } = useAuth();
const darfStatusAendern = istMaterialverantwortlich || istAdmin;
const [maengel, setMaengel] = useState<Mangel[]>([]);
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [nurOffene, setNurOffene] = useState(true);
const [objektId, setObjektId] = useState<number | "">("");
const [beschreibung, setBeschreibung] = useState("");
const [prioritaet, setPrioritaet] = useState<MangelPrioritaet>("normal");
const [wirdGemeldet, setWirdGemeldet] = useState(false);
async function laden() {
setLaedt(true);
try {
const [m, o] = await Promise.all([
apiRequest<Mangel[]>("/maengel"),
apiRequest<Objekt[]>("/objekte"),
]);
setMaengel(m);
setObjekte(o);
} catch {
setFehler("Mängel konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
}, []);
function objektName(id: number): string {
const o = objekte.find((x) => x.id === id);
return o ? `${o.name} (${o.code})` : `#${id}`;
}
async function melden() {
if (objektId === "" || !beschreibung) {
setFehler("Objekt und Beschreibung angeben.");
return;
}
setWirdGemeldet(true);
try {
await apiRequest("/maengel", {
method: "POST",
body: { objekt_id: objektId, beschreibung, prioritaet },
});
setBeschreibung("");
setPrioritaet("normal");
await laden();
} catch {
setFehler("Mangel konnte nicht gemeldet werden.");
} finally {
setWirdGemeldet(false);
}
}
async function statusAendern(mangel: Mangel, status: MangelStatus) {
try {
await apiRequest(`/maengel/${mangel.id}`, { method: "PATCH", body: { status } });
await laden();
} catch {
setFehler("Status konnte nicht geändert werden.");
}
}
const angezeigt = nurOffene ? maengel.filter((m) => m.status !== "erledigt") : maengel;
return (
<main className="page">
<h1>Mängel</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
<div className="card" style={{ marginBottom: "1.25rem" }}>
<h3 style={{ marginTop: 0 }}>Mangel melden</h3>
<div className="stack">
<select className="input" value={objektId} onChange={(e) => setObjektId(Number(e.target.value) || "")}>
<option value="">Objekt wählen</option>
{objekte.map((o) => (
<option key={o.id} value={o.id}>
{o.name} ({o.code})
</option>
))}
</select>
<input
className="input"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
placeholder="Beschreibung (z.B. Blaulicht vorne rechts defekt)"
/>
<select className="input" value={prioritaet} onChange={(e) => setPrioritaet(e.target.value as MangelPrioritaet)}>
<option value="niedrig">Niedrig</option>
<option value="normal">Normal</option>
<option value="hoch">Hoch (blockiert Einsatzbereitschaft)</option>
</select>
<button className="btn btn-primary" onClick={melden} disabled={wirdGemeldet}>
Melden
</button>
</div>
</div>
<label className="row" style={{ alignItems: "center", gap: "0.4rem", marginBottom: "1rem" }}>
<input type="checkbox" checked={nurOffene} onChange={(e) => setNurOffene(e.target.checked)} />
Nur offene Mängel anzeigen
</label>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && angezeigt.length === 0 && <p className="text-muted">Keine Mängel.</p>}
<ul className="card-list">
{angezeigt.map((m) => (
<li key={m.id} className="card" style={{ marginBottom: "0.75rem" }}>
<div className="row-between">
<span>
<strong>{objektName(m.objekt_id)}</strong>
{m.prioritaet === "hoch" && <span style={{ color: "#b00020" }}> hoch</span>}
</span>
<span className="badge badge-neutral">{STATUS_LABEL[m.status]}</span>
</div>
<div style={{ margin: "0.4rem 0" }}>{m.beschreibung}</div>
{darfStatusAendern && m.status !== "erledigt" && (
<select
className="input"
style={{ maxWidth: "16rem" }}
value={m.status}
onChange={(e) => statusAendern(m, e.target.value as MangelStatus)}
>
{STATUS_OPTIONEN.map((s) => (
<option key={s} value={s}>
{STATUS_LABEL[s]}
</option>
))}
</select>
)}
</li>
))}
</ul>
</main>
);
}