resolve issues with new features

This commit is contained in:
Matthias Hochmeister
2026-03-12 16:05:01 +01:00
parent a5cd78f01f
commit 5aa309b97a
22 changed files with 796 additions and 234 deletions

View File

@@ -64,6 +64,8 @@ import {
Today as TodayIcon,
Tune,
ViewList as ListViewIcon,
ViewDay as ViewDayIcon,
ViewWeek as ViewWeekIcon,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
@@ -97,6 +99,10 @@ import {
format as fnsFormat,
startOfWeek,
endOfWeek,
startOfMonth,
endOfMonth,
addDays,
subDays,
addWeeks,
subWeeks,
eachDayOfInterval,
@@ -1571,8 +1577,11 @@ export default function Kalender() {
year: today.getFullYear(),
month: today.getMonth(),
});
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
const [viewMode, setViewMode] = useState<'calendar' | 'list' | 'day' | 'week'>('calendar');
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedKategorie, setSelectedKategorie] = useState<string | 'all'>('all');
const [listFrom, setListFrom] = useState(() => fnsFormat(startOfMonth(today), 'yyyy-MM-dd'));
const [listTo, setListTo] = useState(() => fnsFormat(endOfMonth(today), 'yyyy-MM-dd'));
const [trainingEvents, setTrainingEvents] = useState<UebungListItem[]>([]);
const [veranstaltungen, setVeranstaltungen] = useState<VeranstaltungListItem[]>([]);
@@ -1696,23 +1705,37 @@ export default function Kalender() {
// ── Calendar tab helpers ─────────────────────────────────────────────────────
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 };
});
if (viewMode === 'day') {
setCurrentDate((d) => subDays(d, 1));
} else if (viewMode === 'week') {
setCurrentDate((d) => subWeeks(d, 1));
} else {
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 };
});
if (viewMode === 'day') {
setCurrentDate((d) => addDays(d, 1));
} else if (viewMode === 'week') {
setCurrentDate((d) => addWeeks(d, 1));
} else {
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 now = new Date();
setViewMonth({ year: now.getFullYear(), month: now.getMonth() });
setCurrentDate(now);
};
const handleDayClick = useCallback(
@@ -1742,23 +1765,59 @@ export default function Kalender() {
});
}, [veranstaltungen, popoverDay, selectedKategorie]);
// Filtered lists for list view (current month only)
// Filtered lists for list view (filtered by date range)
const trainingForMonth = useMemo(
() =>
trainingEvents.filter((t) => {
() => {
const from = parseISO(listFrom);
const to = parseISO(listTo);
return trainingEvents.filter((t) => {
const d = new Date(t.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
}),
[trainingEvents, viewMonth]
return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59);
});
},
[trainingEvents, listFrom, listTo]
);
const eventsForMonth = useMemo(
() =>
veranstaltungen.filter((ev) => {
() => {
const from = parseISO(listFrom);
const to = parseISO(listTo);
return veranstaltungen.filter((ev) => {
const d = new Date(ev.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
}),
[veranstaltungen, viewMonth]
return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59);
});
},
[veranstaltungen, listFrom, listTo]
);
// Events for the selected day (day view)
const trainingForCurrentDay = useMemo(
() => trainingEvents.filter((t) => sameDay(new Date(t.datum_von), currentDate)),
[trainingEvents, currentDate]
);
const eventsForCurrentDay = useMemo(
() => veranstaltungen.filter((ev) => {
const start = startOfDay(new Date(ev.datum_von));
const end = startOfDay(new Date(ev.datum_bis));
const cur = startOfDay(currentDate);
return cur >= start && cur <= end;
}),
[veranstaltungen, currentDate]
);
// Events for the selected week (week view)
const currentWeekStartCal = useMemo(
() => startOfWeek(currentDate, { weekStartsOn: 1 }),
[currentDate]
);
const currentWeekEndCal = useMemo(
() => endOfWeek(currentDate, { weekStartsOn: 1 }),
[currentDate]
);
const weekDaysCal = useMemo(
() => eachDayOfInterval({ start: currentWeekStartCal, end: currentWeekEndCal }),
[currentWeekStartCal, currentWeekEndCal]
);
// ── Veranstaltung cancel ─────────────────────────────────────────────────────
@@ -1991,6 +2050,24 @@ export default function Kalender() {
>
{/* View toggle */}
<ButtonGroup size="small" variant="outlined">
<Tooltip title="Tagesansicht">
<Button
onClick={() => setViewMode('day')}
variant={viewMode === 'day' ? 'contained' : 'outlined'}
>
<ViewDayIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Tag</Box>}
</Button>
</Tooltip>
<Tooltip title="Wochenansicht">
<Button
onClick={() => setViewMode('week')}
variant={viewMode === 'week' ? 'contained' : 'outlined'}
>
<ViewWeekIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Woche</Box>}
</Button>
</Tooltip>
<Tooltip title="Monatsansicht">
<Button
onClick={() => setViewMode('calendar')}
@@ -2051,22 +2128,20 @@ export default function Kalender() {
</Tooltip>
)}
{/* PDF Export — only in list view */}
{viewMode === 'list' && (
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<PdfIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* PDF Export — available in all views */}
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<PdfIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* CSV Import */}
{canWriteEvents && (
@@ -2091,7 +2166,7 @@ export default function Kalender() {
</Button>
</Box>
{/* Month navigation */}
{/* Navigation */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<IconButton onClick={handlePrev} size="small">
<ChevronLeft />
@@ -2100,7 +2175,11 @@ export default function Kalender() {
variant="h6"
sx={{ flexGrow: 1, textAlign: 'center', fontWeight: 600 }}
>
{MONTH_LABELS[viewMonth.month]} {viewMonth.year}
{viewMode === 'day'
? formatDateLong(currentDate)
: viewMode === 'week'
? `KW ${fnsFormat(currentWeekStartCal, 'w')}${fnsFormat(currentWeekStartCal, 'dd.MM.')} ${fnsFormat(currentWeekEndCal, 'dd.MM.yyyy')}`
: `${MONTH_LABELS[viewMonth.month]} ${viewMonth.year}`}
</Typography>
<Button
size="small"
@@ -2121,13 +2200,171 @@ export default function Kalender() {
</Alert>
)}
{/* Calendar / List body */}
{/* Calendar / List / Day / Week body */}
{calLoading ? (
<Skeleton
variant="rectangular"
height={isMobile ? 320 : 480}
sx={{ borderRadius: 2 }}
/>
) : viewMode === 'day' ? (
/* ── Day View ── */
<Paper elevation={1} sx={{ p: 2 }}>
{trainingForCurrentDay.length === 0 && eventsForCurrentDay.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
Keine Einträge an diesem Tag.
</Typography>
) : (
<List disablePadding>
{trainingForCurrentDay.length > 0 && (
<Typography variant="overline" sx={{ px: 0.5, color: 'text.secondary' }}>
Dienste
</Typography>
)}
{trainingForCurrentDay.map((t) => (
<ListItem
key={`t-${t.id}`}
sx={{
borderLeft: `4px solid ${TYP_DOT_COLOR[t.typ]}`,
borderRadius: 1,
mb: 0.5,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
opacity: t.abgesagt ? 0.55 : 1,
}}
onClick={() => navigate(`/training/${t.id}`)}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{t.pflichtveranstaltung && <StarIcon sx={{ fontSize: 14, color: 'warning.main' }} />}
<Typography variant="body2" sx={{ fontWeight: t.pflichtveranstaltung ? 700 : 400, textDecoration: t.abgesagt ? 'line-through' : 'none' }}>
{t.titel}
</Typography>
</Box>
}
secondary={`${formatTime(t.datum_von)} ${formatTime(t.datum_bis)} Uhr · ${t.typ}`}
/>
<RsvpDot status={t.eigener_status} />
</ListItem>
))}
{trainingForCurrentDay.length > 0 && eventsForCurrentDay.length > 0 && <Divider sx={{ my: 1 }} />}
{eventsForCurrentDay.length > 0 && (
<Typography variant="overline" sx={{ px: 0.5, color: 'text.secondary' }}>
Veranstaltungen
</Typography>
)}
{eventsForCurrentDay
.filter((ev) => selectedKategorie === 'all' || ev.kategorie_id === selectedKategorie)
.map((ev) => (
<ListItem
key={`e-${ev.id}`}
sx={{
borderLeft: `4px solid ${ev.kategorie_farbe ?? '#1976d2'}`,
borderRadius: 1,
mb: 0.5,
opacity: ev.abgesagt ? 0.55 : 1,
}}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 500, textDecoration: ev.abgesagt ? 'line-through' : 'none' }}>
{ev.titel}
{ev.abgesagt && <Chip label="Abgesagt" size="small" color="error" variant="outlined" sx={{ ml: 0.5, fontSize: '0.6rem', height: 16 }} />}
</Typography>
}
secondary={
<>
{ev.ganztaegig ? 'Ganztägig' : `${formatTime(ev.datum_von)} ${formatTime(ev.datum_bis)} Uhr`}
{ev.ort && ` · ${ev.ort}`}
</>
}
/>
</ListItem>
))}
</List>
)}
</Paper>
) : viewMode === 'week' ? (
/* ── Week View ── */
<Paper elevation={1} sx={{ p: 1, overflowX: 'auto' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', minWidth: 700 }}>
{weekDaysCal.map((day) => {
const isToday = sameDay(day, new Date());
const dayTraining = trainingEvents.filter((t) => sameDay(new Date(t.datum_von), day));
const dayEvents = veranstaltungen.filter((ev) => {
if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false;
const start = startOfDay(new Date(ev.datum_von));
const end = startOfDay(new Date(ev.datum_bis));
const cur = startOfDay(day);
return cur >= start && cur <= end;
});
return (
<Box
key={day.toISOString()}
sx={{
minHeight: 120,
border: '1px solid',
borderColor: isToday ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5,
bgcolor: isToday ? 'primary.main' : 'background.paper',
}}
>
<Typography
variant="caption"
sx={{
display: 'block',
textAlign: 'center',
fontWeight: 600,
color: isToday ? 'primary.contrastText' : 'text.primary',
mb: 0.5,
}}
>
{fnsFormat(day, 'EEE dd.MM.', { locale: de })}
</Typography>
{dayTraining.map((t) => (
<Chip
key={`t-${t.id}`}
label={t.titel}
size="small"
onClick={() => navigate(`/training/${t.id}`)}
sx={{
fontSize: '0.6rem',
height: 18,
mb: '2px',
width: '100%',
justifyContent: 'flex-start',
bgcolor: t.abgesagt ? 'action.disabledBackground' : TYP_DOT_COLOR[t.typ],
color: t.abgesagt ? 'text.disabled' : 'white',
textDecoration: t.abgesagt ? 'line-through' : 'none',
cursor: 'pointer',
}}
/>
))}
{dayEvents.map((ev) => (
<Chip
key={`e-${ev.id}`}
label={ev.titel}
size="small"
sx={{
fontSize: '0.6rem',
height: 18,
mb: '2px',
width: '100%',
justifyContent: 'flex-start',
bgcolor: ev.abgesagt ? 'action.disabledBackground' : (ev.kategorie_farbe ?? '#1976d2'),
color: ev.abgesagt ? 'text.disabled' : 'white',
textDecoration: ev.abgesagt ? 'line-through' : 'none',
}}
/>
))}
</Box>
);
})}
</Box>
</Paper>
) : viewMode === 'calendar' ? (
<Paper elevation={1} sx={{ p: 1 }}>
<MonthCalendar
@@ -2140,24 +2377,47 @@ export default function Kalender() {
/>
</Paper>
) : (
<Paper elevation={1} sx={{ px: 1 }}>
<CombinedListView
trainingEvents={trainingForMonth}
veranstaltungen={eventsForMonth}
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
/>
</Paper>
<>
{/* Date range inputs for list view */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
type="date"
label="Von"
size="small"
value={listFrom}
onChange={(e) => setListFrom(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
/>
<TextField
type="date"
label="Bis"
size="small"
value={listTo}
onChange={(e) => setListTo(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
/>
</Box>
<Paper elevation={1} sx={{ px: 1 }}>
<CombinedListView
trainingEvents={trainingForMonth}
veranstaltungen={eventsForMonth}
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
/>
</Paper>
</>
)}
{/* FAB: Create Veranstaltung */}