feat(vorlage): Beladungsplanung per Drag&Drop (Shelf-inspiriert)
CI / backend-tests (push) Successful in 1m56s
CI / frontend-build (push) Successful in 17s

Nutzer-Vorgabe: gute Inhalte aus Vergleichsprojekten übernehmen. Ersetzt die
Zeile-für-Zeile-Formular-Bearbeitung beim "Positionen bearbeiten" durch ein
Drag&Drop-Board: Material-Pool links, Fach-Spalten rechts (+ "Ohne Fach").
Material reinziehen ordnet zu, zwischen Spalten ziehen verschiebt das Fach,
"×" oder zurück in den Pool ziehen entfernt die Zuordnung. Sollmenge bleibt
inline editierbar. Nutzt weiterhin PUT /vorlagen/{id}/positionen, keine
Backend-Änderung nötig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 23:25:10 +02:00
co-authored by Claude Sonnet 5
parent c71597b76a
commit 4fa7ace6d9
2 changed files with 138 additions and 40 deletions
@@ -0,0 +1,131 @@
import type { Fach, Material } from "../../api/types";
interface PositionZeile {
materialId: number | "";
fach: string;
sollmenge: string;
}
interface Props {
zeilen: PositionZeile[];
onZeilenChange: (zeilen: PositionZeile[]) => void;
materialien: Material[];
faecher: Fach[];
}
const OHNE_FACH = "__ohne_fach__";
/** Shelf-inspiriert (Nutzer-Vorgabe 2026-09-05: gute Inhalte aus Vergleichsprojekten
* übernehmen): Beladungsplanung per Drag&Drop statt Zeile-für-Zeile-Formular -
* Material aus dem Pool links in eine Fach-Spalte rechts ziehen, weiterziehen
* verschiebt es ins andere Fach, "×" entfernt es wieder in den Pool. */
export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faecher }: Props) {
const zugewieseneIds = new Set(zeilen.filter((z) => z.materialId !== "").map((z) => z.materialId));
const pool = materialien.filter((m) => !zugewieseneIds.has(m.id));
const spalten = [...faecher.map((f) => f.name), OHNE_FACH];
function materialName(id: number | ""): string {
if (id === "") return "";
return materialien.find((m) => m.id === id)?.name ?? `#${id}`;
}
function onDropAufSpalte(e: React.DragEvent, fachName: string) {
e.preventDefault();
const materialId = Number(e.dataTransfer.getData("text/plain"));
if (!materialId) return;
const zielFach = fachName === OHNE_FACH ? "" : fachName;
const vorhanden = zeilen.find((z) => z.materialId === materialId);
if (vorhanden) {
onZeilenChange(zeilen.map((z) => (z.materialId === materialId ? { ...z, fach: zielFach } : z)));
} else {
onZeilenChange([...zeilen, { materialId, fach: zielFach, sollmenge: "1" }]);
}
}
function sollmengeAendern(materialId: number, sollmenge: string) {
onZeilenChange(zeilen.map((z) => (z.materialId === materialId ? { ...z, sollmenge } : z)));
}
function entfernen(materialId: number) {
onZeilenChange(zeilen.filter((z) => z.materialId !== materialId));
}
return (
<div className="row" style={{ alignItems: "flex-start", gap: "1rem", flexWrap: "wrap" }}>
<div
className="card"
style={{ flex: "0 0 14rem", minHeight: "8rem" }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
// Zurück in den Pool ziehen entfernt die Zuordnung.
e.preventDefault();
const materialId = Number(e.dataTransfer.getData("text/plain"));
if (materialId) entfernen(materialId);
}}
>
<h4 style={{ marginTop: 0 }}>Material-Pool</h4>
{pool.length === 0 && <p className="text-muted" style={{ fontSize: "0.85rem" }}>Alles zugeordnet.</p>}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{pool.map((m) => (
<li
key={m.id}
draggable
onDragStart={(e) => e.dataTransfer.setData("text/plain", String(m.id))}
className="badge badge-neutral"
style={{ display: "block", marginBottom: "0.4rem", padding: "0.35rem 0.5rem", cursor: "grab" }}
>
{m.name}
</li>
))}
</ul>
</div>
{spalten.map((spalte) => {
const inhalt = zeilen.filter((z) => (spalte === OHNE_FACH ? !z.fach : z.fach === spalte));
return (
<div
key={spalte}
className="card"
style={{ flex: "0 0 14rem", minHeight: "8rem" }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDropAufSpalte(e, spalte)}
>
<h4 style={{ marginTop: 0 }}>{spalte === OHNE_FACH ? "Ohne Fach" : spalte}</h4>
{inhalt.length === 0 && (
<p className="text-muted" style={{ fontSize: "0.85rem" }}>Material hierher ziehen</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{inhalt.map((z) => (
<li
key={z.materialId}
draggable
onDragStart={(e) => e.dataTransfer.setData("text/plain", String(z.materialId))}
className="card"
style={{ padding: "0.4rem", marginBottom: "0.4rem", cursor: "grab" }}
>
<div className="row-between">
<span style={{ fontSize: "0.85rem" }}>{materialName(z.materialId)}</span>
<button
className="btn btn-secondary"
style={{ padding: "0 0.4rem" }}
onClick={() => z.materialId !== "" && entfernen(z.materialId)}
>
×
</button>
</div>
<input
className="input"
type="number"
style={{ width: "4rem", marginTop: "0.3rem" }}
value={z.sollmenge}
onChange={(e) => z.materialId !== "" && sollmengeAendern(z.materialId, e.target.value)}
/>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
+5 -38
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Beladungsvorlage, Fach, Material, Objekttyp } from "../../api/types";
import { BeladungsplanerBoard } from "./BeladungsplanerBoard";
import { FachAuswahl } from "./FachAuswahl";
interface PositionZeile {
@@ -41,10 +42,6 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, faecher, on
);
}
function neueVersionZeileAendern(index: number, zeile: Partial<PositionZeile>) {
setNeueVersionZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z)));
}
async function positionenSpeichern(vorlageId: number) {
const positionen = neueVersionZeilen
.filter((z) => z.materialId !== "")
@@ -177,42 +174,12 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, faecher, on
Objekte behalten ihre bisherige Sollmenge nur neu angelegte Objekte nutzen die
aktualisierten Werte.
</p>
{neueVersionZeilen.map((zeile, i) => (
<div className="row" key={i}>
<select
className="input"
value={zeile.materialId}
onChange={(e) =>
neueVersionZeileAendern(i, { materialId: Number(e.target.value) || "" })
}
>
<option value="">Material</option>
{materialien.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<FachAuswahl
<BeladungsplanerBoard
zeilen={neueVersionZeilen}
onZeilenChange={setNeueVersionZeilen}
materialien={materialien}
faecher={faecher.filter((f) => f.objekttyp_id === v.objekttyp_id)}
wert={zeile.fach}
onAendern={(wert) => neueVersionZeileAendern(i, { fach: wert })}
/>
<input
className="input"
style={{ width: "5rem" }}
type="number"
value={zeile.sollmenge}
onChange={(e) => neueVersionZeileAendern(i, { sollmenge: e.target.value })}
/>
</div>
))}
<button
className="btn btn-secondary"
onClick={() => setNeueVersionZeilen((z) => [...z, LEERE_ZEILE])}
>
+ Position
</button>
<button
className="btn btn-primary"
onClick={() => positionenSpeichern(v.id)}