add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View 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>
);
}