From 364720111192e012affafef05e2a13dedbf37ab6 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Fri, 7 Aug 2026 09:42:39 +0200 Subject: [PATCH] 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) --- docs/05-frontend.md | 2 +- docs/07-anleitung-benutzer.md | 6 + src/components/calendar/Calendar.tsx | 406 ++++++++++++++++++++++++--- 3 files changed, 378 insertions(+), 36 deletions(-) diff --git a/docs/05-frontend.md b/docs/05-frontend.md index dcc2de7..4882093 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 0cb559b..62cfcaa 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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. diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx index bd15015..7f42d25 100644 --- a/src/components/calendar/Calendar.tsx +++ b/src/components/calendar/Calendar.tsx @@ -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): boolean { + return !dragEvent.currentTarget.contains(dragEvent.relatedTarget as Node | null); +} // ---------- Bausteine ---------- // Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React @@ -63,27 +108,42 @@ 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 ( + + ); +}; 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 = ({ @@ -100,11 +161,18 @@ const MonthView: React.FC = ({ onCreate, onOpenEvent, onShowDay, + drag, }) => { const days = monthMatrix(cursor); + const [dropDay, setDropDay] = useState(null); return ( -
+ // Bricht der Benutzer den Drag mit Esc ab, kommt kein dragleave mehr - das + // dragend der Quelle blubbert aber hierher und raeumt die Markierung weg. +
setDropDay(null)} + className="overflow-hidden rounded-2xl border border-ink-200 bg-surface" + >
{WEEKDAY_LABELS.map((label) => (
= ({ 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 (
onCreate(createAt)} @@ -137,10 +206,27 @@ const MonthView: React.FC = ({ 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' : '' + }`} >
= ({
{visible.map((event) => ( - + ))} {hidden > 0 && (