feat(material): Material-Dubletten zusammenführen
CI / backend-tests (push) Failing after 2m6s
CI / frontend-build (push) Successful in 17s

Verwendung je Material einsehbar (Objektposition/Vorlagenposition/
Bestand/Kontrollposition/Fehlbestand/Ausgabe/Nachfüllung/
Materialbewegung), POST /materialien/{id}/ersetzen hängt alle
Referenzen auf ein Ziel-Material um und löscht die Quelle. Bricht bei
Konflikten ab (z.B. dasselbe Objekt hätte danach zwei Positionen für
dasselbe Material) statt Daten stillschweigend zu verlieren. Admin-UI:
"Zusammenführen" je Material zeigt Verwendung + Ziel-Auswahl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-07 00:23:35 +02:00
co-authored by Claude Sonnet 5
parent 5f568c365c
commit 06baf03708
5 changed files with 306 additions and 13 deletions
+106 -13
View File
@@ -28,6 +28,48 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
const [wirdGespeichert, setWirdGespeichert] = useState(false);
const [suche, setSuche] = useState("");
const [mergeQuelleId, setMergeQuelleId] = useState<number | null>(null);
const [mergeVerwendung, setMergeVerwendung] = useState<Record<string, number> | null>(null);
const [mergeZielId, setMergeZielId] = useState<number | "">("");
const [mergeKonflikte, setMergeKonflikte] = useState<string[]>([]);
async function zusammenfuehrenOeffnen(material: Material) {
if (mergeQuelleId === material.id) {
setMergeQuelleId(null);
return;
}
setMergeQuelleId(material.id);
setMergeZielId("");
setMergeKonflikte([]);
setMergeVerwendung(null);
try {
setMergeVerwendung(await apiRequest<Record<string, number>>(`/materialien/${material.id}/verwendung`));
} catch {
onFehler("Verwendung konnte nicht geladen werden.");
}
}
async function zusammenfuehren() {
if (mergeQuelleId === null || mergeZielId === "") {
onFehler("Ziel-Material auswählen.");
return;
}
try {
const ergebnis = await apiRequest<{ erfolgreich: boolean; konflikte: string[] }>(
`/materialien/${mergeQuelleId}/ersetzen`,
{ method: "POST", body: { ziel_material_id: mergeZielId } }
);
if (!ergebnis.erfolgreich) {
setMergeKonflikte(ergebnis.konflikte);
return;
}
setMergeQuelleId(null);
await onGeaendert();
} catch {
onFehler("Zusammenführen fehlgeschlagen.");
}
}
function bearbeiten(material: Material) {
setBearbeiteteId(material.id);
setFormular({
@@ -149,19 +191,70 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
{gefiltert.length > 0 && (
<ul className="card-list">
{gefiltert.map((m) => (
<li key={m.id} className="card row-between" style={{ padding: "0.6rem 1rem" }}>
<span>
{m.name} <span className="text-muted">({m.einheit})</span>
{m.artikelnummer && <span className="text-muted"> · {m.artikelnummer}</span>}
{m.materialtyp !== "standard" && (
<span className="badge badge-neutral" style={{ marginLeft: "0.5rem" }}>
{m.materialtyp}
</span>
)}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(m)}>
Bearbeiten
</button>
<li key={m.id} className="card" style={{ padding: "0.6rem 1rem" }}>
<div className="row-between">
<span>
{m.name} <span className="text-muted">({m.einheit})</span>
{m.artikelnummer && <span className="text-muted"> · {m.artikelnummer}</span>}
{m.materialtyp !== "standard" && (
<span className="badge badge-neutral" style={{ marginLeft: "0.5rem" }}>
{m.materialtyp}
</span>
)}
</span>
<span className="row" style={{ gap: "0.4rem" }}>
<button className="btn btn-secondary" onClick={() => bearbeiten(m)}>
Bearbeiten
</button>
<button className="btn btn-secondary" onClick={() => zusammenfuehrenOeffnen(m)}>
Zusammenführen
</button>
</span>
</div>
{mergeQuelleId === m.id && (
<div className="card" style={{ marginTop: "0.5rem", background: "var(--color-surface-sunken)" }}>
{mergeVerwendung === null ? (
<p className="text-muted">Lade Verwendung</p>
) : (
<ul>
{Object.entries(mergeVerwendung).map(([tabelle, anzahl]) => (
<li key={tabelle}>
{tabelle}: {anzahl}
</li>
))}
</ul>
)}
{mergeKonflikte.length > 0 && (
<div className="alert alert-danger">
Konflikt(e), Zusammenführen abgebrochen:
<ul>
{mergeKonflikte.map((k) => (
<li key={k}>{k}</li>
))}
</ul>
</div>
)}
<div className="row" style={{ marginTop: "0.5rem" }}>
<select
className="input"
value={mergeZielId}
onChange={(e) => setMergeZielId(Number(e.target.value) || "")}
>
<option value="">Ziel-Material wählen</option>
{materialien
.filter((z) => z.id !== m.id)
.map((z) => (
<option key={z.id} value={z.id}>
{z.name}
</option>
))}
</select>
<button className="btn btn-danger" onClick={zusammenfuehren}>
{m.name} löschen, alles auf Ziel umhängen
</button>
</div>
</div>
)}
</li>
))}
</ul>