import { useState, useMemo, useCallback } from 'react'; import { Box, Typography, IconButton, ButtonGroup, Button, Popover, List, ListItem, ListItemText, Chip, Tooltip, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Snackbar, Alert, Skeleton, Divider, useTheme, useMediaQuery, } from '@mui/material'; import { ChevronLeft, ChevronRight, Today as TodayIcon, CalendarViewMonth as CalendarIcon, ViewList as ListViewIcon, Star as StarIcon, ContentCopy as CopyIcon, CheckCircle as CheckIcon, Cancel as CancelIcon, HelpOutline as UnknownIcon, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { trainingApi } from '../services/training'; import type { UebungListItem, UebungTyp, TeilnahmeStatus } from '../types/training.types'; // --------------------------------------------------------------------------- // Constants & helpers // --------------------------------------------------------------------------- const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const MONTH_LABELS = [ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; const TYP_DOT_COLOR: Record = { 'Übungsabend': '#1976d2', // blue 'Lehrgang': '#7b1fa2', // purple 'Sonderdienst': '#e65100', // orange 'Versammlung': '#616161', // gray 'Gemeinschaftsübung': '#00796b', // teal 'Sonstiges': '#9e9e9e', // light gray }; const TYP_CHIP_COLOR: Record< UebungTyp, 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success' > = { 'Übungsabend': 'primary', 'Lehrgang': 'secondary', 'Sonderdienst': 'warning', 'Versammlung': 'default', 'Gemeinschaftsübung': 'info', 'Sonstiges': 'default', }; function startOfDay(d: Date): Date { const c = new Date(d); c.setHours(0, 0, 0, 0); return c; } function sameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } /** Returns calendar grid cells for the month view — always 6×7 (42 cells) */ function buildMonthGrid(year: number, month: number): Date[] { // month is 0-indexed const firstDay = new Date(year, month, 1); // ISO week starts Monday; getDay() returns 0=Sun → convert to Mon=0 const dayOfWeek = (firstDay.getDay() + 6) % 7; const start = new Date(firstDay); start.setDate(start.getDate() - dayOfWeek); const cells: Date[] = []; for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); cells.push(d); } return cells; } function formatTime(isoString: string): string { const d = new Date(isoString); const h = String(d.getHours()).padStart(2, '0'); const m = String(d.getMinutes()).padStart(2, '0'); return `${h}:${m}`; } function formatDateLong(isoString: string): string { const d = new Date(isoString); const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`; } // --------------------------------------------------------------------------- // RSVP indicator // --------------------------------------------------------------------------- function RsvpDot({ status }: { status: TeilnahmeStatus | undefined }) { if (!status || status === 'unbekannt') return ; if (status === 'zugesagt' || status === 'erschienen') return ; return ; } // --------------------------------------------------------------------------- // iCal Subscribe Dialog // --------------------------------------------------------------------------- interface IcalDialogProps { open: boolean; onClose: () => void; } function IcalDialog({ open, onClose }: IcalDialogProps) { const [snackOpen, setSnackOpen] = useState(false); const [subscribeUrl, setSubscribeUrl] = useState(null); const [loading, setLoading] = useState(false); const handleOpen = async () => { if (subscribeUrl) return; setLoading(true); try { const { subscribeUrl: url } = await trainingApi.getCalendarToken(); setSubscribeUrl(url); } catch (_) { setSubscribeUrl(null); } finally { setLoading(false); } }; const handleCopy = async () => { if (!subscribeUrl) return; await navigator.clipboard.writeText(subscribeUrl); setSnackOpen(true); }; return ( <> Kalender abonnieren Kopiere die URL und füge sie in deiner Kalender-App unter "Kalender abonnieren" ein. Der Kalender wird automatisch aktualisiert, sobald neue Dienste eingetragen werden. {loading && } {!loading && subscribeUrl && ( {subscribeUrl} )} Apple Kalender: Ablage → Neues Kalenderabonnement
Google Kalender: Andere Kalender → Per URL
Thunderbird: Neu → Kalender → Im Netzwerk
{subscribeUrl && ( )}
setSnackOpen(false)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackOpen(false)}> URL kopiert! ); } // --------------------------------------------------------------------------- // Month Calendar Grid // --------------------------------------------------------------------------- interface MonthCalendarProps { year: number; month: number; events: UebungListItem[]; onDayClick: (day: Date, anchor: Element) => void; } function MonthCalendar({ year, month, events, onDayClick }: MonthCalendarProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const today = startOfDay(new Date()); const cells = useMemo(() => buildMonthGrid(year, month), [year, month]); // Build a map: "YYYY-MM-DD" → events const eventsByDay = useMemo(() => { const map = new Map(); for (const ev of events) { const d = startOfDay(new Date(ev.datum_von)); const key = d.toISOString().slice(0, 10); const arr = map.get(key) ?? []; arr.push(ev); map.set(key, arr); } return map; }, [events]); return ( {/* Weekday headers */} {WEEKDAY_LABELS.map((wd) => ( {wd} ))} {/* Day cells — 6 rows × 7 cols */} {cells.map((cell, idx) => { const isCurrentMonth = cell.getMonth() === month; const isToday = sameDay(cell, today); const key = cell.toISOString().slice(0, 10); const dayEvents = eventsByDay.get(key) ?? []; const hasEvents = dayEvents.length > 0; return ( hasEvents && onDayClick(cell, e.currentTarget)} sx={{ minHeight: isMobile ? 44 : 72, borderRadius: 1, p: '4px', cursor: hasEvents ? 'pointer' : 'default', bgcolor: isToday ? 'primary.main' : isCurrentMonth ? 'background.paper' : 'action.disabledBackground', border: '1px solid', borderColor: isToday ? 'primary.dark' : 'divider', transition: 'background 0.1s', '&:hover': hasEvents ? { bgcolor: isToday ? 'primary.dark' : 'action.hover' } : {}, display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden', }} > {cell.getDate()} {/* Event dots — max 3 visible on mobile */} {hasEvents && ( {dayEvents.slice(0, isMobile ? 3 : 5).map((ev, i) => ( ))} {dayEvents.length > (isMobile ? 3 : 5) && ( +{dayEvents.length - (isMobile ? 3 : 5)} )} )} {/* On desktop: show short event titles */} {!isMobile && hasEvents && ( {dayEvents.slice(0, 2).map((ev, i) => ( {ev.pflichtveranstaltung && '* '}{ev.titel} ))} )} ); })} {/* Legend */} {Object.entries(TYP_DOT_COLOR).map(([typ, color]) => ( {typ} ))} Pflichtveranstaltung ); } // --------------------------------------------------------------------------- // List View // --------------------------------------------------------------------------- function ListView({ events, onEventClick, }: { events: UebungListItem[]; onEventClick: (id: string) => void; }) { return ( {events.map((ev, idx) => ( {idx > 0 && } onEventClick(ev.id)} sx={{ cursor: 'pointer', px: 1, py: 1, borderRadius: 1, opacity: ev.abgesagt ? 0.55 : 1, '&:hover': { bgcolor: 'action.hover' }, }} > {/* Date badge */} {new Date(ev.datum_von).getDate()}. {new Date(ev.datum_von).getMonth() + 1}. {formatTime(ev.datum_von)} {ev.pflichtveranstaltung && ( )} {ev.titel} {ev.abgesagt && ( )} } secondary={ {ev.ort && ( {ev.ort} )} } sx={{ my: 0 }} /> {/* RSVP badge */} ))} {events.length === 0 && ( Keine Veranstaltungen in diesem Monat. )} ); } // --------------------------------------------------------------------------- // Day Popover // --------------------------------------------------------------------------- interface DayPopoverProps { anchorEl: Element | null; day: Date | null; events: UebungListItem[]; onClose: () => void; onEventClick: (id: string) => void; } function DayPopover({ anchorEl, day, events, onClose, onEventClick }: DayPopoverProps) { if (!day) return null; return ( {formatDateLong(day.toISOString())} {events.map((ev) => ( { onEventClick(ev.id); onClose(); }} sx={{ cursor: 'pointer', borderRadius: 1, px: 0.75, '&:hover': { bgcolor: 'action.hover' }, opacity: ev.abgesagt ? 0.6 : 1, }} > {ev.pflichtveranstaltung && } {ev.titel} } secondary={`${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr`} /> ))} ); } // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- export default function Kalender() { const navigate = useNavigate(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const today = new Date(); const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() }); const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar'); const [icalOpen, setIcalOpen] = useState(false); // Popover state const [popoverAnchor, setPopoverAnchor] = useState(null); const [popoverDay, setPopoverDay] = useState(null); const [popoverEvents, setPopoverEvents] = useState([]); // Compute fetch range: whole month ± 1 week buffer for grid const { from, to } = useMemo(() => { const firstCell = new Date(viewMonth.year, viewMonth.month, 1); const dayOfWeek = (firstCell.getDay() + 6) % 7; const f = new Date(firstCell); f.setDate(f.getDate() - dayOfWeek); const lastCell = new Date(f); lastCell.setDate(lastCell.getDate() + 41); return { from: f, to: lastCell }; }, [viewMonth]); const { data, isLoading } = useQuery({ queryKey: ['training', 'calendar', from.toISOString(), to.toISOString()], queryFn: () => trainingApi.getCalendarRange(from, to), staleTime: 5 * 60 * 1000, }); const events = useMemo(() => data ?? [], [data]); const handlePrev = () => { setViewMonth((prev) => { const m = prev.month === 0 ? 11 : prev.month - 1; const y = prev.month === 0 ? prev.year - 1 : prev.year; return { year: y, month: m }; }); }; const handleNext = () => { setViewMonth((prev) => { const m = prev.month === 11 ? 0 : prev.month + 1; const y = prev.month === 11 ? prev.year + 1 : prev.year; return { year: y, month: m }; }); }; const handleToday = () => { setViewMonth({ year: today.getFullYear(), month: today.getMonth() }); }; const handleDayClick = useCallback((day: Date, anchor: Element) => { const key = day.toISOString().slice(0, 10); const dayEvs = events.filter( (ev) => startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10) === key ); setPopoverDay(day); setPopoverAnchor(anchor); setPopoverEvents(dayEvs); }, [events]); return ( {/* Page header */} Dienstkalender {/* View toggle */} {/* Month navigation */} {MONTH_LABELS[viewMonth.month]} {viewMonth.year} {/* Calendar / List body */} {isLoading ? ( ) : viewMode === 'calendar' ? ( ) : ( { const d = new Date(ev.datum_von); return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year; })} onEventClick={(id) => navigate(`/training/${id}`)} /> )} {/* Day Popover */} setPopoverAnchor(null)} onEventClick={(id) => navigate(`/training/${id}`)} /> {/* iCal Subscribe Dialog */} setIcalOpen(false)} /> ); }