805 lines
26 KiB
TypeScript
805 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|