Files
ICON-CRM/src/lib/kanban.ts
T
2026-09-14 16:55:40 +02:00

106 lines
3.9 KiB
TypeScript

import { useCallback, useState, type DragEvent } from 'react';
/*
* Ziehen und Fallenlassen auf den Kanban-Brettern (Leads, Ausschreibungen).
*
* Die Gesten stecken hier und nicht in den Ansichten, weil sie dreimal an
* denselben Stellen schiefgehen:
*
* 1. **Ohne Nutzlast keine Geste.** Firefox startet eine Ziehgeste nur, wenn
* `dragstart` etwas in `dataTransfer` legt. Chrome ist da nachsichtiger -
* deshalb faellt es beim Entwickeln nicht auf und beim Benutzen sofort.
* 2. **Der Zustand allein reicht nicht.** Die Kennung wird zusaetzlich in
* `dataTransfer` mitgegeben. Sie ueberlebt damit auch einen Neuaufbau der
* Liste waehrend der Geste.
* 3. **Ohne `dropEffect` zeigt der Zeiger ein Verbotsschild**, obwohl das
* Fallenlassen erlaubt ist.
*
* Bilder in den Kacheln (Logo, Foto) muessen zusaetzlich `draggable={false}`
* tragen - sonst zieht der Browser das Bild statt der Kachel. Das erledigt die
* Avatar-Komponente.
*/
/** Eigenes Format neben `text/plain`: erkennt eigene Kacheln von fremdem Text. */
const MIME = 'application/x-icon-card';
interface KanbanDrag<K extends string> {
/** Kennung der Kachel am Zeiger - fuer die gedaempfte Darstellung. */
draggedId: number | null;
/** Spalte unter dem Zeiger - fuer die Hervorhebung des Ziels. */
hoverColumn: K | null;
/** Auf die Kachel legen. */
cardProps: (id: number) => {
draggable: true;
onDragStart: (event: DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
};
/** Auf die Spalte legen. */
columnProps: (column: K) => {
onDragOver: (event: DragEvent<HTMLElement>) => void;
onDragLeave: (event: DragEvent<HTMLElement>) => void;
onDrop: (event: DragEvent<HTMLElement>) => void;
};
}
export function useKanbanDrag<K extends string>(
onMove: (id: number, column: K) => void
): KanbanDrag<K> {
const [draggedId, setDraggedId] = useState<number | null>(null);
const [hoverColumn, setHoverColumn] = useState<K | null>(null);
const cardProps = useCallback(
(id: number) =>
({
draggable: true,
onDragStart: (event: DragEvent<HTMLElement>) => {
event.dataTransfer.setData(MIME, String(id));
event.dataTransfer.setData('text/plain', String(id));
event.dataTransfer.effectAllowed = 'move';
setDraggedId(id);
},
onDragEnd: () => {
setDraggedId(null);
setHoverColumn(null);
},
}) as const,
[]
);
const columnProps = useCallback(
(column: K) => ({
onDragOver: (event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setHoverColumn((current) => (current === column ? current : column));
},
onDragLeave: (event: DragEvent<HTMLElement>) => {
// Der Zeiger wandert staendig ueber Kindelemente - nur das Verlassen
// der Spalte selbst zaehlt.
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setHoverColumn((current) => (current === column ? null : current));
},
onDrop: (event: DragEvent<HTMLElement>) => {
event.preventDefault();
const carried = event.dataTransfer.getData(MIME) || event.dataTransfer.getData('text/plain');
const id = draggedId ?? Number(carried);
setDraggedId(null);
setHoverColumn(null);
if (Number.isInteger(id) && id > 0) onMove(id, column);
},
}),
[draggedId, onMove]
);
return { draggedId, hoverColumn, cardProps, columnProps };
}
/** Rahmen der Spalte, solange eine Kachel darueber schwebt. */
export function columnClass(isHover: boolean): string {
return isHover ? 'border-brand-400 bg-brand-50/40' : 'border-ink-200 bg-canvas';
}
/** Die Kachel am Zeiger wird gedaempft - sonst sieht man nicht, was man zieht. */
export function cardClass(isDragged: boolean): string {
return isDragged ? 'opacity-40' : '';
}