Bestückung eines Objekts vollständig pflegbar (Sollmenge/Status/Neuanlage)
ObjektPositionenPanel zeigte bisher nur ablauf_charge/geraet_sn-Positionen und konnte weder Sollmenge-Override noch Status noch neues Material ändern - Backend-Endpunkte (PATCH/POST .../positionen) existierten schon, waren im Frontend nicht angebunden. Jetzt: alle Positionen sichtbar, Sollmenge-Override je Position, Entfernen/ Reaktivieren (ist_status), zusätzliches Material hinzufügen (Dropdown mit noch nicht vorhandenen Materialien + Sollmenge). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -3493,3 +3493,16 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/admin/ObjektSection.tsx | 42 ++++++++++++++++++++++++++--------
|
- frontend/src/pages/admin/ObjektSection.tsx | 42 ++++++++++++++++++++++++++--------
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-09-04 22:31 – 22:31 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** frontend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- a2b201d Verwaltung: breiteres Layout für Admin/Material-/Leitungsverantwortliche
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/AdminPage.tsx | 4 ++--
|
||||||
|
- frontend/src/styles/global.css | 7 +++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ZeilenFormular {
|
interface ZeilenFormular {
|
||||||
|
sollmengeOverride: string;
|
||||||
ablaufdatum: string;
|
ablaufdatum: string;
|
||||||
chargennummer: string;
|
chargennummer: string;
|
||||||
pruefintervallMonate: string;
|
pruefintervallMonate: string;
|
||||||
@@ -18,6 +19,7 @@ interface ZeilenFormular {
|
|||||||
|
|
||||||
function leeresFormular(p: Objektposition): ZeilenFormular {
|
function leeresFormular(p: Objektposition): ZeilenFormular {
|
||||||
return {
|
return {
|
||||||
|
sollmengeOverride: p.sollmenge_override ?? "",
|
||||||
ablaufdatum: p.ablaufdatum ?? "",
|
ablaufdatum: p.ablaufdatum ?? "",
|
||||||
chargennummer: p.chargennummer ?? "",
|
chargennummer: p.chargennummer ?? "",
|
||||||
pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "",
|
pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "",
|
||||||
@@ -25,24 +27,34 @@ function leeresFormular(p: Objektposition): ZeilenFormular {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt 10: Ablaufdatum/Chargennummer (materialtyp 'ablauf_charge') und
|
* Bestückung eines Objekts pflegen (Prompt 10): Sollmenge-Override je Position
|
||||||
* Seriennummer (materialtyp 'geraet_sn') gehören zur Objektposition, nicht
|
* (Abweichung vom Vorlagen-Soll), Position entfernen/reaktivieren, zusätzliches
|
||||||
* zur Kontrolle selbst (Vier-Kernbegriffe: Kontrolle erfasst nur die Menge).
|
* Material hinzufügen das nicht in der Vorlage steht - plus Ablaufdatum/Charge/
|
||||||
* Backend-Endpoint (PATCH .../positionen/{id}) existierte schon, war im
|
* Prüfintervall/Geräte-Instanzen für die entsprechenden Materialtypen. Backend-
|
||||||
* Frontend bisher nirgends angebunden.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props) {
|
export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props) {
|
||||||
const [positionen, setPositionen] = useState<Objektposition[]>([]);
|
const [positionen, setPositionen] = useState<Objektposition[]>([]);
|
||||||
const [formulare, setFormulare] = useState<Record<string, ZeilenFormular>>({});
|
const [formulare, setFormulare] = useState<Record<string, ZeilenFormular>>({});
|
||||||
const [speichertId, setSpeichertId] = useState<string | null>(null);
|
const [speichertId, setSpeichertId] = useState<string | null>(null);
|
||||||
|
const [neuesMaterialId, setNeuesMaterialId] = useState<number | "">("");
|
||||||
|
const [neueSollmenge, setNeueSollmenge] = useState("");
|
||||||
|
const [wirdHinzugefuegt, setWirdHinzugefuegt] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
async function laden() {
|
||||||
apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`)
|
try {
|
||||||
.then((liste) => {
|
const liste = await apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`);
|
||||||
setPositionen(liste);
|
setPositionen(liste);
|
||||||
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
|
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
|
||||||
})
|
} catch {
|
||||||
.catch(() => onFehler("Positionen konnten nicht geladen werden."));
|
onFehler("Positionen konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
laden();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [objektId]);
|
}, [objektId]);
|
||||||
|
|
||||||
@@ -61,6 +73,7 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
|
|||||||
const aktualisiert = await apiRequest<Objektposition>(`/objekte/${objektId}/positionen/${position.id}`, {
|
const aktualisiert = await apiRequest<Objektposition>(`/objekte/${objektId}/positionen/${position.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: {
|
body: {
|
||||||
|
sollmenge_override: formular.sollmengeOverride === "" ? null : Number(formular.sollmengeOverride),
|
||||||
ablaufdatum: formular.ablaufdatum || null,
|
ablaufdatum: formular.ablaufdatum || null,
|
||||||
chargennummer: formular.chargennummer || null,
|
chargennummer: formular.chargennummer || null,
|
||||||
pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null,
|
pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null,
|
||||||
@@ -74,24 +87,104 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const relevante = positionen.filter((p) => materialtyp(p.material_id) !== "standard");
|
async function statusUmschalten(position: Objektposition) {
|
||||||
|
const neuerStatus = position.ist_status === "aktiv" ? "entfernt" : "aktiv";
|
||||||
if (relevante.length === 0) {
|
try {
|
||||||
return <p className="text-muted">Kein Material mit Ablaufdatum/Charge oder Seriennummer in diesem Objekt.</p>;
|
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 (
|
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>}
|
||||||
|
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{relevante.map((p) => {
|
{positionen.map((p) => {
|
||||||
const typ = materialtyp(p.material_id);
|
const typ = materialtyp(p.material_id);
|
||||||
const formular = formulare[p.id] ?? leeresFormular(p);
|
const formular = formulare[p.id] ?? leeresFormular(p);
|
||||||
|
const entfernt = p.ist_status === "entfernt";
|
||||||
return (
|
return (
|
||||||
<li key={p.id} className="card">
|
<li key={p.id} className="card" style={entfernt ? { opacity: 0.6 } : undefined}>
|
||||||
<div className="card-title-row">
|
<div className="card-title-row">
|
||||||
<strong>{materialName(p.material_id)}</strong>
|
<strong>{materialName(p.material_id)}</strong>
|
||||||
|
<span className="row" style={{ gap: "0.4rem" }}>
|
||||||
<span className="badge badge-neutral">{typ}</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>
|
</div>
|
||||||
<div className="row" style={{ marginTop: "0.6rem" }}>
|
|
||||||
{typ === "ablauf_charge" && (
|
{typ === "ablauf_charge" && (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -128,7 +221,10 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
|
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
|
||||||
{typ === "geraet_sn" ? "Prüfintervall speichern" : "Speichern"}
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => statusUmschalten(p)}>
|
||||||
|
{entfernt ? "Reaktivieren" : "Entfernen"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{typ === "geraet_sn" && (
|
{typ === "geraet_sn" && (
|
||||||
@@ -142,5 +238,6 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user