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
132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
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>
|
||
);
|
||
}
|