From 4fa7ace6d9c59cef071fb1d1a41ce428bb34cb99 Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 5 Sep 2026 23:25:10 +0200 Subject: [PATCH] feat(vorlage): Beladungsplanung per Drag&Drop (Shelf-inspiriert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- .../src/pages/admin/BeladungsplanerBoard.tsx | 131 ++++++++++++++++++ frontend/src/pages/admin/VorlageSection.tsx | 47 +------ 2 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 frontend/src/pages/admin/BeladungsplanerBoard.tsx diff --git a/frontend/src/pages/admin/BeladungsplanerBoard.tsx b/frontend/src/pages/admin/BeladungsplanerBoard.tsx new file mode 100644 index 0000000..07f6619 --- /dev/null +++ b/frontend/src/pages/admin/BeladungsplanerBoard.tsx @@ -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 ( +
+
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); + }} + > +

Material-Pool

+ {pool.length === 0 &&

Alles zugeordnet.

} +
    + {pool.map((m) => ( +
  • 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} +
  • + ))} +
+
+ + {spalten.map((spalte) => { + const inhalt = zeilen.filter((z) => (spalte === OHNE_FACH ? !z.fach : z.fach === spalte)); + return ( +
e.preventDefault()} + onDrop={(e) => onDropAufSpalte(e, spalte)} + > +

{spalte === OHNE_FACH ? "Ohne Fach" : spalte}

+ {inhalt.length === 0 && ( +

Material hierher ziehen…

+ )} +
    + {inhalt.map((z) => ( +
  • e.dataTransfer.setData("text/plain", String(z.materialId))} + className="card" + style={{ padding: "0.4rem", marginBottom: "0.4rem", cursor: "grab" }} + > +
    + {materialName(z.materialId)} + +
    + z.materialId !== "" && sollmengeAendern(z.materialId, e.target.value)} + /> +
  • + ))} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/pages/admin/VorlageSection.tsx b/frontend/src/pages/admin/VorlageSection.tsx index 960d901..1e09775 100644 --- a/frontend/src/pages/admin/VorlageSection.tsx +++ b/frontend/src/pages/admin/VorlageSection.tsx @@ -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) { - 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.

- {neueVersionZeilen.map((zeile, i) => ( -
- - f.objekttyp_id === v.objekttyp_id)} - wert={zeile.fach} - onAendern={(wert) => neueVersionZeileAendern(i, { fach: wert })} - /> - neueVersionZeileAendern(i, { sollmenge: e.target.value })} - /> -
- ))} - + f.objekttyp_id === v.objekttyp_id)} + />