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

248 lines
7.1 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 { useMemo } from 'react';
import {
Box,
List,
ListItem,
ListItemText,
Chip,
Typography,
Button,
Skeleton,
Tooltip,
} from '@mui/material';
import {
CheckCircle as CheckIcon,
Cancel as CancelIcon,
HelpOutline as UnknownIcon,
Star as StarIcon,
CalendarMonth as CalendarIcon,
ArrowForward as ArrowIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { trainingApi } from '../../services/training';
import type { UebungListItem, TeilnahmeStatus, UebungTyp } from '../../types/training.types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TYP_COLORS: Record<UebungTyp, 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'> = {
'Übungsabend': 'primary',
'Lehrgang': 'secondary',
'Sonderdienst': 'warning',
'Versammlung': 'default',
'Gemeinschaftsübung': 'info',
'Sonstiges': 'default',
};
const WEEKDAY_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
function formatEventDate(isoString: string): string {
const d = new Date(isoString);
return `${WEEKDAY_SHORT[d.getDay()]}, ${String(d.getDate()).padStart(2, '0')}. ${MONTH_SHORT[d.getMonth()]}`;
}
function formatEventTime(vonIso: string, bisIso: string): string {
const von = new Date(vonIso);
const bis = new Date(bisIso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(von.getHours())}:${pad(von.getMinutes())} ${pad(bis.getHours())}:${pad(bis.getMinutes())} Uhr`;
}
// ---------------------------------------------------------------------------
// RSVP Status badge
// ---------------------------------------------------------------------------
function RsvpBadge({ status }: { status: TeilnahmeStatus | undefined }) {
if (!status || status === 'unbekannt') {
return (
<Tooltip title="Noch keine Rückmeldung">
<UnknownIcon sx={{ color: 'text.disabled', fontSize: 20 }} />
</Tooltip>
);
}
if (status === 'zugesagt') {
return (
<Tooltip title="Zugesagt">
<CheckIcon sx={{ color: 'success.main', fontSize: 20 }} />
</Tooltip>
);
}
if (status === 'erschienen') {
return (
<Tooltip title="Erschienen">
<CheckIcon sx={{ color: 'success.dark', fontSize: 20 }} />
</Tooltip>
);
}
if (status === 'abgesagt' || status === 'entschuldigt') {
return (
<Tooltip title={status === 'entschuldigt' ? 'Entschuldigt' : 'Abgesagt'}>
<CancelIcon sx={{ color: 'error.main', fontSize: 20 }} />
</Tooltip>
);
}
return null;
}
// ---------------------------------------------------------------------------
// Single event row
// ---------------------------------------------------------------------------
function EventRow({ event }: { event: UebungListItem }) {
const navigate = useNavigate();
return (
<ListItem
disablePadding
onClick={() => navigate(`/training/${event.id}`)}
sx={{
cursor: 'pointer',
borderRadius: 1,
mb: 0.5,
px: 1,
py: 0.75,
transition: 'background 0.15s',
'&:hover': { backgroundColor: 'action.hover' },
opacity: event.abgesagt ? 0.55 : 1,
}}
>
{/* Date column */}
<Box
sx={{
minWidth: 72,
mr: 1.5,
textAlign: 'center',
flexShrink: 0,
}}
>
<Typography
variant="caption"
sx={{
display: 'block',
color: 'text.secondary',
lineHeight: 1.2,
fontSize: '0.7rem',
}}
>
{formatEventDate(event.datum_von)}
</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
color: 'text.disabled',
fontSize: '0.65rem',
}}
>
{formatEventTime(event.datum_von, event.datum_bis)}
</Typography>
</Box>
{/* Title + chip */}
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{event.pflichtveranstaltung && (
<StarIcon sx={{ fontSize: 14, color: 'warning.main', flexShrink: 0 }} />
)}
<Typography
variant="body2"
sx={{
fontWeight: event.pflichtveranstaltung ? 700 : 400,
textDecoration: event.abgesagt ? 'line-through' : 'none',
lineHeight: 1.3,
}}
>
{event.titel}
</Typography>
</Box>
}
secondary={
<Chip
label={event.typ}
size="small"
color={TYP_COLORS[event.typ]}
variant="outlined"
sx={{ fontSize: '0.65rem', height: 18, mt: 0.25 }}
/>
}
sx={{ my: 0 }}
/>
{/* RSVP status */}
<Box sx={{ ml: 1, flexShrink: 0 }}>
<RsvpBadge status={event.eigener_status} />
</Box>
</ListItem>
);
}
// ---------------------------------------------------------------------------
// Main widget component
// ---------------------------------------------------------------------------
export default function UpcomingEvents() {
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
queryKey: ['training', 'upcoming', 3],
queryFn: () => trainingApi.getUpcoming(3),
staleTime: 5 * 60 * 1000, // 5 min
});
const events = useMemo(() => data ?? [], [data]);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1, gap: 1 }}>
<CalendarIcon color="primary" fontSize="small" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Nächste Dienste
</Typography>
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Skeleton key={n} variant="rectangular" height={56} sx={{ borderRadius: 1, mb: 0.5 }} />
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Dienste konnten nicht geladen werden.
</Typography>
)}
{!isLoading && !isError && events.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine bevorstehenden Veranstaltungen.
</Typography>
)}
{!isLoading && !isError && events.length > 0 && (
<List dense disablePadding>
{events.map((event) => (
<EventRow key={event.id} event={event} />
))}
</List>
)}
<Box sx={{ mt: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
endIcon={<ArrowIcon />}
onClick={() => navigate('/kalender')}
sx={{ textTransform: 'none' }}
>
Zum Kalender
</Button>
</Box>
</Box>
);
}