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:
co-authored by
Claude Opus 5
parent
4d90f14d6b
commit
3647201111
+1
-1
@@ -81,7 +81,7 @@ src/
|
|||||||
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
|
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
|
||||||
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
|
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
|
||||||
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
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)
|
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
|
||||||
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
|
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
|
||||||
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
|
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ eintragen.
|
|||||||
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blaettern.
|
* **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 Monatstag oder eine Stundenzelle legt sofort einen Termin an.
|
||||||
* Klick auf einen Wochentagskopf springt in die Tagesansicht.
|
* 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
|
* **Farben** gehoeren zum Ersteller. Die eigene Farbe aendert man unter
|
||||||
*Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche
|
*Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche
|
||||||
Farbe zu wem gehoert.
|
Farbe zu wem gehoert.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
positionEvents,
|
positionEvents,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
|
toSqlDateTime,
|
||||||
weekDays,
|
weekDays,
|
||||||
} from './calendarUtils';
|
} from './calendarUtils';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
@@ -55,6 +56,50 @@ const VIEW_BUTTONS: { mode: CalendarViewMode; label: string }[] = [
|
|||||||
const HOUR_HEIGHT = 48;
|
const HOUR_HEIGHT = 48;
|
||||||
/** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */
|
/** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */
|
||||||
const MONTH_CHIP_LIMIT = 3;
|
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 ----------
|
// ---------- Bausteine ----------
|
||||||
// Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React
|
// Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React
|
||||||
@@ -63,19 +108,33 @@ const MONTH_CHIP_LIMIT = 3;
|
|||||||
|
|
||||||
const EventChip: React.FC<{
|
const EventChip: React.FC<{
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
|
/** Tag, an dem dieser Chip haengt - Ausgangspunkt fuer den Tagesversatz. */
|
||||||
|
day: Date;
|
||||||
|
drag: CalendarDrag;
|
||||||
onOpen: (id: number) => void;
|
onOpen: (id: number) => void;
|
||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
}> = ({ event, onOpen, showTime = false }) => (
|
}> = ({ event, day, drag, onOpen, showTime = false }) => {
|
||||||
|
const draggable = drag.canDrag(event);
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={(dragEvent) => {
|
||||||
|
armDrag(dragEvent.dataTransfer, event);
|
||||||
|
drag.begin({ event, sourceDay: day, grabOffsetMs: 0 });
|
||||||
|
}}
|
||||||
|
onDragEnd={drag.finish}
|
||||||
onClick={(clickEvent) => {
|
onClick={(clickEvent) => {
|
||||||
clickEvent.stopPropagation();
|
clickEvent.stopPropagation();
|
||||||
onOpen(event.id);
|
onOpen(event.id);
|
||||||
}}
|
}}
|
||||||
title={`${event.title} · ${formatEventRange(event)}${
|
title={`${event.title} · ${formatEventRange(event)}${
|
||||||
event.created_by_name ? ` · ${event.created_by_name}` : ''
|
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) }}
|
style={{ backgroundColor: eventColor(event) }}
|
||||||
>
|
>
|
||||||
{showTime && !event.all_day && (
|
{showTime && !event.all_day && (
|
||||||
@@ -83,7 +142,8 @@ const EventChip: React.FC<{
|
|||||||
)}
|
)}
|
||||||
<span className="truncate">{event.title}</span>
|
<span className="truncate">{event.title}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface GridProps {
|
interface GridProps {
|
||||||
today: Date;
|
today: Date;
|
||||||
@@ -91,6 +151,7 @@ interface GridProps {
|
|||||||
onCreate: (start: Date) => void;
|
onCreate: (start: Date) => void;
|
||||||
onOpenEvent: (id: number) => void;
|
onOpenEvent: (id: number) => void;
|
||||||
onShowDay: (day: Date) => void;
|
onShowDay: (day: Date) => void;
|
||||||
|
drag: CalendarDrag;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
||||||
@@ -100,11 +161,18 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onOpenEvent,
|
onOpenEvent,
|
||||||
onShowDay,
|
onShowDay,
|
||||||
|
drag,
|
||||||
}) => {
|
}) => {
|
||||||
const days = monthMatrix(cursor);
|
const days = monthMatrix(cursor);
|
||||||
|
const [dropDay, setDropDay] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid grid-cols-7 border-b border-ink-100 bg-ink-50/60">
|
||||||
{WEEKDAY_LABELS.map((label) => (
|
{WEEKDAY_LABELS.map((label) => (
|
||||||
<div
|
<div
|
||||||
@@ -124,10 +192,11 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
|||||||
const visible = list.slice(0, MONTH_CHIP_LIMIT);
|
const visible = list.slice(0, MONTH_CHIP_LIMIT);
|
||||||
const hidden = list.length - visible.length;
|
const hidden = list.length - visible.length;
|
||||||
const createAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9);
|
const createAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9);
|
||||||
|
const key = day.toDateString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.toDateString()}
|
key={key}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onCreate(createAt)}
|
onClick={() => onCreate(createAt)}
|
||||||
@@ -137,10 +206,27 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
|||||||
onCreate(createAt);
|
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"
|
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 ${
|
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'
|
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">
|
<div className="mb-1 flex items-center justify-between px-0.5">
|
||||||
<span
|
<span
|
||||||
@@ -161,7 +247,14 @@ const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{visible.map((event) => (
|
{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 && (
|
{hidden > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -191,8 +284,14 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
|
|||||||
onCreate,
|
onCreate,
|
||||||
onOpenEvent,
|
onOpenEvent,
|
||||||
onShowDay,
|
onShowDay,
|
||||||
|
drag,
|
||||||
scrollRef,
|
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 allDayRows = days.map((day) => dayEvents(day).filter((event) => event.all_day));
|
||||||
const hasAllDay = allDayRows.some((list) => list.length > 0);
|
const hasAllDay = allDayRows.some((list) => list.length > 0);
|
||||||
const gridTemplate = `56px repeat(${days.length}, minmax(0, 1fr))`;
|
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;
|
const nowPercent = ((now.getTime() - startOfDay(now).getTime()) / DAY_MS) * 100;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Kopfzeile mit den Tagen */}
|
||||||
<div
|
<div
|
||||||
className="grid border-b border-ink-100 bg-ink-50/60"
|
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">
|
<div className="px-2 py-2 text-right text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||||
Ganztag
|
Ganztag
|
||||||
</div>
|
</div>
|
||||||
{days.map((day, index) => (
|
{days.map((day, index) => {
|
||||||
<div key={day.toDateString()} className="space-y-1 border-l border-ink-100 p-1">
|
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) => (
|
{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>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -267,8 +398,46 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
|
|||||||
day
|
day
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const key = day.toDateString();
|
||||||
|
const isDropTarget = timeDrop?.key === key;
|
||||||
|
const hint = isDropTarget ? timeDrop.start : null;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Stundenzellen sind gleichzeitig Klickflaeche fuer neue Termine */}
|
||||||
{Array.from({ length: 24 }, (_, hour) => (
|
{Array.from({ length: 24 }, (_, hour) => (
|
||||||
<div
|
<div
|
||||||
@@ -291,16 +460,60 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
|
|||||||
</div>
|
</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 }) => (
|
{positioned.map(({ event, topPercent, heightPercent, leftPercent, widthPercent }) => (
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
key={event.id}
|
||||||
type="button"
|
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) => {
|
onClick={(clickEvent) => {
|
||||||
clickEvent.stopPropagation();
|
clickEvent.stopPropagation();
|
||||||
onOpenEvent(event.id);
|
onOpenEvent(event.id);
|
||||||
}}
|
}}
|
||||||
title={`${event.title} · ${formatEventRange(event)}`}
|
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"
|
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={{
|
style={{
|
||||||
top: `${topPercent}%`,
|
top: `${topPercent}%`,
|
||||||
height: `calc(${heightPercent}% - 2px)`,
|
height: `calc(${heightPercent}% - 2px)`,
|
||||||
@@ -329,7 +542,7 @@ const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDi
|
|||||||
// ---------- Hauptansicht ----------
|
// ---------- Hauptansicht ----------
|
||||||
|
|
||||||
export const Calendar: React.FC<CalendarProps> = ({
|
export const Calendar: React.FC<CalendarProps> = ({
|
||||||
events,
|
events: rawEvents,
|
||||||
leads,
|
leads,
|
||||||
users,
|
users,
|
||||||
connections,
|
connections,
|
||||||
@@ -350,6 +563,43 @@ export const Calendar: React.FC<CalendarProps> = ({
|
|||||||
const [detailLeadId, setDetailLeadId] = useState<number | null>(null);
|
const [detailLeadId, setDetailLeadId] = useState<number | null>(null);
|
||||||
|
|
||||||
const timeGridRef = useRef<HTMLDivElement>(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.
|
// Das Zeitraster startet am Vormittag - die Nachtstunden interessieren selten.
|
||||||
useEffect(() => {
|
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 = {
|
const gridProps: GridProps = {
|
||||||
today,
|
today,
|
||||||
dayEvents,
|
dayEvents,
|
||||||
onCreate: openCreate,
|
onCreate: openCreate,
|
||||||
onOpenEvent: setDetailEventId,
|
onOpenEvent: setDetailEventId,
|
||||||
onShowDay: showDay,
|
onShowDay: showDay,
|
||||||
|
drag,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user