Files
MABEA/frontend/src/pages/admin/BeladungsplanerBoard.tsx
T
patrickandClaude Sonnet 5 4fa7ace6d9
CI / backend-tests (push) Successful in 1m56s
CI / frontend-build (push) Successful in 17s
feat(vorlage): Beladungsplanung per Drag&Drop (Shelf-inspiriert)
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
2026-09-05 23:25:10 +02:00

132 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}