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,247 @@
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>
);
}