update
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
@@ -11,20 +11,49 @@ import {
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
Select,
|
||||
} from '@mui/material';
|
||||
import type { SelectChangeEvent } from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import type { BroadcastPayload } from '../../types/admin.types';
|
||||
|
||||
const DIENSTGRAD_OPTIONS = [
|
||||
'Mitglied',
|
||||
'Maschinist',
|
||||
'Truppführer',
|
||||
'Gruppenführer',
|
||||
'Zugkommandant',
|
||||
'Kommandant',
|
||||
];
|
||||
|
||||
const GROUP_OPTIONS = [
|
||||
'dashboard_admin',
|
||||
'dashboard_kommando',
|
||||
'dashboard_gruppenfuehrer',
|
||||
];
|
||||
|
||||
function NotificationBroadcastTab() {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const [titel, setTitel] = useState('');
|
||||
const [nachricht, setNachricht] = useState('');
|
||||
const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info');
|
||||
const [targetGroup, setTargetGroup] = useState('');
|
||||
const [targetDienstgrad, setTargetDienstgrad] = useState<string[]>([]);
|
||||
const [alleBenutzer, setAlleBenutzer] = useState(true);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [previewCount, setPreviewCount] = useState<number | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const broadcastMutation = useMutation({
|
||||
mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data),
|
||||
@@ -34,26 +63,74 @@ function NotificationBroadcastTab() {
|
||||
setNachricht('');
|
||||
setSchwere('info');
|
||||
setTargetGroup('');
|
||||
setTargetDienstgrad([]);
|
||||
setAlleBenutzer(true);
|
||||
setPreviewCount(null);
|
||||
},
|
||||
onError: () => {
|
||||
showError('Fehler beim Senden der Benachrichtigung');
|
||||
},
|
||||
});
|
||||
|
||||
const fetchPreview = useCallback(() => {
|
||||
if (alleBenutzer) {
|
||||
// For "Alle Benutzer" we still fetch the count (no filters)
|
||||
setPreviewLoading(true);
|
||||
adminApi.broadcastPreview({})
|
||||
.then((result) => setPreviewCount(result.count))
|
||||
.catch(() => setPreviewCount(null))
|
||||
.finally(() => setPreviewLoading(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: { targetGroup?: string; targetDienstgrad?: string[] } = {};
|
||||
if (targetGroup.trim()) payload.targetGroup = targetGroup.trim();
|
||||
if (targetDienstgrad.length > 0) payload.targetDienstgrad = targetDienstgrad;
|
||||
|
||||
setPreviewLoading(true);
|
||||
adminApi.broadcastPreview(payload)
|
||||
.then((result) => setPreviewCount(result.count))
|
||||
.catch(() => setPreviewCount(null))
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, [alleBenutzer, targetGroup, targetDienstgrad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(fetchPreview, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [fetchPreview]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmOpen(false);
|
||||
broadcastMutation.mutate({
|
||||
titel,
|
||||
nachricht,
|
||||
schwere,
|
||||
...(targetGroup.trim() ? { targetGroup: targetGroup.trim() } : {}),
|
||||
});
|
||||
const payload: BroadcastPayload = { titel, nachricht, schwere };
|
||||
if (!alleBenutzer) {
|
||||
if (targetGroup.trim()) payload.targetGroup = targetGroup.trim();
|
||||
if (targetDienstgrad.length > 0) payload.targetDienstgrad = targetDienstgrad;
|
||||
}
|
||||
broadcastMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleDienstgradChange = (event: SelectChangeEvent<string[]>) => {
|
||||
const value = event.target.value;
|
||||
setTargetDienstgrad(typeof value === 'string' ? value.split(',') : value);
|
||||
};
|
||||
|
||||
const filtersActive = !alleBenutzer && (targetGroup.trim() || targetDienstgrad.length > 0);
|
||||
|
||||
const filterDescription = (() => {
|
||||
if (alleBenutzer) return 'alle aktiven Benutzer';
|
||||
const parts: string[] = [];
|
||||
if (targetGroup.trim()) parts.push(`Gruppe "${targetGroup.trim()}"`);
|
||||
if (targetDienstgrad.length > 0) parts.push(`Dienstgrad: ${targetDienstgrad.join(', ')}`);
|
||||
return parts.length > 0 ? parts.join(' + ') : 'alle aktiven Benutzer';
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 600 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Benachrichtigung senden</Typography>
|
||||
@@ -91,15 +168,77 @@ function NotificationBroadcastTab() {
|
||||
<MenuItem value="fehler">Fehler</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Zielgruppe (optional)"
|
||||
fullWidth
|
||||
value={targetGroup}
|
||||
onChange={(e) => setTargetGroup(e.target.value)}
|
||||
helperText="Leer lassen um an alle aktiven Benutzer zu senden"
|
||||
sx={{ mb: 3 }}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alleBenutzer}
|
||||
onChange={(e) => {
|
||||
setAlleBenutzer(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
setTargetGroup('');
|
||||
setTargetDienstgrad([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Alle Benutzer"
|
||||
sx={{ mb: 2, display: 'block' }}
|
||||
/>
|
||||
|
||||
{!alleBenutzer && (
|
||||
<>
|
||||
<TextField
|
||||
select
|
||||
label="Authentik-Gruppe"
|
||||
fullWidth
|
||||
value={targetGroup}
|
||||
onChange={(e) => setTargetGroup(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Keine Einschraenkung</em>
|
||||
</MenuItem>
|
||||
{GROUP_OPTIONS.map((g) => (
|
||||
<MenuItem key={g} value={g}>{g}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Dienstgrad</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={targetDienstgrad}
|
||||
onChange={handleDienstgradChange}
|
||||
input={<OutlinedInput label="Dienstgrad" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{DIENSTGRAD_OPTIONS.map((d) => (
|
||||
<MenuItem key={d} value={d}>{d}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PeopleIcon color="action" fontSize="small" />
|
||||
{previewLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{previewCount !== null
|
||||
? `Wird an ${previewCount} Benutzer gesendet`
|
||||
: 'Empfaengeranzahl wird geladen...'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={broadcastMutation.isPending ? <CircularProgress size={18} color="inherit" /> : <SendIcon />}
|
||||
@@ -113,8 +252,10 @@ function NotificationBroadcastTab() {
|
||||
<DialogTitle>Benachrichtigung senden?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Sind Sie sicher, dass Sie diese Benachrichtigung
|
||||
{targetGroup.trim() ? ` an die Gruppe "${targetGroup.trim()}"` : ' an alle aktiven Benutzer'} senden moechten?
|
||||
Sind Sie sicher, dass Sie diese Benachrichtigung an {filterDescription} senden moechten?
|
||||
{previewCount !== null && (
|
||||
<> ({previewCount} {previewCount === 1 ? 'Empfaenger' : 'Empfaenger'})</>
|
||||
)}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { atemschutzApi } from '../../services/atemschutz';
|
||||
|
||||
/**
|
||||
* Invisible component — polls /api/atemschutz/expiring every 10 minutes.
|
||||
* The backend creates notifications as a side-effect when polled.
|
||||
*/
|
||||
const AtemschutzExpiryNotifier: React.FC = () => {
|
||||
useQuery({
|
||||
queryKey: ['atemschutz-expiring'],
|
||||
queryFn: () => atemschutzApi.getExpiring(),
|
||||
refetchInterval: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AtemschutzExpiryNotifier;
|
||||
@@ -9,6 +9,7 @@ export { default as BookStackSearchWidget } from './BookStackSearchWidget';
|
||||
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
||||
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
||||
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
||||
export { default as AtemschutzExpiryNotifier } from './AtemschutzExpiryNotifier';
|
||||
export { default as AdminStatusWidget } from './AdminStatusWidget';
|
||||
export { default as VehicleBookingListWidget } from './VehicleBookingListWidget';
|
||||
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './api';
|
||||
import type { MonitoredService, PingResult, PingHistoryEntry, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types';
|
||||
import type { MonitoredService, PingResult, PingHistoryEntry, StatusSummary, SystemHealth, UserOverview, BroadcastPayload, BroadcastPreviewPayload } from '../types/admin.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
@@ -26,6 +26,7 @@ export const adminApi = {
|
||||
getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data),
|
||||
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
|
||||
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
|
||||
broadcastPreview: (data: BroadcastPreviewPayload) => api.post<ApiResponse<{ count: number }>>('/api/admin/notifications/broadcast/preview', data).then(r => r.data.data),
|
||||
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
||||
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
||||
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
|
||||
|
||||
@@ -76,4 +76,9 @@ export const atemschutzApi = {
|
||||
async delete(id: string): Promise<void> {
|
||||
await api.delete(`/api/atemschutz/${id}`);
|
||||
},
|
||||
|
||||
async getExpiring(): Promise<any[]> {
|
||||
const response = await api.get<{ success: boolean; data: any[] }>('/api/atemschutz/expiring');
|
||||
return response.data?.data ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
FahrzeugBuchung,
|
||||
Fahrzeug,
|
||||
CreateBuchungInput,
|
||||
MaintenanceWindow,
|
||||
} from '../types/booking.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,15 +23,22 @@ export const bookingApi = {
|
||||
// Calendar / listing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getCalendarRange(from: Date, to: Date, fahrzeugId?: string): Promise<FahrzeugBuchungListItem[]> {
|
||||
getCalendarRange(
|
||||
from: Date,
|
||||
to: Date,
|
||||
fahrzeugId?: string
|
||||
): Promise<{ bookings: FahrzeugBuchungListItem[]; maintenanceWindows: MaintenanceWindow[] }> {
|
||||
return api
|
||||
.get<ApiResponse<FahrzeugBuchungListItem[]>>('/api/bookings/calendar', {
|
||||
params: {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
...(fahrzeugId ? { fahrzeugId } : {}),
|
||||
},
|
||||
})
|
||||
.get<ApiResponse<{ bookings: FahrzeugBuchungListItem[]; maintenanceWindows: MaintenanceWindow[] }>>(
|
||||
'/api/bookings/calendar',
|
||||
{
|
||||
params: {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
...(fahrzeugId ? { fahrzeugId } : {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Veranstaltung,
|
||||
GroupInfo,
|
||||
CreateVeranstaltungInput,
|
||||
ConflictEvent,
|
||||
} from '../types/events.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,4 +154,13 @@ export const eventsApi = {
|
||||
.post<ApiResponse<{ created: number; errors: string[] }>>('/api/events/import', { events })
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
/** Check for overlapping events in a time range */
|
||||
checkConflicts(from: string, to: string, excludeId?: string): Promise<ConflictEvent[]> {
|
||||
return api
|
||||
.get<ApiResponse<ConflictEvent[]>>('/api/events/conflicts', {
|
||||
params: { from, to, ...(excludeId ? { excludeId } : {}) },
|
||||
})
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,4 +93,11 @@ export const vehiclesApi = {
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async exportAlerts(): Promise<Blob> {
|
||||
const response = await api.get('/api/vehicles/alerts/export', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data as Blob;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,4 +58,10 @@ export interface BroadcastPayload {
|
||||
nachricht: string;
|
||||
schwere?: 'info' | 'warnung' | 'fehler';
|
||||
targetGroup?: string;
|
||||
targetDienstgrad?: string[];
|
||||
}
|
||||
|
||||
export interface BroadcastPreviewPayload {
|
||||
targetGroup?: string;
|
||||
targetDienstgrad?: string[];
|
||||
}
|
||||
|
||||
@@ -47,10 +47,21 @@ export interface Fahrzeug {
|
||||
kurzname: string | null;
|
||||
amtliches_kennzeichen: string | null;
|
||||
status: string;
|
||||
status_bemerkung?: string | null;
|
||||
ausser_dienst_von: string | null;
|
||||
ausser_dienst_bis: string | null;
|
||||
}
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
status: string;
|
||||
status_bemerkung: string | null;
|
||||
ausser_dienst_von: string;
|
||||
ausser_dienst_bis: string;
|
||||
}
|
||||
|
||||
export interface CreateBuchungInput {
|
||||
fahrzeugId: string;
|
||||
titel: string;
|
||||
|
||||
@@ -76,3 +76,11 @@ export interface CreateVeranstaltungInput {
|
||||
anmeldung_bis?: string | null;
|
||||
wiederholung?: WiederholungConfig | null;
|
||||
}
|
||||
|
||||
export interface ConflictEvent {
|
||||
id: string;
|
||||
titel: string;
|
||||
datum_von: string; // ISO
|
||||
datum_bis: string; // ISO
|
||||
kategorie_name: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user