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 }) => ( ); 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 && ( )}
); })}
); }; 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) => ( ))}
{/* 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 }) => ( ))}
); })}
); }; // ---------- 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 */}

{rangeLabel}

{VIEW_BUTTONS.map((button) => ( ))}
{/* Kalender */}
{isLoading && events.length === 0 ? (
) : viewMode === 'month' ? ( ) : ( )} {legendUsers.length > 0 && (
Farben {legendUsers.map((item) => ( ))}
)}
{/* 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)} /> )}
); };