fix(beladungsplaner): Klick-zum-Zuordnen statt HTML5-Drag&Drop
CI / backend-tests (push) Successful in 1m58s
CI / frontend-build (push) Successful in 18s

Nutzer-Fund 2026-09-05: natives Drag&Drop funktionierte auf Desktop mit Maus
nicht zuverlässig und wäre auf Touch-Geräten ohnehin gar nicht nutzbar
(HTML5-DnD-API unterstützt keine Touch-Eingabe) - genau die Geräteklasse, die
dieses Projekt im Feld primär anspricht. Ersetzt durch Klick-zum-Zuordnen:
Material im Pool antippen (markiert es), dann Ziel-Fach antippen ordnet zu.
Funktioniert identisch auf Desktop und Touch.

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:33:36 +02:00
co-authored by Claude Sonnet 5
parent 4fa7ace6d9
commit a6f0c51842
+104 -84
View File
@@ -1,3 +1,5 @@
import { useState } from "react";
import type { Fach, Material } from "../../api/types"; import type { Fach, Material } from "../../api/types";
interface PositionZeile { interface PositionZeile {
@@ -16,10 +18,15 @@ interface Props {
const OHNE_FACH = "__ohne_fach__"; const OHNE_FACH = "__ohne_fach__";
/** Shelf-inspiriert (Nutzer-Vorgabe 2026-09-05: gute Inhalte aus Vergleichsprojekten /** Shelf-inspiriert (Nutzer-Vorgabe 2026-09-05: gute Inhalte aus Vergleichsprojekten
* übernehmen): Beladungsplanung per Drag&Drop statt Zeile-für-Zeile-Formular - * übernehmen): Beladungsplanung als Board statt Zeile-für-Zeile-Formular. Klick-zum-
* Material aus dem Pool links in eine Fach-Spalte rechts ziehen, weiterziehen * Zuordnen statt HTML5-Drag&Drop (Nutzer-Fund 2026-09-05: natives Drag&Drop
* verschiebt es ins andere Fach, "×" entfernt es wieder in den Pool. */ * funktioniert unzuverlässig browserübergreifend und gar nicht auf Touch-Geräten,
* die dieses Projekt aber gerade im Feld primär anspricht) - Material im Pool
* antippen (markiert es), dann Ziel-Fach antippen ordnet zu. Bereits zugeordnetes
* Material antippen verschiebt/wählt es erneut, "×" entfernt es. */
export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faecher }: Props) { export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faecher }: Props) {
const [ausgewaehlt, setAusgewaehlt] = useState<number | null>(null);
const zugewieseneIds = new Set(zeilen.filter((z) => z.materialId !== "").map((z) => z.materialId)); const zugewieseneIds = new Set(zeilen.filter((z) => z.materialId !== "").map((z) => z.materialId));
const pool = materialien.filter((m) => !zugewieseneIds.has(m.id)); const pool = materialien.filter((m) => !zugewieseneIds.has(m.id));
const spalten = [...faecher.map((f) => f.name), OHNE_FACH]; const spalten = [...faecher.map((f) => f.name), OHNE_FACH];
@@ -29,17 +36,16 @@ export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faec
return materialien.find((m) => m.id === id)?.name ?? `#${id}`; return materialien.find((m) => m.id === id)?.name ?? `#${id}`;
} }
function onDropAufSpalte(e: React.DragEvent, fachName: string) { function zuordnen(fachName: string) {
e.preventDefault(); if (ausgewaehlt === null) return;
const materialId = Number(e.dataTransfer.getData("text/plain"));
if (!materialId) return;
const zielFach = fachName === OHNE_FACH ? "" : fachName; const zielFach = fachName === OHNE_FACH ? "" : fachName;
const vorhanden = zeilen.find((z) => z.materialId === materialId); const vorhanden = zeilen.find((z) => z.materialId === ausgewaehlt);
if (vorhanden) { if (vorhanden) {
onZeilenChange(zeilen.map((z) => (z.materialId === materialId ? { ...z, fach: zielFach } : z))); onZeilenChange(zeilen.map((z) => (z.materialId === ausgewaehlt ? { ...z, fach: zielFach } : z)));
} else { } else {
onZeilenChange([...zeilen, { materialId, fach: zielFach, sollmenge: "1" }]); onZeilenChange([...zeilen, { materialId: ausgewaehlt, fach: zielFach, sollmenge: "1" }]);
} }
setAusgewaehlt(null);
} }
function sollmengeAendern(materialId: number, sollmenge: string) { function sollmengeAendern(materialId: number, sollmenge: string) {
@@ -48,84 +54,98 @@ export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faec
function entfernen(materialId: number) { function entfernen(materialId: number) {
onZeilenChange(zeilen.filter((z) => z.materialId !== materialId)); onZeilenChange(zeilen.filter((z) => z.materialId !== materialId));
if (ausgewaehlt === materialId) setAusgewaehlt(null);
} }
return ( return (
<div className="row" style={{ alignItems: "flex-start", gap: "1rem", flexWrap: "wrap" }}> <div>
<div {ausgewaehlt !== null && (
className="card" <p className="text-muted" style={{ marginBottom: "0.5rem" }}>
style={{ flex: "0 0 14rem", minHeight: "8rem" }} <strong>{materialName(ausgewaehlt)}</strong> ausgewählt jetzt Ziel-Fach antippen (oder nochmal
onDragOver={(e) => e.preventDefault()} anklicken zum Abbrechen).
onDrop={(e) => { </p>
// Zurück in den Pool ziehen entfernt die Zuordnung. )}
e.preventDefault(); <div className="row" style={{ alignItems: "flex-start", gap: "1rem", flexWrap: "wrap" }}>
const materialId = Number(e.dataTransfer.getData("text/plain")); <div className="card" style={{ flex: "0 0 14rem", minHeight: "8rem" }}>
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 }}>
<h4 style={{ marginTop: 0 }}>Material-Pool</h4> {pool.map((m) => (
{pool.length === 0 && <p className="text-muted" style={{ fontSize: "0.85rem" }}>Alles zugeordnet.</p>} <li
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}> key={m.id}
{pool.map((m) => ( onClick={() => setAusgewaehlt(ausgewaehlt === m.id ? null : m.id)}
<li className="badge badge-neutral"
key={m.id} style={{
draggable display: "block",
onDragStart={(e) => e.dataTransfer.setData("text/plain", String(m.id))} marginBottom: "0.4rem",
className="badge badge-neutral" padding: "0.35rem 0.5rem",
style={{ display: "block", marginBottom: "0.4rem", padding: "0.35rem 0.5rem", cursor: "grab" }} cursor: "pointer",
> outline: ausgewaehlt === m.id ? "2px solid var(--color-primary, #2563eb)" : "none",
{m.name} }}
</li> >
))} {m.name}
</ul> </li>
</div> ))}
</ul>
</div>
{spalten.map((spalte) => { {spalten.map((spalte) => {
const inhalt = zeilen.filter((z) => (spalte === OHNE_FACH ? !z.fach : z.fach === spalte)); const inhalt = zeilen.filter((z) => (spalte === OHNE_FACH ? !z.fach : z.fach === spalte));
return ( return (
<div <div
key={spalte} key={spalte}
className="card" className="card"
style={{ flex: "0 0 14rem", minHeight: "8rem" }} style={{
onDragOver={(e) => e.preventDefault()} flex: "0 0 14rem",
onDrop={(e) => onDropAufSpalte(e, spalte)} minHeight: "8rem",
> cursor: ausgewaehlt !== null ? "pointer" : "default",
<h4 style={{ marginTop: 0 }}>{spalte === OHNE_FACH ? "Ohne Fach" : spalte}</h4> borderColor: ausgewaehlt !== null ? "var(--color-primary, #2563eb)" : undefined,
{inhalt.length === 0 && ( }}
<p className="text-muted" style={{ fontSize: "0.85rem" }}>Material hierher ziehen</p> onClick={() => zuordnen(spalte)}
)} >
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}> <h4 style={{ marginTop: 0 }}>{spalte === OHNE_FACH ? "Ohne Fach" : spalte}</h4>
{inhalt.map((z) => ( {inhalt.length === 0 && (
<li <p className="text-muted" style={{ fontSize: "0.85rem" }}>
key={z.materialId} {ausgewaehlt !== null ? "Hier antippen zum Zuordnen" : "Kein Material"}
draggable </p>
onDragStart={(e) => e.dataTransfer.setData("text/plain", String(z.materialId))} )}
className="card" <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
style={{ padding: "0.4rem", marginBottom: "0.4rem", cursor: "grab" }} {inhalt.map((z) => (
> <li
<div className="row-between"> key={z.materialId}
<span style={{ fontSize: "0.85rem" }}>{materialName(z.materialId)}</span> className="card"
<button style={{ padding: "0.4rem", marginBottom: "0.4rem" }}
className="btn btn-secondary" onClick={(e) => e.stopPropagation()}
style={{ padding: "0 0.4rem" }} >
onClick={() => z.materialId !== "" && entfernen(z.materialId)} <div className="row-between">
> <span
× style={{ fontSize: "0.85rem", cursor: "pointer" }}
</button> onClick={() => z.materialId !== "" && setAusgewaehlt(z.materialId)}
</div> >
<input {materialName(z.materialId)}
className="input" </span>
type="number" <button
style={{ width: "4rem", marginTop: "0.3rem" }} className="btn btn-secondary"
value={z.sollmenge} style={{ padding: "0 0.4rem" }}
onChange={(e) => z.materialId !== "" && sollmengeAendern(z.materialId, e.target.value)} onClick={() => z.materialId !== "" && entfernen(z.materialId)}
/> >
</li> ×
))} </button>
</ul> </div>
</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>
</div> </div>
); );
} }