This commit is contained in:
Matthias Hochmeister
2026-03-16 15:01:09 +01:00
parent 3c72fe627f
commit f3ad989a9e
28 changed files with 794 additions and 52 deletions

View File

@@ -18,6 +18,7 @@ import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget
import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
import AtemschutzExpiryNotifier from '../components/dashboard/AtemschutzExpiryNotifier';
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
@@ -72,6 +73,8 @@ function Dashboard() {
<DashboardLayout>
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
<VikunjaOverdueNotifier />
{/* Atemschutz — Expiry Notifier (invisible, polling component — outside grid) */}
<AtemschutzExpiryNotifier />
<Container maxWidth={false} disableGutters>
<Box
sx={{

View File

@@ -41,6 +41,7 @@ import {
CheckCircle,
Warning,
Block,
Build,
} from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -52,6 +53,7 @@ import type {
Fahrzeug,
CreateBuchungInput,
BuchungsArt,
MaintenanceWindow,
} from '../types/booking.types';
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
import {
@@ -116,6 +118,7 @@ function FahrzeugBuchungen() {
// ── Data ──────────────────────────────────────────────────────────────────
const [vehicles, setVehicles] = useState<Fahrzeug[]>([]);
const [bookings, setBookings] = useState<FahrzeugBuchungListItem[]>([]);
const [maintenanceWindows, setMaintenanceWindows] = useState<MaintenanceWindow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -123,12 +126,13 @@ function FahrzeugBuchungen() {
setLoading(true);
try {
const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
const [vehiclesData, bookingsData] = await Promise.all([
const [vehiclesData, calendarData] = await Promise.all([
fetchVehicles(),
bookingApi.getCalendarRange(currentWeekStart, end),
]);
setVehicles(vehiclesData);
setBookings(bookingsData);
setBookings(calendarData.bookings);
setMaintenanceWindows(calendarData.maintenanceWindows);
setError(null);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden';
@@ -160,6 +164,22 @@ function FahrzeugBuchungen() {
};
const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => {
// Check from maintenance windows (server-side filtered)
const mw = maintenanceWindows.find((w) => w.id === vehicle.id);
if (mw) {
try {
if (
isWithinInterval(day, {
start: parseISO(mw.ausser_dienst_von),
end: parseISO(mw.ausser_dienst_bis),
})
)
return true;
} catch {
/* ignore parse errors */
}
}
// Fallback to vehicle-level dates
if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false;
try {
return isWithinInterval(day, {
@@ -171,6 +191,29 @@ function FahrzeugBuchungen() {
}
};
/** Get the maintenance tooltip text for an out-of-service cell */
const getMaintenanceTooltip = (vehicle: Fahrzeug): string => {
const mw = maintenanceWindows.find((w) => w.id === vehicle.id);
const statusLabel =
(mw?.status ?? vehicle.status) === 'ausser_dienst_wartung'
? 'Wartung'
: 'Schaden';
const bemerkung = mw?.status_bemerkung ?? vehicle.status_bemerkung;
const von = mw?.ausser_dienst_von ?? vehicle.ausser_dienst_von;
const bis = mw?.ausser_dienst_bis ?? vehicle.ausser_dienst_bis;
let tooltip = `Außer Dienst (${statusLabel})`;
if (bemerkung) tooltip += `: ${bemerkung}`;
if (von && bis) {
try {
tooltip += `\n${format(parseISO(von), 'dd.MM.yyyy')} ${format(parseISO(bis), 'dd.MM.yyyy')}`;
} catch {
/* ignore */
}
}
return tooltip;
};
// ── Create / Edit dialog ──────────────────────────────────────────────────
const [dialogOpen, setDialogOpen] = useState(false);
const [editingBooking, setEditingBooking] =
@@ -485,9 +528,15 @@ function FahrzeugBuchungen() {
}}
>
{oos && (
<Tooltip title="Fahrzeug außer Dienst">
<Tooltip
title={
<span style={{ whiteSpace: 'pre-line' }}>
{getMaintenanceTooltip(vehicle)}
</span>
}
>
<Chip
icon={<Block fontSize="small" />}
icon={<Build fontSize="small" />}
label="Außer Dienst"
size="small"
color="error"
@@ -570,9 +619,14 @@ function FahrzeugBuchungen() {
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300',
borderRadius: 0.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
<Typography variant="caption">Außer Dienst</Typography>
>
<Build sx={{ fontSize: 10, color: 'error.main' }} />
</Box>
<Typography variant="caption">Außer Dienst (Wartung/Schaden)</Typography>
</Box>
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
([art, label]) => (

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Card,
CardActionArea,
CardContent,
@@ -20,6 +21,7 @@ import {
CheckCircle,
DirectionsCar,
Error as ErrorIcon,
FileDownload,
PauseCircle,
School,
Search,
@@ -328,6 +330,24 @@ function Fahrzeuge() {
fetchWarnings();
}, []);
const handleExportAlerts = useCallback(async () => {
try {
const blob = await vehiclesApi.exportAlerts();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
a.download = `pruefungen_${dateStr}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setError('CSV-Export fehlgeschlagen.');
}
}, []);
const filtered = vehicles.filter((v) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
@@ -366,6 +386,14 @@ function Fahrzeuge() {
</Typography>
)}
</Box>
<Button
variant="outlined"
size="small"
startIcon={<FileDownload />}
onClick={handleExportAlerts}
>
Prüfungen CSV
</Button>
</Box>
{hasOverdue && (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Box,
Typography,
@@ -60,6 +60,7 @@ import type {
VeranstaltungKategorie,
GroupInfo,
CreateVeranstaltungInput,
ConflictEvent,
} from '../types/events.types';
// ---------------------------------------------------------------------------
@@ -609,6 +610,46 @@ function EventFormDialog({
}
}, [open, editingEvent]);
// -----------------------------------------------------------------------
// Conflict detection — debounced check when dates change
// -----------------------------------------------------------------------
const [conflicts, setConflicts] = useState<ConflictEvent[]>([]);
const conflictTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear conflicts when dialog closes
if (!open) {
setConflicts([]);
return;
}
const vonDate = new Date(form.datum_von);
const bisDate = new Date(form.datum_bis);
if (isNaN(vonDate.getTime()) || isNaN(bisDate.getTime()) || bisDate <= vonDate) {
setConflicts([]);
return;
}
if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current);
conflictTimerRef.current = setTimeout(async () => {
try {
const result = await eventsApi.checkConflicts(
vonDate.toISOString(),
bisDate.toISOString(),
editingEvent?.id
);
setConflicts(result);
} catch {
// Silently ignore — conflict check is advisory only
setConflicts([]);
}
}, 500);
return () => {
if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current);
};
}, [open, form.datum_von, form.datum_bis, editingEvent]);
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
if (field === 'kategorie_id' && !editingEvent) {
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
@@ -771,6 +812,31 @@ function EventFormDialog({
fullWidth
/>
{/* Conflict warning */}
{conflicts.length > 0 && (
<Alert severity="warning" sx={{ mt: 0 }}>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
Überschneidung mit bestehenden Veranstaltungen:
</Typography>
{conflicts.map((c) => {
const von = new Date(c.datum_von);
const bis = new Date(c.datum_bis);
const fmtDate = (d: Date) =>
`${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
const fmtTime = (d: Date) =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const range = sameDay(von, bis)
? `${fmtDate(von)} ${fmtTime(von)} - ${fmtTime(bis)}`
: `${fmtDate(von)} ${fmtTime(von)} - ${fmtDate(bis)} ${fmtTime(bis)}`;
return (
<Typography key={c.id} variant="body2">
{'\u2022'} &quot;{c.titel}&quot; ({range})
</Typography>
);
})}
</Alert>
)}
{/* Ort */}
<TextField
label="Ort"