Sprint 8: Ablaufdatum/Chargennummer/Seriennummer im Admin-Portal pflegbar
Nutzer-Fund: bei keinem Objekt war Chargennummer/Ablaufdatum sichtbar.
Backend-Endpoint (PATCH /objekte/{id}/positionen/{position_id}) existierte
bereits (Prompt 10), war im Frontend nirgends angebunden. Kontroll-Screen
selbst bleibt unverändert (Vier-Kernbegriffe: Kontrolle erfasst nur Menge,
Ablauf/Charge/SN gehören zur Objektposition) - neue Pflege-Ansicht im
Admin-Portal pro Objekt, nur für materialtyp ablauf_charge/geraet_sn.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
@@ -90,6 +90,7 @@ export function AdminPage() {
|
|||||||
objekte={objekte}
|
objekte={objekte}
|
||||||
vorlagen={vorlagen.filter((v) => v.status === "aktiv")}
|
vorlagen={vorlagen.filter((v) => v.status === "aktiv")}
|
||||||
standorte={standorte}
|
standorte={standorte}
|
||||||
|
materialien={materialien}
|
||||||
onGeaendert={ladeAlles}
|
onGeaendert={ladeAlles}
|
||||||
onFehler={setFehler}
|
onFehler={setFehler}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { apiRequest } from "../../api/client";
|
||||||
|
import type { Material, Objektposition } from "../../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objektId: number;
|
||||||
|
materialien: Material[];
|
||||||
|
onFehler: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZeilenFormular {
|
||||||
|
ablaufdatum: string;
|
||||||
|
chargennummer: string;
|
||||||
|
seriennummer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leeresFormular(p: Objektposition): ZeilenFormular {
|
||||||
|
return {
|
||||||
|
ablaufdatum: p.ablaufdatum ?? "",
|
||||||
|
chargennummer: p.chargennummer ?? "",
|
||||||
|
seriennummer: p.seriennummer ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt 10: Ablaufdatum/Chargennummer (materialtyp 'ablauf_charge') und
|
||||||
|
* Seriennummer (materialtyp 'geraet_sn') gehören zur Objektposition, nicht
|
||||||
|
* zur Kontrolle selbst (Vier-Kernbegriffe: Kontrolle erfasst nur die Menge).
|
||||||
|
* Backend-Endpoint (PATCH .../positionen/{id}) existierte schon, war im
|
||||||
|
* Frontend bisher nirgends angebunden.
|
||||||
|
*/
|
||||||
|
export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props) {
|
||||||
|
const [positionen, setPositionen] = useState<Objektposition[]>([]);
|
||||||
|
const [formulare, setFormulare] = useState<Record<string, ZeilenFormular>>({});
|
||||||
|
const [speichertId, setSpeichertId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`)
|
||||||
|
.then((liste) => {
|
||||||
|
setPositionen(liste);
|
||||||
|
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
|
||||||
|
})
|
||||||
|
.catch(() => onFehler("Positionen konnten nicht geladen werden."));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [objektId]);
|
||||||
|
|
||||||
|
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: {
|
||||||
|
ablaufdatum: formular.ablaufdatum || null,
|
||||||
|
chargennummer: formular.chargennummer || null,
|
||||||
|
seriennummer: formular.seriennummer || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p)));
|
||||||
|
} catch {
|
||||||
|
onFehler("Position konnte nicht gespeichert werden.");
|
||||||
|
} finally {
|
||||||
|
setSpeichertId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevante = positionen.filter((p) => materialtyp(p.material_id) !== "standard");
|
||||||
|
|
||||||
|
if (relevante.length === 0) {
|
||||||
|
return <p className="text-muted">Kein Material mit Ablaufdatum/Charge oder Seriennummer in diesem Objekt.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="card-list">
|
||||||
|
{relevante.map((p) => {
|
||||||
|
const typ = materialtyp(p.material_id);
|
||||||
|
const formular = formulare[p.id] ?? leeresFormular(p);
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="card">
|
||||||
|
<div className="card-title-row">
|
||||||
|
<strong>{materialName(p.material_id)}</strong>
|
||||||
|
<span className="badge badge-neutral">{typ}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ marginTop: "0.6rem" }}>
|
||||||
|
{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
|
||||||
|
className="input"
|
||||||
|
placeholder="Seriennummer"
|
||||||
|
value={formular.seriennummer}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormulare((f) => ({ ...f, [p.id]: { ...formular, seriennummer: e.target.value } }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { apiRequest } from "../../api/client";
|
import { apiRequest } from "../../api/client";
|
||||||
import type { Beladungsvorlage, Objekt, Standort } from "../../api/types";
|
import type { Beladungsvorlage, Material, Objekt, Standort } from "../../api/types";
|
||||||
|
import { ObjektPositionenPanel } from "./ObjektPositionenPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
objekte: Objekt[];
|
objekte: Objekt[];
|
||||||
vorlagen: Beladungsvorlage[];
|
vorlagen: Beladungsvorlage[];
|
||||||
standorte: Standort[];
|
standorte: Standort[];
|
||||||
|
materialien: Material[];
|
||||||
onGeaendert: () => Promise<void>;
|
onGeaendert: () => Promise<void>;
|
||||||
onFehler: (text: string) => void;
|
onFehler: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObjektSection({ objekte, vorlagen, standorte, onGeaendert, onFehler }: Props) {
|
export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGeaendert, onFehler }: Props) {
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [vorlageId, setVorlageId] = useState<number | "">("");
|
const [vorlageId, setVorlageId] = useState<number | "">("");
|
||||||
const [standortId, setStandortId] = useState<number | "">("");
|
const [standortId, setStandortId] = useState<number | "">("");
|
||||||
const [wirdAngelegt, setWirdAngelegt] = useState(false);
|
const [wirdAngelegt, setWirdAngelegt] = useState(false);
|
||||||
|
const [ausgeklapptId, setAusgeklapptId] = useState<number | null>(null);
|
||||||
|
|
||||||
async function anlegen() {
|
async function anlegen() {
|
||||||
if (vorlageId === "" || standortId === "") {
|
if (vorlageId === "" || standortId === "") {
|
||||||
@@ -99,9 +102,23 @@ export function ObjektSection({ objekte, vorlagen, standorte, onGeaendert, onFeh
|
|||||||
{objekte.length > 0 && (
|
{objekte.length > 0 && (
|
||||||
<ul className="card-list" style={{ marginTop: "1.25rem" }}>
|
<ul className="card-list" style={{ marginTop: "1.25rem" }}>
|
||||||
{objekte.map((o) => (
|
{objekte.map((o) => (
|
||||||
<li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}>
|
<li key={o.id}>
|
||||||
<span>{o.name}</span>
|
<div className="row-between" style={{ padding: "0.4rem 0" }}>
|
||||||
<span className="text-muted">{o.code}</span>
|
<span>
|
||||||
|
{o.name} <span className="text-muted">({o.code})</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
|
||||||
|
>
|
||||||
|
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{ausgeklapptId === o.id && (
|
||||||
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
|
<ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user