import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react' import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode } from '../../api/client' import { addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO, toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes, } from './dates' import { ColorSwatches, Toggle } from './controls' interface Props { /** Fehlt er, entsteht ein neuer Termin. */ event?: CalendarEvent /** Vorbelegter Start – aus dem angeklickten Tag bzw. der Stunde. */ defaultStart: Date calendars: CalendarItem[] calendarId: string onSave: (input: EventInput, calendarId: string) => void onDelete?: () => void onCancel: () => void } type FreqChoice = 'NONE' | Recurrence['freq'] type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL' const freqLabels: Record = { NONE: 'Einmalig', DAILY: 'Täglich', WEEKLY: 'Wöchentlich', MONTHLY: 'Monatlich', YEARLY: 'Jährlich', } const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] const monthNames = [ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ] /** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie * man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */ function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } { const n = nthOfMonth(start) const nth = n === -1 ? 'letzten' : `${n}.` const wd = weekdayLong[(start.getDay() + 6) % 7] const month = monthNames[start.getMonth()] return yearly ? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` } : { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` } } export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) { const overlay = useOverlayDismiss(onCancel) const titleRef = useRef(null) const allDayInit = event?.all_day ?? false // Bei einer Reihe bearbeitet das Formular die **Reihe**, nicht den // angeklickten Einzeltermin – deshalb series_start, wo vorhanden. const baseStartISO = event?.series_start ?? event?.start const baseEndISO = event?.series_end ?? event?.end const start = baseStartISO ? eventDay(baseStartISO, allDayInit) : defaultStart const startTimed = baseStartISO && !allDayInit ? new Date(baseStartISO) : defaultStart const end = baseEndISO ? eventDay(baseEndISO, allDayInit) : new Date(startTimed.getTime() + 60 * 60 * 1000) const [calId, setCalId] = useState(calendarId) const [summary, setSummary] = useState(event?.summary ?? '') const [description, setDescription] = useState(event?.description ?? '') const [location, setLocation] = useState(event?.location ?? '') const [color, setColor] = useState(event?.color ?? '') const [allDay, setAllDay] = useState(allDayInit) const [startDate, setStartDate] = useState(toDateInput(start)) const [startTime, setStartTime] = useState(toTimeInput(startTimed)) const [endDate, setEndDate] = useState(toDateInput(end)) const [endTime, setEndTime] = useState(toTimeInput(end)) const rec = event?.recurrence const [freq, setFreq] = useState(rec?.freq ?? 'NONE') const [interval, setInterval] = useState(String(rec?.interval ?? 1)) const [endChoice, setEndChoice] = useState( rec?.count ? 'COUNT' : rec?.until ? 'UNTIL' : 'NEVER') const [count, setCount] = useState(String(rec?.count ?? 10)) const [until, setUntil] = useState( rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90))) const [byday, setByday] = useState(rec?.byday ?? []) const [monthMode, setMonthMode] = useState<'DAY' | 'WEEKDAY'>(rec?.mode === 'WEEKDAY' ? 'WEEKDAY' : 'DAY') // Bezugstag für alles, was sich aus dem Start ableitet („am 1. Freitag"). // Mittags, damit ein Sommerzeitsprung das Datum nicht kippt. const startDay = dateFromInput(startDate) const startWeekday = weekdayCodeOf(startDay) // Der Wochentag des Starts ist immer dabei und lässt sich nicht abwählen – // sonst zeigte die Reihe nicht mehr auf ihren eigenen ersten Termin. const activeDays: WeekdayCode[] = Array.from(new Set([...byday, startWeekday])) const modeLabels = monthlyModeLabels(startDay, freq === 'YEARLY') useEffect(() => { titleRef.current?.focus() }, []) useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onCancel]) // Nur Regeln, die dieses Formular vollständig abbildet, sind bearbeitbar. // Alles andere ("jeden zweiten Dienstag im Monat") bliebe beim Speichern // auf der Strecke – der Server lehnt es deshalb auch ab. const readOnly = !!event?.recurring && !event?.recurrence const isSeries = !!event?.recurring function changeStartDate(v: string) { const shift = (+new Date(v) - +new Date(startDate)) / 86400000 setStartDate(v) if (Number.isFinite(shift) && shift !== 0) { setEndDate(toDateInput(addDays(new Date(`${endDate}T12:00:00`), shift))) } } function buildRecurrence(): Recurrence | null { if (freq === 'NONE') return null const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) } if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1) if (endChoice === 'UNTIL') r.until = allDayISO(until) if (freq === 'WEEKLY') r.byday = activeDays if (freq === 'MONTHLY' || freq === 'YEARLY') r.mode = monthMode return r } function toggleDay(code: WeekdayCode) { if (code === startWeekday) return setByday(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]) } function handleSubmit(e: FormEvent) { e.preventDefault() if (readOnly || !summary.trim()) return let startISO: string, endISO: string if (allDay) { startISO = allDayISO(startDate) endISO = allDayISO(endDate < startDate ? startDate : endDate) } else { startISO = timedISO(startDate, startTime) endISO = timedISO(endDate, endTime) if (endISO <= startISO) { endISO = new Date(+new Date(startISO) + 60 * 60 * 1000).toISOString() } } onSave({ summary: summary.trim(), description: description.trim() || undefined, location: location.trim() || undefined, all_day: allDay, color: color || undefined, recurrence: buildRecurrence(), start: startISO, end: endISO, }, calId) } const inputStyle: CSSProperties = { width: '100%', boxSizing: 'border-box', background: 'var(--color-surface-2)', border: '1px solid var(--color-border)', borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)', outline: 'none', } const labelStyle: CSSProperties = { fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block', } const noteStyle: CSSProperties = { background: 'var(--color-surface-2)', border: '1px solid var(--color-border)', borderRadius: 8, padding: '8px 10px', fontSize: 12.5, color: 'var(--color-text-muted)', marginBottom: 14, lineHeight: 1.45, } const calColor = calendars.find(c => c.id === calId)?.color ?? '#4285f4' return (

{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}

{readOnly && (
Diese Wiederholungsregel kann dieses Formular nicht vollständig abbilden – zum Ändern einen CalDAV-Client verwenden (z. B. DAVx5). Der Termin bleibt unverändert erhalten.
)} {isSeries && !readOnly && (
Terminreihe: Änderungen gelten für alle Termine der Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
)}
setSummary(e.target.value)} style={inputStyle} required />
{calendars.length > 1 && (
{/* Beim Bearbeiten fest: Ein Umhängen wäre Löschen und Neuanlegen unter neuer Kennung – das gehört nicht versteckt in ein Auswahlfeld. */}
)}
changeStartDate(e.target.value)} style={inputStyle} />
{!allDay && (
setStartTime(e.target.value)} style={inputStyle} />
)}
setEndDate(e.target.value)} style={inputStyle} />
{!allDay && (
setEndTime(e.target.value)} style={inputStyle} />
)}
{/* ── Wiederholung ───────────────────────────────────────────── */}
{freq !== 'NONE' && (
alle setInterval(e.target.value)} style={{ ...inputStyle, width: 60 }} />
)}
{/* Wöchentlich: an welchen Tagen. Der Starttag ist fest an – eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */} {freq === 'WEEKLY' && (
{weekdayCodes.map((code, i) => { const on = activeDays.includes(code) const fixed = code === startWeekday return ( ) })}
)} {/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */} {(freq === 'MONTHLY' || freq === 'YEARLY') && (
)} {freq !== 'NONE' && (
{endChoice === 'COUNT' && (
setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} /> Mal
)} {endChoice === 'UNTIL' && ( setUntil(e.target.value)} style={{ ...inputStyle, width: 160 }} /> )}
)} {/* ── Farbe ──────────────────────────────────────────────────── */}
setLocation(e.target.value)} style={inputStyle} />