'use client'; import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ClipboardCheck, CalendarCheck, TrendingUp, FileText, BookOpen, Loader2, AlertCircle, Clock, Plus, Pencil, Trash2, Bell, CheckCircle2, Repeat, GraduationCap, Filter, Timer, Printer, } from 'lucide-react'; import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, addWeeks, isSameDay, isSameMonth, isToday as isDateToday, parseISO, addMonths, addDays as dateAddDays, differenceInCalendarDays, isWithinInterval, setHours, setMinutes, } from 'date-fns'; import { de as deLocale, enUS as enLocale } from 'date-fns/locale'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from '@/components/ui/sheet'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, } from '@/components/ui/drawer'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchCalendarEvents, type CalendarEvent, type CalendarEventType, createCalendarEventItem, updateCalendarEventItem, deleteCalendarEventItem, type CalendarEventItemType, type RecurrencePattern, fetchClasses, fetchSubjects, type ClassGroup, type Subject, } from '@/lib/api'; import { toast } from 'sonner'; /* ── Event type visual config ─────────────────────────────────────── */ type TypeConfig = { icon: React.ElementType; labelKey: string; chipBg: string; chipText: string; dotClass: string; ringClass: string; }; const TYPE_CONFIG: Record = { assessment: { icon: ClipboardCheck, labelKey: 'calendar.event_types.assessment', chipBg: 'bg-amber-100 dark:bg-amber-900/30', chipText: 'text-amber-700 dark:text-amber-300', dotClass: 'bg-amber-500', ringClass: 'ring-amber-300 dark:ring-amber-700', }, attendance: { icon: CalendarCheck, labelKey: 'calendar.event_types.attendance', chipBg: 'bg-emerald-100 dark:bg-emerald-900/30', chipText: 'text-emerald-700 dark:text-emerald-300', dotClass: 'bg-emerald-500', ringClass: 'ring-emerald-300 dark:ring-emerald-700', }, progress: { icon: TrendingUp, labelKey: 'calendar.event_types.progress', chipBg: 'bg-teal-100 dark:bg-teal-900/30', chipText: 'text-teal-700 dark:text-teal-300', dotClass: 'bg-teal-500', ringClass: 'ring-teal-300 dark:ring-teal-700', }, report: { icon: FileText, labelKey: 'calendar.event_types.report', chipBg: 'bg-violet-100 dark:bg-violet-900/30', chipText: 'text-violet-700 dark:text-violet-300', dotClass: 'bg-violet-500', ringClass: 'ring-violet-300 dark:ring-violet-700', }, lesson: { icon: BookOpen, labelKey: 'calendar.event_types.lesson', chipBg: 'bg-rose-100 dark:bg-rose-900/30', chipText: 'text-rose-700 dark:text-rose-300', dotClass: 'bg-rose-500', ringClass: 'ring-rose-300 dark:ring-rose-700', }, }; /* Custom event type visual config */ const CUSTOM_TYPE_CONFIG: Record = { assessment: { icon: ClipboardCheck, labelKey: 'calendar.event_types.assessment', chipBg: 'bg-amber-100 dark:bg-amber-900/30', chipText: 'text-amber-700 dark:text-amber-300', dotClass: 'bg-amber-500', }, lesson: { icon: BookOpen, labelKey: 'calendar.event_types.lesson', chipBg: 'bg-rose-100 dark:bg-rose-900/30', chipText: 'text-rose-700 dark:text-rose-300', dotClass: 'bg-rose-500', }, reminder: { icon: Bell, labelKey: 'calendar.reminder', chipBg: 'bg-sky-100 dark:bg-sky-900/30', chipText: 'text-sky-700 dark:text-sky-300', dotClass: 'bg-sky-500', }, exam: { icon: GraduationCap, labelKey: 'calendar.exam_type', chipBg: 'bg-red-100 dark:bg-red-900/30', chipText: 'text-red-700 dark:text-red-300', dotClass: 'bg-red-500', }, }; const ALL_TYPES: CalendarEventType[] = ['assessment', 'attendance', 'progress', 'report', 'lesson']; /* ── helpers ──────────────────────────────────────────────────────── */ function getMonthLabel(date: Date, locale: 'de' | 'en'): string { const localeObj = locale === 'de' ? deLocale : enLocale; return format(date, 'MMMM yyyy', { locale: localeObj }); } function getWeekLabel(date: Date, locale: 'de' | 'en'): string { const localeObj = locale === 'de' ? deLocale : enLocale; const start = startOfWeek(date, { weekStartsOn: 1 }); const end = endOfWeek(date, { weekStartsOn: 1 }); return `${format(start, 'd. MMM', { locale: localeObj })} – ${format(end, 'd. MMM yyyy', { locale: localeObj })}`; } function getWeekdayLabels(locale: 'de' | 'en'): string[] { const localeObj = locale === 'de' ? deLocale : enLocale; const base = startOfWeek(new Date(), { weekStartsOn: 1 }); return Array.from({ length: 7 }, (_, i) => format(dateAddDays(base, i), 'EEEEEE', { locale: localeObj }) ); } function getWeekdayFullLabels(locale: 'de' | 'en'): string[] { const localeObj = locale === 'de' ? deLocale : enLocale; const base = startOfWeek(new Date(), { weekStartsOn: 1 }); return Array.from({ length: 7 }, (_, i) => format(dateAddDays(base, i), 'EEE', { locale: localeObj }) ); } function buildMonthGrid(monthDate: Date): Date[] { const start = startOfWeek(startOfMonth(monthDate), { weekStartsOn: 1 }); const end = endOfWeek(endOfMonth(monthDate), { weekStartsOn: 1 }); const days: Date[] = []; let cursor = start; while (cursor <= end) { days.push(cursor); cursor = dateAddDays(cursor, 1); } return days; } function buildWeekGrid(weekDate: Date): Date[] { const start = startOfWeek(weekDate, { weekStartsOn: 1 }); return Array.from({ length: 7 }, (_, i) => dateAddDays(start, i)); } function formatDayLong(date: Date, locale: 'de' | 'en'): string { const localeObj = locale === 'de' ? deLocale : enLocale; return format(date, 'EEEE, d. MMMM yyyy', { locale: localeObj }); } /* ── Event Form Data ──────────────────────────────────────────────── */ interface EventFormData { title: string; date: string; startTime: string; endTime: string; eventType: CalendarEventItemType; subjectId: string; classGroupId: string; notes: string; allDay: boolean; recurrenceType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; recurrenceInterval: number; recurrenceEndType: 'never' | 'on_date'; recurrenceEndDate: string; recurrenceDaysOfWeek: number[]; editMode: 'series' | 'instance'; } const EMPTY_FORM: EventFormData = { title: '', date: format(new Date(), 'yyyy-MM-dd'), startTime: '08:00', endTime: '09:00', eventType: 'reminder', subjectId: '', classGroupId: '', notes: '', allDay: false, recurrenceType: 'none', recurrenceInterval: 1, recurrenceEndType: 'never', recurrenceEndDate: '', recurrenceDaysOfWeek: [], editMode: 'instance', }; /* ── Sub-components ───────────────────────────────────────────────── */ function EventDetailContent({ event, onEdit, onDelete }: { event: CalendarEvent; onEdit?: () => void; onDelete?: () => void }) { const cfg = TYPE_CONFIG[event.type]; const Icon = cfg.icon; const meta = event.meta as Record; const isCustomEvent = meta.customEvent === true; const rows: Array<{ key: string; value: string | number | null | undefined }> = []; if (typeof meta.subject === 'string' && meta.subject) rows.push({ key: 'calendar.meta.subject', value: meta.subject }); if (typeof meta.classGroup === 'string' && meta.classGroup) rows.push({ key: 'calendar.meta.class', value: meta.classGroup }); if (typeof meta.assessmentType === 'string' && meta.assessmentType) rows.push({ key: 'calendar.meta.type', value: meta.assessmentType }); if (typeof meta.period === 'string' && meta.period) rows.push({ key: 'calendar.meta.period', value: meta.period }); if (typeof meta.status === 'string' && meta.status) rows.push({ key: 'calendar.meta.status', value: meta.status }); if (typeof meta.studentName === 'string' && meta.studentName) rows.push({ key: 'calendar.meta.student', value: meta.studentName }); if (typeof meta.weight === 'number') rows.push({ key: 'calendar.meta.weight', value: meta.weight }); if (typeof meta.maxScore === 'number') rows.push({ key: 'calendar.meta.max_score', value: meta.maxScore }); if (typeof meta.includesGrades === 'boolean') rows.push({ key: 'calendar.meta.includes_grades', value: meta.includesGrades ? 'Ja' : 'Nein' }); if (typeof meta.count === 'number') rows.push({ key: 'calendar.meta.count', value: meta.count }); // Custom event specific fields if (isCustomEvent) { if (typeof meta.eventType === 'string') rows.push({ key: 'calendar.event_type', value: meta.eventType as string }); if (typeof meta.startTime === 'string' && meta.startTime) rows.push({ key: 'calendar.start_time', value: meta.startTime as string }); if (typeof meta.endTime === 'string' && meta.endTime) rows.push({ key: 'calendar.end_time', value: meta.endTime as string }); if (typeof meta.allDay === 'boolean' && meta.allDay) rows.push({ key: 'calendar.all_day', value: 'Ja' }); if (typeof meta.notes === 'string' && meta.notes) rows.push({ key: 'calendar.notes', value: meta.notes as string }); } // Attendance rates if (typeof meta.rate === 'number') rows.push({ key: 'calendar.meta.rate', value: `${meta.rate}%` }); if (typeof meta.present === 'number') rows.push({ key: 'calendar.meta.present', value: meta.present }); if (typeof meta.absent === 'number') rows.push({ key: 'calendar.meta.absent', value: meta.absent }); if (typeof meta.excused === 'number') rows.push({ key: 'calendar.meta.excused', value: meta.excused }); if (typeof meta.late === 'number') rows.push({ key: 'calendar.meta.late', value: meta.late }); return (

{event.title}

{t(cfg.labelKey)}

{rows.length > 0 && (
{rows.map((row, idx) => (
{t(row.key)}
{String(row.value)}
))}
)} {event.type === 'progress' && Array.isArray(meta.classGroups) && meta.classGroups.length > 0 && (
{(meta.classGroups as string[]).map((g) => ( {g} ))}
)} {isCustomEvent && (onEdit || onDelete) && (
{onEdit && ( )} {onDelete && ( )}
)} {/* Recurring indicator */} {isCustomEvent && typeof meta.recurrencePattern === 'string' && meta.recurrencePattern && (
{t('calendar.recurring')}
)}
); } /* ── Mini Calendar Widget ─────────────────────────────────────────── */ function MiniCalendar({ monthDate, eventsByDay, onDateSelect, onMonthChange, localeCode, }: { monthDate: Date; eventsByDay: Map; onDateSelect: (date: Date) => void; onMonthChange: (date: Date) => void; localeCode: 'de' | 'en'; }) { const today = new Date(); const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]); const weekdayLabels = useMemo(() => getWeekdayLabels(localeCode), [localeCode]); return (
{getMonthLabel(monthDate, localeCode)}
{weekdayLabels.map((label) => (
{label}
))}
{days.map((day, idx) => { const dayKey = format(day, 'yyyy-MM-dd'); const dayEvents = eventsByDay.get(dayKey) ?? []; const inMonth = isSameMonth(day, monthDate); const isToday = isDateToday(day); return ( ); })}
); } /* ── Week View ────────────────────────────────────────────────────── */ function WeekView({ weekDate, events, eventsByDay, onEventClick, onDayHeaderClick, onPrevWeek, onNextWeek, localeCode, }: { weekDate: Date; events: CalendarEvent[]; eventsByDay: Map; onEventClick: (event: CalendarEvent) => void; onDayHeaderClick: (day: Date) => void; onPrevWeek: () => void; onNextWeek: () => void; localeCode: 'de' | 'en'; }) { const days = useMemo(() => buildWeekGrid(weekDate), [weekDate]); const weekdayLabels = useMemo(() => getWeekdayFullLabels(localeCode), [localeCode]); const hours = useMemo(() => Array.from({ length: 11 }, (_, i) => i + 7), []); // 7am - 5pm const scrollRef = useRef(null); // Scroll to 8am on mount useEffect(() => { if (scrollRef.current) { const hourHeight = 48; scrollRef.current.scrollTop = hourHeight * 1; // 8am } }, []); return (
{getWeekLabel(weekDate, localeCode)}
{/* Time gutter */}
{hours.map((hour) => (
{String(hour).padStart(2, '0')}:00
))}
{/* Day columns */}
{/* Day headers */}
{days.map((day, idx) => { const isToday = isDateToday(day); const dayKey = format(day, 'yyyy-MM-dd'); const dayEvents = eventsByDay.get(dayKey) ?? []; return ( ); })}
{/* Time grid */}
{days.map((day, idx) => { const dayKey = format(day, 'yyyy-MM-dd'); const dayEvents = eventsByDay.get(dayKey) ?? []; return (
{hours.map((hour) => (
))} {/* Place events in their time slots */} {dayEvents.map((ev) => { const meta = ev.meta as Record; const cfg = TYPE_CONFIG[ev.type]; const Icon = cfg.icon; // Calculate position based on start time let topOffset = 0; let eventHeight = 44; const startTime = typeof meta.startTime === 'string' ? meta.startTime : null; const endTime = typeof meta.endTime === 'string' ? meta.endTime : null; if (startTime) { const [startH, startM] = startTime.split(':').map(Number); const startMinutes = startH * 60 + startM; const gridStartMinutes = 7 * 60; // 7am topOffset = Math.max(0, ((startMinutes - gridStartMinutes) / 60) * 48); if (endTime) { const [endH, endM] = endTime.split(':').map(Number); const endMinutes = endH * 60 + endM; const duration = Math.max(15, endMinutes - startMinutes); eventHeight = Math.max(20, (duration / 60) * 48); } } else { // All-day or no time, place at top topOffset = 4; eventHeight = 28; } return ( ); })}
); })}
{events.length === 0 && (

{t('calendar.no_events')}

)} ); } /* ── Main component ───────────────────────────────────────────────── */ export default function CalendarView() { const currentUser = useAppStore((s) => s.currentUser); const locale = useAppStore((s) => s.locale); const localeCode = (locale === 'en' ? 'en' : 'de') as 'de' | 'en'; // Month state const [monthDate, setMonthDate] = useState(() => startOfMonth(new Date())); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // View mode const [viewMode, setViewMode] = useState<'month' | 'week'>('month'); const [weekDate, setWeekDate] = useState(() => new Date()); // Selected day (opens sheet) const [selectedDay, setSelectedDay] = useState(null); // Selected event (opens detail dialog) const [selectedEvent, setSelectedEvent] = useState(null); // Event form dialog const [showEventForm, setShowEventForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); const [formData, setFormData] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); // Delete confirmation const [deleteConfirmEvent, setDeleteConfirmEvent] = useState(null); // Edit mode dialog for recurring events const [showEditModeDialog, setShowEditModeDialog] = useState(false); const [pendingEditEvent, setPendingEditEvent] = useState(null); // Classes and subjects for form const [classes, setClasses] = useState([]); const [subjects, setSubjects] = useState([]); // Exam filter and exam events const [showExamsOnly, setShowExamsOnly] = useState(false); const [examEvents, setExamEvents] = useState([]); const [showExamForm, setShowExamForm] = useState(false); const [examFormData, setExamFormData] = useState({ title: '', date: format(new Date(), 'yyyy-MM-dd'), startTime: '08:00', endTime: '09:30', subjectId: '', classGroupId: '', notes: '', }); const [savingExam, setSavingExam] = useState(false); // Detect mobile const [isMobile, setIsMobile] = useState(false); useEffect(() => { const mql = window.matchMedia('(max-width: 768px)'); const update = () => setIsMobile(mql.matches); update(); mql.addEventListener('change', update); return () => mql.removeEventListener('change', update); }, []); // Fetch events when month or school changes const monthKey = useMemo(() => format(monthDate, 'yyyy-MM'), [monthDate]); const loadEvents = useCallback(async () => { if (!currentUser?.schoolId) return; setLoading(true); setError(null); try { const res = await fetchCalendarEvents(currentUser.schoolId, monthKey); setEvents(res.events ?? []); } catch (err) { console.error('Calendar fetch failed:', err); setError(t('calendar.error')); toast.error(t('calendar.error')); } finally { setLoading(false); } }, [currentUser?.schoolId, monthKey]); useEffect(() => { loadEvents(); }, [loadEvents]); // Load exam events const loadExamEvents = useCallback(async () => { if (!currentUser?.schoolId) return; try { const res = await fetch(`/api/calendar-events/exams?schoolId=${currentUser.schoolId}&limit=50`); if (res.ok) { const data = await res.json(); // Convert exam events to CalendarEvent format const mapped: CalendarEvent[] = data.map((exam: Record) => ({ id: exam.id as string, date: format(new Date(exam.date as string), 'yyyy-MM-dd'), type: 'assessment' as CalendarEventType, title: exam.title as string, meta: { customEvent: true, eventType: 'exam', startTime: exam.startTime, endTime: exam.endTime, subject: (exam.subject as Record)?.name ?? '', classGroup: (exam.classGroup as Record)?.name ?? '', notes: exam.notes, daysUntil: exam.daysUntil, countdownLabel: exam.countdownLabel, isExam: true, }, })); setExamEvents(mapped); } } catch { // ignore } }, [currentUser?.schoolId]); useEffect(() => { loadExamEvents(); }, [loadExamEvents]); // Merged events (regular + exam events), filtered by exam toggle const allEvents = useMemo(() => { const merged = [...events, ...examEvents]; // Deduplicate by id const seen = new Set(); return merged.filter((e) => { if (seen.has(e.id)) return false; seen.add(e.id); return true; }); }, [events, examEvents]); const filteredEvents = useMemo(() => { if (!showExamsOnly) return allEvents; return allEvents.filter((e) => { const meta = e.meta as Record; return meta.isExam === true || meta.eventType === 'exam'; }); }, [allEvents, showExamsOnly]); // Load classes and subjects for form useEffect(() => { async function load() { try { const [cls, subjs] = await Promise.all([ fetchClasses(currentUser?.schoolId ?? undefined), fetchSubjects(currentUser?.schoolId ?? undefined), ]); setClasses(cls); setSubjects(subjs); } catch { // ignore } } if (currentUser?.schoolId) load(); }, [currentUser?.schoolId]); // Group events by YYYY-MM-DD const eventsByDay = useMemo(() => { const map = new Map(); for (const e of filteredEvents) { const arr = map.get(e.date) ?? []; arr.push(e); map.set(e.date, arr); } return map; }, [filteredEvents]); // Upcoming events const upcomingEvents = useMemo(() => { const today = new Date(); today.setHours(0, 0, 0, 0); const weekEnd = dateAddDays(today, 7); return filteredEvents .filter((e) => { const d = parseISO(e.date); return (d >= today || isSameDay(d, today)) && d <= weekEnd; }) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 5); }, [filteredEvents]); // Upcoming exams const upcomingExams = useMemo(() => { const today = new Date(); today.setHours(0, 0, 0, 0); return filteredEvents .filter((e) => { const meta = e.meta as Record; const d = parseISO(e.date); return (meta.isExam === true || meta.eventType === 'exam') && d >= today; }) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 5); }, [filteredEvents]); // Build month grid const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]); const weekdayLabels = useMemo(() => getWeekdayLabels(localeCode), [localeCode]); const handlePrev = useCallback(() => { if (viewMode === 'month') setMonthDate((d) => addMonths(d, -1)); else setWeekDate((d) => addWeeks(d, -1)); }, [viewMode]); const handleNext = useCallback(() => { if (viewMode === 'month') setMonthDate((d) => addMonths(d, 1)); else setWeekDate((d) => addWeeks(d, 1)); }, [viewMode]); const handleToday = useCallback(() => { if (viewMode === 'month') setMonthDate(startOfMonth(new Date())); else setWeekDate(new Date()); }, [viewMode]); const selectedDayEvents = useMemo(() => { if (!selectedDay) return []; const key = format(selectedDay, 'yyyy-MM-dd'); return eventsByDay.get(key) ?? []; }, [selectedDay, eventsByDay]); // Event form handlers const handleNewEvent = useCallback((date?: string) => { setEditingEvent(null); setFormData({ ...EMPTY_FORM, date: date ?? format(new Date(), 'yyyy-MM-dd'), }); setShowEventForm(true); }, []); const handleEditEvent = useCallback((event: CalendarEvent) => { const meta = event.meta as Record; const isRecurring = typeof meta.recurrencePattern === 'string' && meta.recurrencePattern; const isChild = typeof meta.parentEventId === 'string' && meta.parentEventId; if (isRecurring || isChild) { setPendingEditEvent(event); setShowEditModeDialog(true); return; } setEditingEvent(event); setFormData({ title: event.title, date: event.date, startTime: (typeof meta.startTime === 'string' && meta.startTime) ? meta.startTime : '08:00', endTime: (typeof meta.endTime === 'string' && meta.endTime) ? meta.endTime : '09:00', eventType: (typeof meta.eventType === 'string' ? meta.eventType : 'reminder') as CalendarEventItemType, subjectId: '', classGroupId: '', notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '', allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false, recurrenceType: 'none', recurrenceInterval: 1, recurrenceEndType: 'never', recurrenceEndDate: '', recurrenceDaysOfWeek: [], editMode: 'instance', }); setShowEventForm(true); setSelectedEvent(null); }, []); const handleEditEventWithMode = useCallback((event: CalendarEvent, mode: 'series' | 'instance') => { const meta = event.meta as Record; let parsedRecurrence: RecurrencePattern | null = null; if (typeof meta.recurrencePattern === 'string' && meta.recurrencePattern) { try { parsedRecurrence = JSON.parse(meta.recurrencePattern as string); } catch { /* ignore */ } } setEditingEvent(event); setFormData({ title: event.title, date: event.date, startTime: (typeof meta.startTime === 'string' && meta.startTime) ? meta.startTime : '08:00', endTime: (typeof meta.endTime === 'string' && meta.endTime) ? meta.endTime : '09:00', eventType: (typeof meta.eventType === 'string' ? meta.eventType : 'reminder') as CalendarEventItemType, subjectId: '', classGroupId: '', notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '', allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false, recurrenceType: parsedRecurrence?.type ?? 'none', recurrenceInterval: parsedRecurrence?.interval ?? 1, recurrenceEndType: parsedRecurrence?.endDate ? 'on_date' : 'never', recurrenceEndDate: parsedRecurrence?.endDate ?? '', recurrenceDaysOfWeek: parsedRecurrence?.daysOfWeek ?? [], editMode: mode, }); setShowEventForm(true); setSelectedEvent(null); setShowEditModeDialog(false); setPendingEditEvent(null); }, []); const handleDeleteEvent = useCallback(async () => { if (!deleteConfirmEvent) return; try { await deleteCalendarEventItem(deleteConfirmEvent.id); toast.success(t('calendar.event_deleted')); loadEvents(); } catch { toast.error(t('calendar.event_delete_error')); } setDeleteConfirmEvent(null); setSelectedEvent(null); }, [deleteConfirmEvent, loadEvents]); const handleSaveEvent = useCallback(async () => { if (!currentUser?.schoolId || !formData.title.trim()) return; setSaving(true); try { const recurrencePattern: RecurrencePattern | null = formData.recurrenceType !== 'none' ? { type: formData.recurrenceType, interval: formData.recurrenceInterval, ...(formData.recurrenceEndType === 'on_date' && formData.recurrenceEndDate ? { endDate: formData.recurrenceEndDate } : {}), ...(formData.recurrenceType === 'weekly' && formData.recurrenceDaysOfWeek.length > 0 ? { daysOfWeek: formData.recurrenceDaysOfWeek } : {}), } : null; const recurrenceEnd = formData.recurrenceEndType === 'on_date' && formData.recurrenceEndDate ? formData.recurrenceEndDate : null; if (editingEvent) { await updateCalendarEventItem(editingEvent.id, { title: formData.title, date: formData.date, startTime: formData.allDay ? null : formData.startTime, endTime: formData.allDay ? null : formData.endTime, eventType: formData.eventType, subjectId: formData.subjectId || null, classGroupId: formData.classGroupId || null, notes: formData.notes || null, allDay: formData.allDay, recurrencePattern, recurrenceEnd, editMode: formData.editMode, }); toast.success(t('calendar.event_updated')); } else { await createCalendarEventItem({ schoolId: currentUser.schoolId, title: formData.title, date: formData.date, startTime: formData.allDay ? null : formData.startTime, endTime: formData.allDay ? null : formData.endTime, eventType: formData.eventType, subjectId: formData.subjectId || null, classGroupId: formData.classGroupId || null, notes: formData.notes || null, allDay: formData.allDay, recurrencePattern, recurrenceEnd, }); toast.success(t('calendar.event_created')); } setShowEventForm(false); setEditingEvent(null); loadEvents(); } catch { toast.error(editingEvent ? t('calendar.event_update_error') : t('calendar.event_create_error')); } finally { setSaving(false); } }, [currentUser?.schoolId, formData, editingEvent, loadEvents]); return (
{/* Header */}

{t('calendar.title')}

{t('calendar.subtitle')}

{/* View mode toggle */}
{viewMode === 'month' ? getMonthLabel(monthDate, localeCode) : getWeekLabel(weekDate, localeCode)}
{/* New Event button */} {/* Plan Exam button (teachers only) */} {(currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL' || currentUser?.role === 'SUPER_ADMIN') && ( )} {/* Exam filter toggle */} {/* Print Calendar button */}
{/* Legend */} {t('calendar.legend')}: {ALL_TYPES.map((type) => { const cfg = TYPE_CONFIG[type]; const Icon = cfg.icon; return ( {t(cfg.labelKey)} ); })} {/* Reminder type */} {t('calendar.reminder')} {/* Exam type */} {t('calendar.exam_badge')} {/* Main content area - sidebar + calendar */}
{/* Sidebar with mini calendar */} { setMonthDate(startOfMonth(date)); setWeekDate(date); if (eventsByDay.get(format(date, 'yyyy-MM-dd'))?.length) { setSelectedDay(date); } }} onMonthChange={(date) => setMonthDate(date)} localeCode={localeCode} /> {/* Upcoming this week */} {t('calendar.upcoming_week')} {upcomingEvents.length === 0 ? (

{t('calendar.no_upcoming')}

) : (
    {upcomingEvents.map((ev, idx) => { const cfg = TYPE_CONFIG[ev.type]; const Icon = cfg.icon; const evDate = parseISO(ev.date); const relativeDays = differenceInCalendarDays(evDate, new Date()); const relativeLabel = relativeDays === 0 ? localeCode === 'de' ? 'Heute' : 'Today' : relativeDays === 1 ? localeCode === 'de' ? 'Morgen' : 'Tomorrow' : format(evDate, 'EEE, d. MMM', { locale: localeCode === 'de' ? deLocale : enLocale, }); return ( ); })}
)}
{/* Upcoming Exams */} {t('calendar.upcoming_exams')} {upcomingExams.length === 0 ? (

{t('calendar.no_exams')}

) : (
    {upcomingExams.map((ev, idx) => { const meta = ev.meta as Record; const evDate = parseISO(ev.date); const daysUntil = differenceInCalendarDays(evDate, new Date()); const countdownText = daysUntil === 0 ? t('calendar.today') : daysUntil === 1 ? t('calendar.tomorrow') : daysUntil < 7 ? t('calendar.in_days', { days: daysUntil }) : t('calendar.in_weeks', { weeks: Math.floor(daysUntil / 7) }); return ( ); })}
)}
{/* Calendar grid / Week view */} {viewMode === 'week' ? ( setSelectedEvent(ev)} onDayHeaderClick={(day) => setSelectedDay(day)} onPrevWeek={() => setWeekDate((d) => addWeeks(d, -1))} onNextWeek={() => setWeekDate((d) => addWeeks(d, 1))} localeCode={localeCode} /> ) : ( {/* Weekday header */}
{weekdayLabels.map((label) => (
{label}
))}
{/* Day cells */}
{days.map((day, idx) => { const dayKey = format(day, 'yyyy-MM-dd'); const dayEvents = eventsByDay.get(dayKey) ?? []; const inMonth = isSameMonth(day, monthDate); const isToday = isDateToday(day); const maxVisible = 3; const visibleEvents = dayEvents.slice(0, maxVisible); const overflow = dayEvents.length - maxVisible; return ( dayEvents.length > 0 && setSelectedDay(day)} className={`relative min-h-[80px] p-1 rounded-md border transition-all ${ inMonth ? 'bg-white dark:bg-gray-900/40 border-gray-100 dark:border-gray-800' : 'bg-gray-50/60 dark:bg-gray-900/20 border-transparent' } ${ dayEvents.length > 0 && inMonth ? 'cursor-pointer hover:shadow-md hover:border-emerald-200 dark:hover:border-emerald-800/60 hover:-translate-y-0.5' : '' } ${isToday ? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-white dark:ring-offset-gray-950' : ''}`} > {/* Day number */}
{format(day, 'd')} {dayEvents.length > 0 && inMonth && ( {dayEvents.length} )}
{/* Empty cell placeholder */} {inMonth && dayEvents.length === 0 && ( )} {/* Event chips */}
{visibleEvents.map((ev) => { const cfg = TYPE_CONFIG[ev.type]; const Icon = cfg.icon; return ( ); })} {overflow > 0 && ( )}
); })}
{/* Empty + Loading + Error states */} {loading && (
{t('calendar.loading')}
)} {error && !loading && (
{error}
)} {!loading && !error && events.length === 0 && (

{t('calendar.no_events')}

{t('calendar.no_events_desc')}

)}
)}
{/* Day Events Sheet (desktop) / Drawer (mobile) */} {isMobile ? ( !open && setSelectedDay(null)} > {selectedDay ? formatDayLong(selectedDay, localeCode) : ''} {t('calendar.day_events', { date: selectedDay ? formatDayLong(selectedDay, localeCode) : '' })}
{selectedDayEvents.length === 0 ? (

{t('calendar.no_day_events')}

) : (
    {selectedDayEvents.map((ev) => { const cfg = TYPE_CONFIG[ev.type]; const Icon = cfg.icon; return (
  • ); })}
)}
) : ( !open && setSelectedDay(null)} > {selectedDay ? formatDayLong(selectedDay, localeCode) : ''} {t('calendar.day_events', { date: selectedDay ? format(selectedDay, 'yyyy-MM-dd') : '', })}
{selectedDayEvents.length === 0 ? (

{t('calendar.no_day_events')}

) : (
    {selectedDayEvents.map((ev) => { const cfg = TYPE_CONFIG[ev.type]; const Icon = cfg.icon; return (
  • ); })}
)}
)} {/* Event detail dialog */} !open && setSelectedEvent(null)} > {selectedEvent ? formatDayLong(parseISO(selectedEvent.date), localeCode) : ''} {selectedEvent ? t('calendar.day_events', { date: selectedEvent.date }) : ''} {selectedEvent ? (
)?.customEvent ? () => handleEditEvent(selectedEvent) : undefined} onDelete={(selectedEvent.meta as Record)?.customEvent ? () => setDeleteConfirmEvent(selectedEvent) : undefined} />
) : null}
{/* Event form dialog */} { if (!open) { setShowEventForm(false); setEditingEvent(null); } }} > {editingEvent ? t('calendar.edit_event') : t('calendar.new_event')} {editingEvent ? t('calendar.edit_event') : t('calendar.new_event')}
{/* Title */}
setFormData((f) => ({ ...f, title: e.target.value }))} placeholder={t('calendar.new_event')} className="min-h-[44px]" />
{/* Date */}
setFormData((f) => ({ ...f, date: e.target.value }))} className="min-h-[44px]" />
{/* All day toggle */}
setFormData((f) => ({ ...f, allDay: checked }))} />
{/* Start/End time (if not all day) */} {!formData.allDay && (
setFormData((f) => ({ ...f, startTime: e.target.value }))} className="min-h-[44px]" />
setFormData((f) => ({ ...f, endTime: e.target.value }))} className="min-h-[44px]" />
)} {/* Event type */}
{/* Subject */}
{/* Class */}
{/* Notes */}