Files
dashboard/frontend/src/pages/Kalender.tsx
Matthias Hochmeister 620bacc6b5 add features
2026-02-27 19:50:14 +01:00

805 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<UebungTyp, string> = {
'Ü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 <UnknownIcon sx={{ fontSize: 14, color: 'text.disabled' }} />;
if (status === 'zugesagt' || status === 'erschienen') return <CheckIcon sx={{ fontSize: 14, color: 'success.main' }} />;
return <CancelIcon sx={{ fontSize: 14, color: 'error.main' }} />;
}
// ---------------------------------------------------------------------------
// iCal Subscribe Dialog
// ---------------------------------------------------------------------------
interface IcalDialogProps {
open: boolean;
onClose: () => void;
}
function IcalDialog({ open, onClose }: IcalDialogProps) {
const [snackOpen, setSnackOpen] = useState(false);
const [subscribeUrl, setSubscribeUrl] = useState<string | null>(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 (
<>
<Dialog
open={open}
onClose={onClose}
TransitionProps={{ onEnter: handleOpen }}
maxWidth="sm"
fullWidth
>
<DialogTitle>Kalender abonnieren</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
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.
</DialogContentText>
{loading && <Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1 }} />}
{!loading && subscribeUrl && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1.5,
borderRadius: 1,
bgcolor: 'action.hover',
fontFamily: 'monospace',
fontSize: '0.75rem',
wordBreak: 'break-all',
}}
>
<Box sx={{ flexGrow: 1, userSelect: 'all' }}>{subscribeUrl}</Box>
<Tooltip title="URL kopieren">
<IconButton size="small" onClick={handleCopy}>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
<strong>Apple Kalender:</strong> Ablage Neues Kalenderabonnement<br />
<strong>Google Kalender:</strong> Andere Kalender Per URL<br />
<strong>Thunderbird:</strong> Neu Kalender Im Netzwerk
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
{subscribeUrl && (
<Button variant="contained" onClick={handleCopy} startIcon={<CopyIcon />}>
URL kopieren
</Button>
)}
</DialogActions>
</Dialog>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" onClose={() => setSnackOpen(false)}>
URL kopiert!
</Alert>
</Snackbar>
</>
);
}
// ---------------------------------------------------------------------------
// 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<string, UebungListItem[]>();
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 (
<Box>
{/* Weekday headers */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
mb: 0.5,
}}
>
{WEEKDAY_LABELS.map((wd) => (
<Typography
key={wd}
variant="caption"
sx={{
textAlign: 'center',
fontWeight: 600,
color: wd === 'Sa' || wd === 'So' ? 'error.main' : 'text.secondary',
py: 0.5,
}}
>
{wd}
</Typography>
))}
</Box>
{/* Day cells — 6 rows × 7 cols */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '2px',
}}
>
{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 (
<Box
key={idx}
onClick={(e) => 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',
}}
>
<Typography
variant="caption"
sx={{
fontWeight: isToday ? 700 : 400,
color: isToday
? 'primary.contrastText'
: isCurrentMonth
? 'text.primary'
: 'text.disabled',
lineHeight: 1.4,
fontSize: isMobile ? '0.7rem' : '0.75rem',
}}
>
{cell.getDate()}
</Typography>
{/* Event dots — max 3 visible on mobile */}
{hasEvents && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: '2px',
justifyContent: 'center',
mt: 0.25,
}}
>
{dayEvents.slice(0, isMobile ? 3 : 5).map((ev, i) => (
<Box
key={i}
sx={{
width: isMobile ? 5 : 7,
height: isMobile ? 5 : 7,
borderRadius: '50%',
bgcolor: ev.abgesagt
? 'text.disabled'
: TYP_DOT_COLOR[ev.typ],
border: ev.pflichtveranstaltung
? '1.5px solid'
: 'none',
borderColor: 'warning.main',
flexShrink: 0,
}}
/>
))}
{dayEvents.length > (isMobile ? 3 : 5) && (
<Typography
sx={{
fontSize: '0.55rem',
color: isToday ? 'primary.contrastText' : 'text.secondary',
lineHeight: 1,
}}
>
+{dayEvents.length - (isMobile ? 3 : 5)}
</Typography>
)}
</Box>
)}
{/* On desktop: show short event titles */}
{!isMobile && hasEvents && (
<Box sx={{ width: '100%', mt: 0.25 }}>
{dayEvents.slice(0, 2).map((ev, i) => (
<Typography
key={i}
variant="caption"
noWrap
sx={{
display: 'block',
fontSize: '0.6rem',
lineHeight: 1.3,
color: ev.abgesagt ? 'text.disabled' : TYP_DOT_COLOR[ev.typ],
textDecoration: ev.abgesagt ? 'line-through' : 'none',
px: 0.25,
}}
>
{ev.pflichtveranstaltung && '* '}{ev.titel}
</Typography>
))}
</Box>
)}
</Box>
);
})}
</Box>
{/* Legend */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mt: 2 }}>
{Object.entries(TYP_DOT_COLOR).map(([typ, color]) => (
<Box key={typ} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color }} />
<Typography variant="caption" color="text.secondary">{typ}</Typography>
</Box>
))}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'warning.main', border: '1.5px solid', borderColor: 'warning.dark' }} />
<Typography variant="caption" color="text.secondary">Pflichtveranstaltung</Typography>
</Box>
</Box>
</Box>
);
}
// ---------------------------------------------------------------------------
// List View
// ---------------------------------------------------------------------------
function ListView({
events,
onEventClick,
}: {
events: UebungListItem[];
onEventClick: (id: string) => void;
}) {
return (
<List disablePadding>
{events.map((ev, idx) => (
<Box key={ev.id}>
{idx > 0 && <Divider />}
<ListItem
onClick={() => onEventClick(ev.id)}
sx={{
cursor: 'pointer',
px: 1,
py: 1,
borderRadius: 1,
opacity: ev.abgesagt ? 0.55 : 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
{/* Date badge */}
<Box
sx={{
minWidth: 52,
textAlign: 'center',
mr: 2,
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ display: 'block', color: 'text.disabled', fontSize: '0.65rem' }}>
{new Date(ev.datum_von).getDate()}.
{new Date(ev.datum_von).getMonth() + 1}.
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', fontSize: '0.7rem' }}>
{formatTime(ev.datum_von)}
</Typography>
</Box>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{ev.pflichtveranstaltung && (
<StarIcon sx={{ fontSize: 14, color: 'warning.main' }} />
)}
<Typography
variant="body2"
sx={{
fontWeight: ev.pflichtveranstaltung ? 700 : 400,
textDecoration: ev.abgesagt ? 'line-through' : 'none',
}}
>
{ev.titel}
</Typography>
{ev.abgesagt && (
<Chip label="Abgesagt" size="small" color="error" variant="outlined" sx={{ fontSize: '0.6rem', height: 16 }} />
)}
</Box>
}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, flexWrap: 'wrap' }}>
<Chip
label={ev.typ}
size="small"
color={TYP_CHIP_COLOR[ev.typ]}
variant="outlined"
sx={{ fontSize: '0.6rem', height: 16 }}
/>
{ev.ort && (
<Typography variant="caption" color="text.disabled" noWrap>
{ev.ort}
</Typography>
)}
</Box>
}
sx={{ my: 0 }}
/>
{/* RSVP badge */}
<Box sx={{ ml: 1 }}>
<RsvpDot status={ev.eigener_status} />
</Box>
</ListItem>
</Box>
))}
{events.length === 0 && (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
Keine Veranstaltungen in diesem Monat.
</Typography>
)}
</List>
);
}
// ---------------------------------------------------------------------------
// 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 (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
PaperProps={{ sx: { p: 1, maxWidth: 300, width: '90vw' } }}
>
<Typography variant="subtitle2" sx={{ mb: 1, px: 0.5 }}>
{formatDateLong(day.toISOString())}
</Typography>
<List dense disablePadding>
{events.map((ev) => (
<ListItem
key={ev.id}
onClick={() => { onEventClick(ev.id); onClose(); }}
sx={{
cursor: 'pointer',
borderRadius: 1,
px: 0.75,
'&:hover': { bgcolor: 'action.hover' },
opacity: ev.abgesagt ? 0.6 : 1,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: TYP_DOT_COLOR[ev.typ],
mr: 1,
flexShrink: 0,
}}
/>
<ListItemText
primary={
<Typography
variant="body2"
sx={{
fontWeight: ev.pflichtveranstaltung ? 700 : 400,
textDecoration: ev.abgesagt ? 'line-through' : 'none',
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{ev.pflichtveranstaltung && <StarIcon sx={{ fontSize: 12, color: 'warning.main' }} />}
{ev.titel}
</Typography>
}
secondary={`${formatTime(ev.datum_von)} ${formatTime(ev.datum_bis)} Uhr`}
/>
<RsvpDot status={ev.eigener_status} />
</ListItem>
))}
</List>
</Popover>
);
}
// ---------------------------------------------------------------------------
// 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<Element | null>(null);
const [popoverDay, setPopoverDay] = useState<Date | null>(null);
const [popoverEvents, setPopoverEvents] = useState<UebungListItem[]>([]);
// 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 (
<DashboardLayout>
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
{/* Page header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
mb: 3,
}}
>
<CalendarIcon color="primary" />
<Typography variant="h5" sx={{ flexGrow: 1, fontWeight: 700 }}>
Dienstkalender
</Typography>
{/* View toggle */}
<ButtonGroup size="small" variant="outlined">
<Tooltip title="Monatsansicht">
<Button
onClick={() => setViewMode('calendar')}
variant={viewMode === 'calendar' ? 'contained' : 'outlined'}
>
<CalendarIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Monat</Box>}
</Button>
</Tooltip>
<Tooltip title="Listenansicht">
<Button
onClick={() => setViewMode('list')}
variant={viewMode === 'list' ? 'contained' : 'outlined'}
>
<ListViewIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Liste</Box>}
</Button>
</Tooltip>
</ButtonGroup>
<Button
size="small"
variant="outlined"
startIcon={<CopyIcon fontSize="small" />}
onClick={() => setIcalOpen(true)}
sx={{ whiteSpace: 'nowrap' }}
>
{isMobile ? 'iCal' : 'Kalender abonnieren'}
</Button>
</Box>
{/* Month navigation */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 2,
gap: 1,
}}
>
<IconButton onClick={handlePrev} size="small" aria-label="Vorheriger Monat">
<ChevronLeft />
</IconButton>
<Typography
variant="h6"
sx={{ flexGrow: 1, textAlign: 'center', fontWeight: 600 }}
>
{MONTH_LABELS[viewMonth.month]} {viewMonth.year}
</Typography>
<Button
size="small"
startIcon={<TodayIcon fontSize="small" />}
onClick={handleToday}
sx={{ minWidth: 'auto' }}
>
{!isMobile && 'Heute'}
</Button>
<IconButton onClick={handleNext} size="small" aria-label="Nächster Monat">
<ChevronRight />
</IconButton>
</Box>
{/* Calendar / List body */}
{isLoading ? (
<Skeleton variant="rectangular" height={isMobile ? 320 : 480} sx={{ borderRadius: 2 }} />
) : viewMode === 'calendar' ? (
<MonthCalendar
year={viewMonth.year}
month={viewMonth.month}
events={events}
onDayClick={handleDayClick}
/>
) : (
<ListView
events={events.filter((ev) => {
const d = new Date(ev.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
})}
onEventClick={(id) => navigate(`/training/${id}`)}
/>
)}
</Box>
{/* Day Popover */}
<DayPopover
anchorEl={popoverAnchor}
day={popoverDay}
events={popoverEvents}
onClose={() => setPopoverAnchor(null)}
onEventClick={(id) => navigate(`/training/${id}`)}
/>
{/* iCal Subscribe Dialog */}
<IcalDialog open={icalOpen} onClose={() => setIcalOpen(false)} />
</DashboardLayout>
);
}