ObjektPositionenPanel: Fach-Sidebar (Vorlagen-Feature "feste Fächer-Liste") links, damit die Bestückung übersichtlich bleibt und auch Dritte (Vertretung ohne die Kontrollperson) sehen, wo welches Material im Rucksack hingehört. Gruppierungslogik aus KontrollPage in gemeinsames Util (utils/fachGruppierung) ausgelagert, da jetzt zweimal gebraucht. .page-wide: margin:0 statt zentriert (Nutzer-Vorgabe: bei 1200px sonst viel verschenkter Platz links/rechts auf breiten Monitoren). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import { apiRequest } from "../../api/client";
|
|
import type { Beladungsvorlage, Material, Objektposition } from "../../api/types";
|
|
import { gruppiereNachFach, OHNE_FACH } from "../../utils/fachGruppierung";
|
|
import { GeraeteInstanzenListe } from "./GeraeteInstanzenListe";
|
|
|
|
interface Props {
|
|
objektId: number;
|
|
vorlageId: number | null;
|
|
materialien: Material[];
|
|
onFehler: (text: string) => void;
|
|
}
|
|
|
|
interface ZeilenFormular {
|
|
sollmengeOverride: string;
|
|
ablaufdatum: string;
|
|
chargennummer: string;
|
|
pruefintervallMonate: string;
|
|
}
|
|
|
|
function leeresFormular(p: Objektposition): ZeilenFormular {
|
|
return {
|
|
sollmengeOverride: p.sollmenge_override ?? "",
|
|
ablaufdatum: p.ablaufdatum ?? "",
|
|
chargennummer: p.chargennummer ?? "",
|
|
pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bestückung eines Objekts pflegen (Prompt 10): Sollmenge-Override je Position
|
|
* (Abweichung vom Vorlagen-Soll), Position entfernen/reaktivieren, zusätzliches
|
|
* Material hinzufügen das nicht in der Vorlage steht - plus Ablaufdatum/Charge/
|
|
* Prüfintervall/Geräte-Instanzen für die entsprechenden Materialtypen. Backend-
|
|
* Endpunkte existierten schon (PATCH/POST .../positionen), waren im Frontend
|
|
* bisher nur für ablauf_charge/geraet_sn und nicht für Sollmenge/Status/Neuanlage
|
|
* angebunden.
|
|
*
|
|
* Nutzer-Vorgabe: Fach-Sidebar links, damit die Bestückung übersichtlich bleibt
|
|
* UND damit auch Dritte (Vertretung, wenn die Kontrollperson nicht da ist)
|
|
* auf einen Blick sehen, wo welches Material im Rucksack hingehört - gleiche
|
|
* Fach-Zuordnung wie in der Kontroll-Erfassung (Vorlagenposition.fach).
|
|
*/
|
|
export function ObjektPositionenPanel({ objektId, vorlageId, materialien, onFehler }: Props) {
|
|
const [positionen, setPositionen] = useState<Objektposition[]>([]);
|
|
const [vorlage, setVorlage] = useState<Beladungsvorlage | null>(null);
|
|
const [formulare, setFormulare] = useState<Record<string, ZeilenFormular>>({});
|
|
const [speichertId, setSpeichertId] = useState<string | null>(null);
|
|
const [neuesMaterialId, setNeuesMaterialId] = useState<number | "">("");
|
|
const [neueSollmenge, setNeueSollmenge] = useState("");
|
|
const [wirdHinzugefuegt, setWirdHinzugefuegt] = useState(false);
|
|
const [aktivesFach, setAktivesFach] = useState<string | null>(null);
|
|
|
|
async function laden() {
|
|
try {
|
|
const [liste, vorlageDaten] = await Promise.all([
|
|
apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`),
|
|
vorlageId ? apiRequest<Beladungsvorlage>(`/vorlagen/${vorlageId}`) : Promise.resolve(null),
|
|
]);
|
|
setPositionen(liste);
|
|
setVorlage(vorlageDaten);
|
|
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
|
|
} catch {
|
|
onFehler("Positionen konnten nicht geladen werden.");
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
laden();
|
|
setAktivesFach(null);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [objektId, vorlageId]);
|
|
|
|
const fachByMaterialId = useMemo(() => {
|
|
const map = new Map<number, string | null>();
|
|
for (const vp of vorlage?.positionen ?? []) {
|
|
map.set(vp.material_id, vp.fach);
|
|
}
|
|
return map;
|
|
}, [vorlage]);
|
|
|
|
const fachGruppen = useMemo(
|
|
() => gruppiereNachFach(positionen, (p) => fachByMaterialId.get(p.material_id) ?? null),
|
|
[positionen, fachByMaterialId]
|
|
);
|
|
const sichtbarePositionen = aktivesFach
|
|
? fachGruppen.find(([fach]) => fach === aktivesFach)?.[1] ?? []
|
|
: positionen;
|
|
|
|
function materialName(materialId: number) {
|
|
return materialien.find((m) => m.id === materialId)?.name ?? `Material ${materialId}`;
|
|
}
|
|
|
|
function materialtyp(materialId: number) {
|
|
return materialien.find((m) => m.id === materialId)?.materialtyp;
|
|
}
|
|
|
|
async function speichern(position: Objektposition) {
|
|
const formular = formulare[position.id];
|
|
setSpeichertId(position.id);
|
|
try {
|
|
const aktualisiert = await apiRequest<Objektposition>(`/objekte/${objektId}/positionen/${position.id}`, {
|
|
method: "PATCH",
|
|
body: {
|
|
sollmenge_override: formular.sollmengeOverride === "" ? null : Number(formular.sollmengeOverride),
|
|
ablaufdatum: formular.ablaufdatum || null,
|
|
chargennummer: formular.chargennummer || null,
|
|
pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null,
|
|
},
|
|
});
|
|
setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p)));
|
|
} catch {
|
|
onFehler("Position konnte nicht gespeichert werden.");
|
|
} finally {
|
|
setSpeichertId(null);
|
|
}
|
|
}
|
|
|
|
async function statusUmschalten(position: Objektposition) {
|
|
const neuerStatus = position.ist_status === "aktiv" ? "entfernt" : "aktiv";
|
|
try {
|
|
const aktualisiert = await apiRequest<Objektposition>(`/objekte/${objektId}/positionen/${position.id}`, {
|
|
method: "PATCH",
|
|
body: { ist_status: neuerStatus },
|
|
});
|
|
setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p)));
|
|
} catch {
|
|
onFehler("Status konnte nicht geändert werden.");
|
|
}
|
|
}
|
|
|
|
async function materialHinzufuegen() {
|
|
if (neuesMaterialId === "" || !neueSollmenge) return;
|
|
setWirdHinzugefuegt(true);
|
|
try {
|
|
await apiRequest(`/objekte/${objektId}/positionen`, {
|
|
method: "POST",
|
|
body: { material_id: neuesMaterialId, sollmenge_override: Number(neueSollmenge) },
|
|
});
|
|
setNeuesMaterialId("");
|
|
setNeueSollmenge("");
|
|
await laden();
|
|
} catch {
|
|
onFehler("Material konnte nicht hinzugefügt werden (evtl. schon vorhanden).");
|
|
} finally {
|
|
setWirdHinzugefuegt(false);
|
|
}
|
|
}
|
|
|
|
const vorhandeneMaterialIds = new Set(positionen.map((p) => p.material_id));
|
|
const verfuegbareMaterialien = materialien.filter((m) => !vorhandeneMaterialIds.has(m.id));
|
|
|
|
return (
|
|
<div>
|
|
<div className="row" style={{ flexWrap: "wrap", marginBottom: "1rem" }}>
|
|
<select
|
|
className="input"
|
|
value={neuesMaterialId}
|
|
onChange={(e) => setNeuesMaterialId(Number(e.target.value) || "")}
|
|
>
|
|
<option value="">Material hinzufügen…</option>
|
|
{verfuegbareMaterialien.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="input"
|
|
style={{ maxWidth: "8rem" }}
|
|
placeholder="Sollmenge"
|
|
value={neueSollmenge}
|
|
onChange={(e) => setNeueSollmenge(e.target.value)}
|
|
/>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={materialHinzufuegen}
|
|
disabled={neuesMaterialId === "" || !neueSollmenge || wirdHinzugefuegt}
|
|
>
|
|
Hinzufügen
|
|
</button>
|
|
</div>
|
|
|
|
{positionen.length === 0 && <p className="text-muted">Keine Positionen in diesem Objekt.</p>}
|
|
|
|
{positionen.length > 0 && (
|
|
<div className="row" style={{ alignItems: "flex-start", gap: "1.25rem" }}>
|
|
<nav
|
|
className="card"
|
|
style={{ minWidth: "11rem", flexShrink: 0, padding: "0.5rem", position: "sticky", top: "0.5rem" }}
|
|
>
|
|
<button
|
|
className={aktivesFach === null ? "btn btn-primary btn-block" : "btn btn-secondary btn-block"}
|
|
style={{ marginBottom: "0.3rem" }}
|
|
onClick={() => setAktivesFach(null)}
|
|
>
|
|
Alle ({positionen.length})
|
|
</button>
|
|
{fachGruppen.map(([fach, gruppenPositionen]) => (
|
|
<button
|
|
key={fach}
|
|
className={aktivesFach === fach ? "btn btn-primary btn-block" : "btn btn-secondary btn-block"}
|
|
style={{ marginBottom: "0.3rem" }}
|
|
onClick={() => setAktivesFach(fach)}
|
|
>
|
|
{fach} ({gruppenPositionen.length})
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<ul className="card-list">
|
|
{sichtbarePositionen.map((p) => {
|
|
const typ = materialtyp(p.material_id);
|
|
const formular = formulare[p.id] ?? leeresFormular(p);
|
|
const entfernt = p.ist_status === "entfernt";
|
|
return (
|
|
<li key={p.id} className="card" style={entfernt ? { opacity: 0.6 } : undefined}>
|
|
<div className="card-title-row">
|
|
<strong>{materialName(p.material_id)}</strong>
|
|
<span className="row" style={{ gap: "0.4rem" }}>
|
|
<span className="badge badge-neutral">{fachByMaterialId.get(p.material_id) ?? OHNE_FACH}</span>
|
|
<span className="badge badge-neutral">{typ}</span>
|
|
{entfernt && <span className="badge badge-danger">entfernt</span>}
|
|
</span>
|
|
</div>
|
|
<div className="card-subtitle">Soll (Vorlage/aktuell wirksam): {p.sollmenge_effektiv}</div>
|
|
<div className="row" style={{ marginTop: "0.6rem", flexWrap: "wrap" }}>
|
|
<div className="field" style={{ maxWidth: "10rem" }}>
|
|
<label>Sollmenge-Override</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="input"
|
|
placeholder="wie Vorlage"
|
|
value={formular.sollmengeOverride}
|
|
onChange={(e) =>
|
|
setFormulare((f) => ({ ...f, [p.id]: { ...formular, sollmengeOverride: e.target.value } }))
|
|
}
|
|
/>
|
|
</div>
|
|
{typ === "ablauf_charge" && (
|
|
<>
|
|
<input
|
|
type="date"
|
|
className="input"
|
|
value={formular.ablaufdatum}
|
|
onChange={(e) =>
|
|
setFormulare((f) => ({ ...f, [p.id]: { ...formular, ablaufdatum: e.target.value } }))
|
|
}
|
|
/>
|
|
<input
|
|
className="input"
|
|
placeholder="Chargennummer"
|
|
value={formular.chargennummer}
|
|
onChange={(e) =>
|
|
setFormulare((f) => ({ ...f, [p.id]: { ...formular, chargennummer: e.target.value } }))
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
{typ === "geraet_sn" && (
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="input"
|
|
placeholder="Prüfintervall (Monate)"
|
|
value={formular.pruefintervallMonate}
|
|
onChange={(e) =>
|
|
setFormulare((f) => ({
|
|
...f,
|
|
[p.id]: { ...formular, pruefintervallMonate: e.target.value },
|
|
}))
|
|
}
|
|
/>
|
|
)}
|
|
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
|
|
Speichern
|
|
</button>
|
|
<button className="btn btn-secondary" onClick={() => statusUmschalten(p)}>
|
|
{entfernt ? "Reaktivieren" : "Entfernen"}
|
|
</button>
|
|
</div>
|
|
{typ === "geraet_sn" && (
|
|
<GeraeteInstanzenListe
|
|
objektpositionId={p.id}
|
|
pruefintervallMonate={p.pruefintervall_monate}
|
|
onFehler={onFehler}
|
|
/>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|