feat(kalender): Termine per Drag & Drop verschieben

Im Monatsraster wechselt ein gezogener Termin den Tag und behaelt seine
Uhrzeit; in Woche und Tag rastet er auf 15 Minuten ein. Der Greifpunkt
wandert mit, die Dauer bleibt erhalten, und eine gestrichelte Linie zeigt
die neue Startzeit vorab an.

Die Anzeige springt sofort ans Ziel (pendingMoves) und faellt zurueck,
wenn der Server den PATCH ablehnt. Ziehen darf nur, wer den Termin auch
aendern darf - das prueft der Server ohnehin erneut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-07 09:42:39 +02:00
co-authored by Claude Opus 5
parent 4d90f14d6b
commit 3647201111
3 changed files with 378 additions and 36 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ src/
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine, @-Mentions
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
+6
View File
@@ -86,6 +86,12 @@ eintragen.
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blaettern.
* Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an.
* Klick auf einen Wochentagskopf springt in die Tagesansicht.
* **Verschieben per Ziehen**: Termin anfassen und auf einen anderen Tag oder eine
andere Uhrzeit ziehen. Im Monatsraster wechselt nur der Tag, die Uhrzeit bleibt
stehen; in Woche und Tag rastet der Termin auf **15 Minuten** ein, und eine
gestrichelte Linie zeigt vorab die neue Startzeit. Die **Dauer bleibt** immer
gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen
darf nur, wer den Termin auch aendern darf.
* **Farben** gehoeren zum Ersteller. Die eigene Farbe aendert man unter
*Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche
Farbe zu wem gehoert.
+352 -16
View File
@@ -22,6 +22,7 @@ import {
positionEvents,
startOfDay,
startOfWeek,
toSqlDateTime,
weekDays,
} from './calendarUtils';
import { useToast } from '../Toast';
@@ -55,6 +56,50 @@ const VIEW_BUTTONS: { mode: CalendarViewMode; label: string }[] = [
const HOUR_HEIGHT = 48;
/** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */
const MONTH_CHIP_LIMIT = 3;
/** Raster, auf das ein gezogener Termin im Zeitraster einrastet. */
const SNAP_MS = 15 * 60 * 1000;
// ---------- Verschieben per Drag & Drop ----------
interface DragInfo {
event: CalendarEvent;
/** Tag, an dem der Termin angefasst wurde - mehrtaegige haengen an mehreren. */
sourceDay: Date;
/** Abstand vom Terminstart bis zum Greifpunkt; nur das Zeitraster fuellt ihn. */
grabOffsetMs: number;
}
interface CalendarDrag {
canDrag: (event: CalendarEvent) => boolean;
active: () => boolean;
begin: (info: DragInfo) => void;
finish: () => void;
/** Ganzer Tagesversatz - Monatsraster und Ganztagszeile. */
dropOnDay: (day: Date) => void;
/** Neuer Startzeitpunkt aus der Zeigerposition im Zeitraster. */
dropOnTime: (day: Date, minutes: number) => void;
/** Vorschaulinie unter dem Zeiger; null bei ganztaegigen Terminen. */
previewStart: (day: Date, minutes: number) => Date | null;
}
/** Zeigerposition innerhalb einer Tagesspalte als Minuten seit 00:00. */
function pointerMinutes(clientY: number, column: HTMLElement): number {
const rect = column.getBoundingClientRect();
if (rect.height === 0) return 0;
const minutes = ((clientY - rect.top) / rect.height) * 24 * 60;
return Math.min(Math.max(minutes, 0), 24 * 60);
}
/** Setzt die Daten, ohne die Firefox gar keinen Drag startet. */
function armDrag(transfer: DataTransfer, event: CalendarEvent) {
transfer.effectAllowed = 'move';
transfer.setData('text/plain', String(event.id));
}
/** Verlaesst der Zeiger das Element wirklich - oder nur ein Kindelement? */
function leftElement(dragEvent: React.DragEvent<HTMLElement>): boolean {
return !dragEvent.currentTarget.contains(dragEvent.relatedTarget as Node | null);
}
// ---------- Bausteine ----------
// Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React
@@ -63,19 +108,33 @@ const MONTH_CHIP_LIMIT = 3;
const EventChip: React.FC<{
event: CalendarEvent;
/** Tag, an dem dieser Chip haengt - Ausgangspunkt fuer den Tagesversatz. */
day: Date;
drag: CalendarDrag;
onOpen: (id: number) => void;
showTime?: boolean;
}> = ({ event, onOpen, showTime = false }) => (
}> = ({ event, day, drag, onOpen, showTime = false }) => {
const draggable = drag.canDrag(event);
return (
<button
type="button"
draggable={draggable}
onDragStart={(dragEvent) => {
armDrag(dragEvent.dataTransfer, event);
drag.begin({ event, sourceDay: day, grabOffsetMs: 0 });
}}
onDragEnd={drag.finish}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpen(event.id);
}}
title={`${event.title} · ${formatEventRange(event)}${
event.created_by_name ? ` · ${event.created_by_name}` : ''
}${draggable ? ' · Ziehen zum Verschieben' : ''}`}
className={`flex w-full items-center gap-1.5 overflow-hidden rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-white transition hover:brightness-110 ${
draggable ? 'cursor-grab active:cursor-grabbing' : ''
}`}
className="flex w-full items-center gap-1.5 overflow-hidden rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-white transition hover:brightness-110"
style={{ backgroundColor: eventColor(event) }}
>
{showTime && !event.all_day && (
@@ -83,7 +142,8 @@ const EventChip: React.FC<{
)}
<span className="truncate">{event.title}</span>
</button>
);
);
};
interface GridProps {
today: Date;
@@ -91,6 +151,7 @@ interface GridProps {
onCreate: (start: Date) => void;
onOpenEvent: (id: number) => void;
onShowDay: (day: Date) => void;
drag: CalendarDrag;
}
const MonthView: React.FC<GridProps & { cursor: Date }> = ({
@@ -100,11 +161,18 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
onCreate,
onOpenEvent,
onShowDay,
drag,
}) => {
const days = monthMatrix(cursor);
const [dropDay, setDropDay] = useState<string | null>(null);
return (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface">
// Bricht der Benutzer den Drag mit Esc ab, kommt kein dragleave mehr - das
// dragend der Quelle blubbert aber hierher und raeumt die Markierung weg.
<div
onDragEnd={() => setDropDay(null)}
className="overflow-hidden rounded-2xl border border-ink-200 bg-surface"
>
<div className="grid grid-cols-7 border-b border-ink-100 bg-ink-50/60">
{WEEKDAY_LABELS.map((label) => (
<div
@@ -124,10 +192,11 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
const visible = list.slice(0, MONTH_CHIP_LIMIT);
const hidden = list.length - visible.length;
const createAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9);
const key = day.toDateString();
return (
<div
key={day.toDateString()}
key={key}
role="button"
tabIndex={0}
onClick={() => onCreate(createAt)}
@@ -137,10 +206,27 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
onCreate(createAt);
}
}}
onDragOver={(dragEvent) => {
if (!drag.active()) return;
dragEvent.preventDefault();
dragEvent.dataTransfer.dropEffect = 'move';
setDropDay(key);
}}
onDragLeave={(dragEvent) => {
if (leftElement(dragEvent)) setDropDay((current) => (current === key ? null : current));
}}
onDrop={(dragEvent) => {
if (!drag.active()) return;
dragEvent.preventDefault();
setDropDay(null);
drag.dropOnDay(day);
}}
title="Klicken, um an diesem Tag einen Termin anzulegen"
className={`min-h-[104px] cursor-pointer border-b border-ink-100 p-1.5 transition hover:bg-brand-50/40 ${
index % 7 === 6 ? '' : 'border-r'
} ${isCurrentMonth ? '' : 'bg-ink-50/50'}`}
} ${isCurrentMonth ? '' : 'bg-ink-50/50'} ${
dropDay === key ? 'bg-brand-50 ring-2 ring-inset ring-brand-400' : ''
}`}
>
<div className="mb-1 flex items-center justify-between px-0.5">
<span
@@ -161,7 +247,14 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
<div className="space-y-1">
{visible.map((event) => (
<EventChip key={event.id} event={event} onOpen={onOpenEvent} showTime />
<EventChip
key={event.id}
event={event}
day={day}
drag={drag}
onOpen={onOpenEvent}
showTime
/>
))}
{hidden > 0 && (
<button
@@ -191,8 +284,14 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
onCreate,
onOpenEvent,
onShowDay,
drag,
scrollRef,
}) => {
const [allDayDrop, setAllDayDrop] = useState<string | null>(null);
// `start` bleibt null, wenn ein ganztaegiger Termin gezogen wird - dann gibt es
// keine Uhrzeit zu zeigen, nur den Zieltag.
const [timeDrop, setTimeDrop] = useState<{ key: string; start: Date | null } | null>(null);
const allDayRows = days.map((day) => dayEvents(day).filter((event) => event.all_day));
const hasAllDay = allDayRows.some((list) => list.length > 0);
const gridTemplate = `56px repeat(${days.length}, minmax(0, 1fr))`;
@@ -200,7 +299,13 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
const nowPercent = ((now.getTime() - startOfDay(now).getTime()) / DAY_MS) * 100;
return (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface">
<div
onDragEnd={() => {
setAllDayDrop(null);
setTimeDrop(null);
}}
className="overflow-hidden rounded-2xl border border-ink-200 bg-surface"
>
{/* Kopfzeile mit den Tagen */}
<div
className="grid border-b border-ink-100 bg-ink-50/60"
@@ -234,13 +339,39 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
<div className="px-2 py-2 text-right text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Ganztag
</div>
{days.map((day, index) => (
<div key={day.toDateString()} className="space-y-1 border-l border-ink-100 p-1">
{days.map((day, index) => {
const key = day.toDateString();
return (
<div
key={key}
onDragOver={(dragEvent) => {
if (!drag.active()) return;
dragEvent.preventDefault();
dragEvent.dataTransfer.dropEffect = 'move';
setAllDayDrop(key);
}}
onDragLeave={(dragEvent) => {
if (leftElement(dragEvent)) {
setAllDayDrop((current) => (current === key ? null : current));
}
}}
onDrop={(dragEvent) => {
if (!drag.active()) return;
dragEvent.preventDefault();
setAllDayDrop(null);
drag.dropOnDay(day);
}}
className={`space-y-1 border-l border-ink-100 p-1 transition ${
allDayDrop === key ? 'bg-brand-50 ring-2 ring-inset ring-brand-400' : ''
}`}
>
{allDayRows[index].map((event) => (
<EventChip key={event.id} event={event} onOpen={onOpenEvent} />
<EventChip key={event.id} event={event} day={day} drag={drag} onOpen={onOpenEvent} />
))}
</div>
))}
);
})}
</div>
)}
@@ -267,8 +398,46 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
day
);
const key = day.toDateString();
const isDropTarget = timeDrop?.key === key;
const hint = isDropTarget ? timeDrop.start : null;
return (
<div key={day.toDateString()} className="relative border-l border-ink-100">
<div
key={key}
data-day-column
className={`relative border-l border-ink-100 transition ${
isDropTarget ? 'bg-brand-50/40' : ''
}`}
onDragOver={(dragEvent) => {
if (!drag.active()) return;
dragEvent.preventDefault();
dragEvent.dataTransfer.dropEffect = 'move';
const start = drag.previewStart(
day,
pointerMinutes(dragEvent.clientY, dragEvent.currentTarget)
);
// Nur bei echter Aenderung neu rendern - dragover feuert dauernd.
setTimeDrop((current) =>
current &&
current.key === key &&
(current.start?.getTime() ?? null) === (start?.getTime() ?? null)
? current
: { key, start }
);
}}
onDragLeave={(dragEvent) => {
if (leftElement(dragEvent)) {
setTimeDrop((current) => (current?.key === key ? null : current));
}
}}
onDrop={(dragEvent) => {
if (!drag.active()) return;
dragEvent.preventDefault();
setTimeDrop(null);
drag.dropOnTime(day, pointerMinutes(dragEvent.clientY, dragEvent.currentTarget));
}}
>
{/* Stundenzellen sind gleichzeitig Klickflaeche fuer neue Termine */}
{Array.from({ length: 24 }, (_, hour) => (
<div
@@ -291,16 +460,60 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
</div>
)}
{/* Wo landet der gezogene Termin? */}
{hint && (
<div
className="pointer-events-none absolute inset-x-0 z-20 border-t-2 border-dashed border-brand-500"
style={{
top: `${
(Math.min(
Math.max((hint.getTime() - startOfDay(day).getTime()) / 60000, 0),
24 * 60
) /
(24 * 60)) *
100
}%`,
}}
>
<span className="absolute left-1 top-0.5 rounded bg-brand-500 px-1 py-px text-[10px] font-extrabold text-white shadow-card">
{formatTime(hint)}
</span>
</div>
)}
{positioned.map(({ event, topPercent, heightPercent, leftPercent, widthPercent }) => (
<button
key={event.id}
type="button"
draggable={drag.canDrag(event)}
onDragStart={(dragEvent) => {
armDrag(dragEvent.dataTransfer, event);
// Der Greifpunkt bleibt erhalten: der Termin rutscht dorthin,
// wo er angefasst wurde - nicht mit dem Start unter den Zeiger.
const column = dragEvent.currentTarget.closest('[data-day-column]');
const grabbedAt =
startOfDay(day).getTime() +
(column instanceof HTMLElement
? pointerMinutes(dragEvent.clientY, column)
: 0) *
60000;
drag.begin({
event,
sourceDay: day,
grabOffsetMs: grabbedAt - parseSqlDateTime(event.starts_at).getTime(),
});
}}
onDragEnd={drag.finish}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpenEvent(event.id);
}}
title={`${event.title} · ${formatEventRange(event)}`}
className="absolute overflow-hidden rounded-lg border-l-4 px-1.5 py-1 text-left text-[11px] leading-tight text-white shadow-card transition hover:brightness-110"
title={`${event.title} · ${formatEventRange(event)}${
drag.canDrag(event) ? ' · Ziehen zum Verschieben' : ''
}`}
className={`absolute overflow-hidden rounded-lg border-l-4 px-1.5 py-1 text-left text-[11px] leading-tight text-white shadow-card transition hover:brightness-110 ${
drag.canDrag(event) ? 'cursor-grab active:cursor-grabbing' : ''
}`}
style={{
top: `${topPercent}%`,
height: `calc(${heightPercent}% - 2px)`,
@@ -329,7 +542,7 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
// ---------- Hauptansicht ----------
export const Calendar: React.FC<CalendarProps> = ({
events,
events: rawEvents,
leads,
users,
connections,
@@ -350,6 +563,43 @@ export const Calendar: React.FC<CalendarProps> = ({
const [detailLeadId, setDetailLeadId] = useState<number | null>(null);
const timeGridRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragInfo | null>(null);
// Verschobene Termine sofort am Ziel zeigen, statt bis zum Neuladen an der
// alten Stelle stehen zu lassen. Der Eintrag faellt weg, sobald der Server
// dieselbe Zeit liefert - oder wenn das Speichern fehlschlaegt.
const [pendingMoves, setPendingMoves] = useState<
Map<number, { starts_at: string; ends_at: string }>
>(() => new Map());
const events = useMemo(() => {
if (pendingMoves.size === 0) return rawEvents;
return rawEvents.map((event) => {
const move = pendingMoves.get(event.id);
return move ? { ...event, ...move } : event;
});
}, [rawEvents, pendingMoves]);
useEffect(() => {
setPendingMoves((current) => {
if (current.size === 0) return current;
const next = new Map(current);
const known = new Set(rawEvents.map((event) => event.id));
for (const id of current.keys()) {
if (!known.has(id)) next.delete(id);
}
for (const event of rawEvents) {
const move = next.get(event.id);
if (
move &&
parseSqlDateTime(event.starts_at).getTime() === parseSqlDateTime(move.starts_at).getTime()
) {
next.delete(event.id);
}
}
return next.size === current.size ? current : next;
});
}, [rawEvents]);
// Das Zeitraster startet am Vormittag - die Nachtstunden interessieren selten.
useEffect(() => {
@@ -477,12 +727,98 @@ export const Calendar: React.FC<CalendarProps> = ({
}
};
/**
* Verschiebt einen Termin auf einen neuen Start; die Dauer bleibt erhalten.
* Die Anzeige springt sofort mit, der Server bekommt das Ergebnis danach.
*/
const moveEvent = async (event: CalendarEvent, newStart: Date) => {
const start = parseSqlDateTime(event.starts_at);
const end = parseSqlDateTime(event.ends_at);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return;
if (newStart.getTime() === start.getTime()) return;
if (!canModify(event)) {
addToast('Diesen Termin darf nur der Ersteller oder ein Administrator verschieben.', 'error');
return;
}
const newEnd = new Date(newStart.getTime() + Math.max(end.getTime() - start.getTime(), 0));
const patch = { starts_at: toSqlDateTime(newStart), ends_at: toSqlDateTime(newEnd) };
setPendingMoves((current) => new Map(current).set(event.id, patch));
try {
await eventsApi.update(event.id, patch);
const target = newStart.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
addToast(
`"${event.title}" auf ${target}${event.all_day ? '' : `, ${formatTime(newStart)}`} verschoben.`,
'success'
);
reload();
} catch (err) {
setPendingMoves((current) => {
const next = new Map(current);
next.delete(event.id);
return next;
});
addToast(err instanceof Error ? err.message : 'Verschieben fehlgeschlagen.', 'error');
}
};
/** Neuer Start bei reinem Tageswechsel - die Uhrzeit bleibt stehen. */
const shiftedStart = (info: DragInfo, day: Date) => {
const delta = Math.round(
(startOfDay(day).getTime() - startOfDay(info.sourceDay).getTime()) / DAY_MS
);
return addDays(parseSqlDateTime(info.event.starts_at), delta);
};
/** Neuer Start aus der Zeigerposition, eingerastet auf 15 Minuten. */
const snappedStart = (info: DragInfo, day: Date, minutes: number) => {
const dayStart = startOfDay(day).getTime();
const raw = dayStart + minutes * 60000 - info.grabOffsetMs;
return new Date(dayStart + Math.round((raw - dayStart) / SNAP_MS) * SNAP_MS);
};
const drag: CalendarDrag = {
canDrag: canModify,
active: () => dragRef.current !== null,
begin: (info) => {
dragRef.current = info;
},
finish: () => {
dragRef.current = null;
},
dropOnDay: (day) => {
const info = dragRef.current;
dragRef.current = null;
if (info) moveEvent(info.event, shiftedStart(info, day));
},
dropOnTime: (day, minutes) => {
const info = dragRef.current;
dragRef.current = null;
if (!info) return;
// Ganztaegige Termine kennen keine Uhrzeit - fuer sie zaehlt nur der Tag.
moveEvent(
info.event,
info.event.all_day ? shiftedStart(info, day) : snappedStart(info, day, minutes)
);
},
previewStart: (day, minutes) => {
const info = dragRef.current;
return info && !info.event.all_day ? snappedStart(info, day, minutes) : null;
},
};
const gridProps: GridProps = {
today,
dayEvents,
onCreate: openCreate,
onOpenEvent: setDetailEventId,
onShowDay: showDay,
drag,
};
return (