import React, { useEffect, useMemo, useRef, useState } from 'react'; import { EventModal } from './EventModal'; import { EventDetailModal } from './EventDetailModal'; import { UserDetailModal } from './UserDetailModal'; import { LeadModal } from '../leads/LeadModal'; import { ConfirmDialog } from '../ui/Modal'; import { UserAvatar } from '../ui/UserAvatar'; import { MentionText, mentionPlainText } from './mentions'; import { DAY_MS, WEEKDAY_LABELS, addDays, addMonths, compareEvents, formatDayLong, formatEventRange, formatMonthYear, formatTime, isSameDay, monthMatrix, parseSqlDateTime, positionEvents, startOfDay, startOfWeek, weekDays, } from './calendarUtils'; import { useToast } from '../Toast'; import { useAuth } from '../../lib/auth'; import { eventsApi } from '../../lib/api'; import { eventColor, userColor, type AppUser, type CalendarEvent, type CalendarViewMode, type Lead, type LeadConnection, } from '../../types'; interface CalendarProps { events: CalendarEvent[]; leads: Lead[]; users: AppUser[]; connections: LeadConnection[]; isLoading: boolean; reload: () => void; } const VIEW_BUTTONS: { mode: CalendarViewMode; label: string }[] = [ { mode: 'month', label: 'Monat' }, { mode: 'week', label: 'Woche' }, { mode: 'day', label: 'Tag' }, ]; const HOUR_HEIGHT = 48; /** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */ const MONTH_CHIP_LIMIT = 3; // ---------- Bausteine ---------- // Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React // sie bei jedem Render als neuen Typ behandeln und das Zeitraster neu montieren // - die Scrollposition waere danach jedes Mal verloren. const EventChip: React.FC<{ event: CalendarEvent; onOpen: (id: number) => void; showTime?: boolean; }> = ({ event, onOpen, showTime = false }) => ( { clickEvent.stopPropagation(); onOpen(event.id); }} title={`${event.title} · ${formatEventRange(event)}${ event.created_by_name ? ` · ${event.created_by_name}` : '' }`} 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 && ( {formatTime(parseSqlDateTime(event.starts_at))} )} {event.title} ); interface GridProps { today: Date; dayEvents: (day: Date) => CalendarEvent[]; onCreate: (start: Date) => void; onOpenEvent: (id: number) => void; onShowDay: (day: Date) => void; } const MonthView: React.FC = ({ cursor, today, dayEvents, onCreate, onOpenEvent, onShowDay, }) => { const days = monthMatrix(cursor); return ( {WEEKDAY_LABELS.map((label) => ( {label} ))} {days.map((day, index) => { const isCurrentMonth = day.getMonth() === cursor.getMonth(); const isToday = isSameDay(day, today); const list = dayEvents(day); 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); return ( onCreate(createAt)} onKeyDown={(keyEvent) => { if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { keyEvent.preventDefault(); onCreate(createAt); } }} 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'}`} > {day.getDate()} {list.length > 0 && ( {list.length} )} {visible.map((event) => ( ))} {hidden > 0 && ( { clickEvent.stopPropagation(); onShowDay(day); }} className="w-full rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-ink-400 transition hover:bg-ink-100 hover:text-ink-700" > +{hidden} weitere )} ); })} ); }; const TimeGrid: React.FC }> = ({ days, today, dayEvents, onCreate, onOpenEvent, onShowDay, scrollRef, }) => { 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))`; const now = new Date(); const nowPercent = ((now.getTime() - startOfDay(now).getTime()) / DAY_MS) * 100; return ( {/* Kopfzeile mit den Tagen */} {days.map((day) => ( onShowDay(day)} className="border-l border-ink-100 px-2 py-2 text-center transition hover:bg-brand-50/50" > {WEEKDAY_LABELS[(day.getDay() + 6) % 7]} {day.getDate()} ))} {/* Ganztaegige Termine */} {hasAllDay && ( Ganztag {days.map((day, index) => ( {allDayRows[index].map((event) => ( ))} ))} )} {/* Zeitraster */} {Array.from({ length: 24 }, (_, hour) => ( {hour === 0 ? '' : `${String(hour).padStart(2, '0')}:00`} ))} {days.map((day) => { const positioned = positionEvents( dayEvents(day).filter((event) => !event.all_day), day ); return ( {/* Stundenzellen sind gleichzeitig Klickflaeche fuer neue Termine */} {Array.from({ length: 24 }, (_, hour) => ( onCreate(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour)) } title={`Termin um ${String(hour).padStart(2, '0')}:00 anlegen`} className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/40" style={{ height: HOUR_HEIGHT }} /> ))} {isSameDay(day, today) && ( )} {positioned.map(({ event, topPercent, heightPercent, leftPercent, widthPercent }) => ( { 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" style={{ top: `${topPercent}%`, height: `calc(${heightPercent}% - 2px)`, left: `calc(${leftPercent}% + 2px)`, width: `calc(${widthPercent}% - 4px)`, backgroundColor: eventColor(event), borderLeftColor: 'rgba(255, 255, 255, 0.55)', }} > {event.title} {formatEventRange(event)} {event.location && ( {event.location} )} ))} ); })} ); }; // ---------- Hauptansicht ---------- export const Calendar: React.FC = ({ events, leads, users, connections, isLoading, reload, }) => { const { addToast } = useToast(); const { user, isAdmin } = useAuth(); const [viewMode, setViewMode] = useState('month'); const [cursor, setCursor] = useState(() => startOfDay(new Date())); const [createStart, setCreateStart] = useState(null); const [editEvent, setEditEvent] = useState(null); const [detailEventId, setDetailEventId] = useState(null); const [eventToDelete, setEventToDelete] = useState(null); const [detailUserId, setDetailUserId] = useState(null); const [detailLeadId, setDetailLeadId] = useState(null); const timeGridRef = useRef(null); // Das Zeitraster startet am Vormittag - die Nachtstunden interessieren selten. useEffect(() => { if (viewMode === 'month') return; const element = timeGridRef.current; if (element) element.scrollTop = 7 * HOUR_HEIGHT; }, [viewMode]); const today = startOfDay(new Date()); // Deaktivierte Konten sollen nicht mehr auswaehlbar sein - zum Aufloesen // bestehender Mentions bleibt die vollstaendige Liste in Verwendung. const activeUsers = useMemo(() => users.filter((item) => item.is_active), [users]); const eventsByDay = useMemo(() => { const map = new Map(); for (const event of events) { const start = startOfDay(parseSqlDateTime(event.starts_at)); const end = startOfDay(parseSqlDateTime(event.ends_at)); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue; // Mehrtaegige Termine an jedem betroffenen Tag eintragen. Der Deckel bei // 400 Tagen verhindert, dass ein Tippfehler im Jahr die Ansicht sprengt. for (let day = start, guard = 0; day <= end && guard < 400; day = addDays(day, 1), guard++) { const key = day.toDateString(); const list = map.get(key) ?? []; list.push(event); map.set(key, list); } } for (const list of map.values()) list.sort(compareEvents); return map; }, [events]); const dayEvents = useMemo( () => (day: Date) => eventsByDay.get(day.toDateString()) ?? [], [eventsByDay] ); const detailEvent = detailEventId ? events.find((event) => event.id === detailEventId) ?? null : null; const detailUser = detailUserId ? users.find((item) => item.id === detailUserId) ?? null : null; const detailLead = detailLeadId ? leads.find((lead) => lead.id === detailLeadId) ?? null : null; const canModify = (event: CalendarEvent) => isAdmin || event.created_by === user?.id; // Anstehende Termine fuer die Liste rechts: alles, was noch nicht vorbei ist, // der naechste Termin zuerst. const upcoming = useMemo(() => { const now = Date.now(); return events .filter((event) => parseSqlDateTime(event.ends_at).getTime() >= now) .sort( (a, b) => parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime() || a.id - b.id ); }, [events]); const upcomingGroups = useMemo(() => { const groups: { day: Date; items: CalendarEvent[] }[] = []; for (const event of upcoming) { const day = startOfDay(parseSqlDateTime(event.starts_at)); const last = groups[groups.length - 1]; if (last && isSameDay(last.day, day)) last.items.push(event); else groups.push({ day, items: [event] }); } return groups; }, [upcoming]); // Nur Benutzer mit eigenen Terminen kommen in die Legende. const legendUsers = useMemo(() => { const ids = new Set( events.map((event) => event.created_by).filter((id): id is number => id !== null) ); return users.filter((item) => ids.has(item.id)); }, [events, users]); const step = (direction: 1 | -1) => setCursor((current) => { if (viewMode === 'month') return addMonths(current, direction); if (viewMode === 'week') return addDays(current, direction * 7); return addDays(current, direction); }); const rangeLabel = viewMode === 'month' ? formatMonthYear(cursor) : viewMode === 'week' ? (() => { const first = startOfWeek(cursor); const last = addDays(first, 6); return `${first.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', })} – ${last.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', })}`; })() : formatDayLong(cursor); const openCreate = (start: Date) => { setEditEvent(null); setCreateStart(start); }; const showDay = (day: Date) => { setCursor(startOfDay(day)); setViewMode('day'); }; const confirmDelete = async () => { if (!eventToDelete) return; try { await eventsApi.remove(eventToDelete.id); addToast('Termin geloescht.', 'success'); setDetailEventId(null); reload(); } catch (err) { addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error'); } finally { setEventToDelete(null); } }; const gridProps: GridProps = { today, dayEvents, onCreate: openCreate, onOpenEvent: setDetailEventId, onShowDay: showDay, }; return ( {/* Steuerleiste */} step(-1)} aria-label="Zurueck" className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700" > setCursor(startOfDay(new Date()))} className="rounded-xl border border-ink-200 bg-surface px-4 py-2 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700" > Heute step(1)} aria-label="Weiter" className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700" > {rangeLabel} {VIEW_BUTTONS.map((button) => ( setViewMode(button.mode)} aria-pressed={viewMode === button.mode} className={`rounded-lg px-3 py-1.5 text-sm font-bold transition ${ viewMode === button.mode ? 'bg-brand-500 text-white' : 'text-ink-500 hover:bg-brand-50 hover:text-brand-700' }`} > {button.label} ))} openCreate(new Date(new Date().setMinutes(0, 0, 0) + 60 * 60 * 1000))} className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600" > Termin hinzufuegen {/* Kalender */} {isLoading && events.length === 0 ? ( ) : viewMode === 'month' ? ( ) : ( )} {legendUsers.length > 0 && ( Farben {legendUsers.map((item) => ( setDetailUserId(item.id)} className="flex items-center gap-2 text-xs font-bold text-ink-600 transition hover:text-brand-700" > {item.name} {item.id === user?.id && (ich)} ))} )} {/* Anstehende Termine */} {/* Dialoge */} {(createStart !== null || editEvent !== null) && ( { setCreateStart(null); setEditEvent(null); }} onSaved={reload} /> )} {detailEvent && editEvent === null && ( { setEditEvent(detailEvent); setDetailEventId(null); }} onDelete={() => setEventToDelete(detailEvent)} onClose={() => setDetailEventId(null)} onOpenLead={setDetailLeadId} onOpenUser={setDetailUserId} /> )} {detailUser && ( setDetailUserId(null)} onOpenLead={(leadId) => { setDetailUserId(null); setDetailLeadId(leadId); }} /> )} {detailLead && ( setDetailLeadId(null)} onSaved={reload} /> )} {eventToDelete && ( setEventToDelete(null)} /> )} ); };
{rangeLabel}