add features
This commit is contained in:
247
frontend/src/components/training/UpcomingEvents.tsx
Normal file
247
frontend/src/components/training/UpcomingEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user