add features
This commit is contained in:
804
frontend/src/pages/Kalender.tsx
Normal file
804
frontend/src/pages/Kalender.tsx
Normal file
@@ -0,0 +1,804 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user