update
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'} "{c.titel}" ({range})
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Ort */}
|
||||
<TextField
|
||||
label="Ort"
|
||||
|
||||
Reference in New Issue
Block a user