fix(beladungsplaner): Klick-zum-Zuordnen statt HTML5-Drag&Drop
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:
@@ -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,31 +54,34 @@ 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>
|
||||||
|
{ausgewaehlt !== null && (
|
||||||
|
<p className="text-muted" style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<strong>{materialName(ausgewaehlt)}</strong> ausgewählt — jetzt Ziel-Fach antippen (oder nochmal
|
||||||
|
anklicken zum Abbrechen).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="row" style={{ alignItems: "flex-start", gap: "1rem", flexWrap: "wrap" }}>
|
<div className="row" style={{ alignItems: "flex-start", gap: "1rem", flexWrap: "wrap" }}>
|
||||||
<div
|
<div className="card" style={{ flex: "0 0 14rem", minHeight: "8rem" }}>
|
||||||
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>
|
<h4 style={{ marginTop: 0 }}>Material-Pool</h4>
|
||||||
{pool.length === 0 && <p className="text-muted" style={{ fontSize: "0.85rem" }}>Alles zugeordnet.</p>}
|
{pool.length === 0 && <p className="text-muted" style={{ fontSize: "0.85rem" }}>Alles zugeordnet.</p>}
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
{pool.map((m) => (
|
{pool.map((m) => (
|
||||||
<li
|
<li
|
||||||
key={m.id}
|
key={m.id}
|
||||||
draggable
|
onClick={() => setAusgewaehlt(ausgewaehlt === m.id ? null : m.id)}
|
||||||
onDragStart={(e) => e.dataTransfer.setData("text/plain", String(m.id))}
|
|
||||||
className="badge badge-neutral"
|
className="badge badge-neutral"
|
||||||
style={{ display: "block", marginBottom: "0.4rem", padding: "0.35rem 0.5rem", cursor: "grab" }}
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.4rem",
|
||||||
|
padding: "0.35rem 0.5rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: ausgewaehlt === m.id ? "2px solid var(--color-primary, #2563eb)" : "none",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{m.name}
|
{m.name}
|
||||||
</li>
|
</li>
|
||||||
@@ -86,25 +95,35 @@ export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faec
|
|||||||
<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",
|
||||||
|
borderColor: ausgewaehlt !== null ? "var(--color-primary, #2563eb)" : undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => zuordnen(spalte)}
|
||||||
>
|
>
|
||||||
<h4 style={{ marginTop: 0 }}>{spalte === OHNE_FACH ? "Ohne Fach" : spalte}</h4>
|
<h4 style={{ marginTop: 0 }}>{spalte === OHNE_FACH ? "Ohne Fach" : spalte}</h4>
|
||||||
{inhalt.length === 0 && (
|
{inhalt.length === 0 && (
|
||||||
<p className="text-muted" style={{ fontSize: "0.85rem" }}>Material hierher ziehen…</p>
|
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
{ausgewaehlt !== null ? "Hier antippen zum Zuordnen" : "Kein Material"}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
{inhalt.map((z) => (
|
{inhalt.map((z) => (
|
||||||
<li
|
<li
|
||||||
key={z.materialId}
|
key={z.materialId}
|
||||||
draggable
|
|
||||||
onDragStart={(e) => e.dataTransfer.setData("text/plain", String(z.materialId))}
|
|
||||||
className="card"
|
className="card"
|
||||||
style={{ padding: "0.4rem", marginBottom: "0.4rem", cursor: "grab" }}
|
style={{ padding: "0.4rem", marginBottom: "0.4rem" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<span style={{ fontSize: "0.85rem" }}>{materialName(z.materialId)}</span>
|
<span
|
||||||
|
style={{ fontSize: "0.85rem", cursor: "pointer" }}
|
||||||
|
onClick={() => z.materialId !== "" && setAusgewaehlt(z.materialId)}
|
||||||
|
>
|
||||||
|
{materialName(z.materialId)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ padding: "0 0.4rem" }}
|
style={{ padding: "0 0.4rem" }}
|
||||||
@@ -127,5 +146,6 @@ export function BeladungsplanerBoard({ zeilen, onZeilenChange, materialien, faec
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user