Files
dashboard/frontend/src/pages/Fahrzeuge.tsx

684 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Card,
CardActionArea,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
Grid,
IconButton,
InputAdornment,
Paper,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
Add as AddIcon,
CheckCircle,
Delete as DeleteIcon,
DirectionsCar,
Edit as EditIcon,
Error as ErrorIcon,
FileDownload,
PauseCircle,
School,
Search,
Warning,
ReportProblem,
} from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { VehicleEquipmentWarning } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import {
FahrzeugListItem,
FahrzeugStatus,
FahrzeugStatusLabel,
} from '../types/vehicle.types';
import type { FahrzeugTyp } from '../types/checklist.types';
import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { FormDialog } from '../components/templates';
// ── Status chip config ────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<
FahrzeugStatus,
{ color: 'success' | 'warning' | 'error' | 'info'; icon: React.ReactElement }
> = {
[FahrzeugStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> },
[FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
[FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
};
// ── Inspection badge helpers ──────────────────────────────────────────────────
type InspBadgeColor = 'success' | 'warning' | 'error' | 'default';
function inspBadgeColor(tage: number | null): InspBadgeColor {
if (tage === null) return 'default';
if (tage < 0) return 'error';
if (tage <= 30) return 'warning';
return 'success';
}
function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string {
if (faelligAm === null) return '';
const date = new Date(faelligAm).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
});
if (tage === null) return `${art}: ${date}`;
if (tage < 0) return `${art}: ÜBERFÄLLIG (${date})`;
if (tage === 0) return `${art}: heute (${date})`;
return `${art}: ${date}`;
}
function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: string | null): string {
if (!faelligAm) return fullLabel;
const date = new Date(faelligAm).toLocaleDateString('de-DE');
if (tage !== null && tage < 0) {
return `${fullLabel}: Seit ${Math.abs(tage)} Tagen überfällig!`;
}
return `${fullLabel}: Fällig am ${date}`;
}
// ── Vehicle Card ──────────────────────────────────────────────────────────────
interface VehicleCardProps {
vehicle: FahrzeugListItem;
onClick: (id: string) => void;
warnings?: VehicleEquipmentWarning[];
}
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings = [] }) => {
const status = vehicle.status as FahrzeugStatus;
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
const inspBadges = [
{
art: '§57a',
fullLabel: '§57a Periodische Prüfung',
tage: vehicle.paragraph57a_tage_bis_faelligkeit,
faelligAm: vehicle.paragraph57a_faellig_am,
},
{
art: 'Wartung',
fullLabel: 'Nächste Wartung / Service',
tage: vehicle.wartung_tage_bis_faelligkeit,
faelligAm: vehicle.naechste_wartung_am,
},
].filter((b) => b.faelligAm !== null);
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: isSchaden ? '2px solid' : undefined,
borderColor: isSchaden ? 'error.main' : undefined,
position: 'relative',
}}
>
{isSchaden && (
<Tooltip title="Fahrzeug außer Dienst (Schaden) — nicht einsatzbereit!">
<ReportProblem
color="error"
sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}
/>
</Tooltip>
)}
<CardActionArea
onClick={() => onClick(vehicle.id)}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
{vehicle.bild_url ? (
<CardMedia
component="img"
height="140"
image={vehicle.bild_url}
alt={vehicle.bezeichnung}
sx={{ objectFit: 'cover' }}
/>
) : (
<Box
sx={{
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled' }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
<Box>
<Typography variant="h6" component="div" lineHeight={1.2}>
{vehicle.bezeichnung}
{vehicle.kurzname && (
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
({vehicle.kurzname})
</Typography>
)}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="body2" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</Box>
</Box>
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip
icon={statusCfg.icon}
label={FahrzeugStatusLabel[status]}
color={statusCfg.color}
size="small"
variant="outlined"
/>
{vehicle.aktiver_lehrgang && (
<Chip
icon={<School fontSize="small" />}
label="In Lehrgang"
color="info"
size="small"
variant="outlined"
/>
)}
</Box>
{(status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden) &&
vehicle.ausser_dienst_bis && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Bis ca. {new Date(vehicle.ausser_dienst_bis).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</Typography>
)}
</Box>
{inspBadges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{inspBadges.map((b) => {
const color = inspBadgeColor(b.tage);
const label = inspBadgeLabel(b.art, b.tage, b.faelligAm);
if (!label) return null;
return (
<Tooltip
key={b.art}
title={inspTooltipTitle(b.fullLabel, b.tage, b.faelligAm)}
>
<Chip
size="small"
label={label}
color={color}
variant={color === 'default' ? 'outlined' : 'filled'}
icon={b.tage !== null && b.tage < 0 ? <Warning fontSize="small" /> : undefined}
sx={{ fontSize: '0.7rem' }}
/>
</Tooltip>
);
})}
</Box>
)}
{warnings.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{warnings.slice(0, warnings.length > 3 ? 2 : 3).map((w) => {
const isError =
w.status === AusruestungStatus.Beschaedigt ||
w.status === AusruestungStatus.AusserDienst;
return (
<Tooltip
key={w.ausruestung_id}
title={`${w.kategorie_name}: ${AusruestungStatusLabel[w.status]}`}
>
<Chip
size="small"
icon={<ReportProblem />}
label={w.bezeichnung}
color={isError ? 'error' : 'warning'}
sx={{ fontSize: '0.7rem', maxWidth: 160 }}
/>
</Tooltip>
);
})}
{warnings.length > 3 && (
<Tooltip
title={warnings
.slice(2)
.map((w) => `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`)
.join('\n')}
>
<Chip
size="small"
icon={<Warning />}
label={`+${warnings.length - 2} weitere`}
color={
warnings
.slice(2)
.some(
(w) =>
w.status === AusruestungStatus.Beschaedigt ||
w.status === AusruestungStatus.AusserDienst
)
? 'error'
: 'warning'
}
sx={{ fontSize: '0.7rem' }}
/>
</Tooltip>
)}
</Box>
)}
</CardContent>
</CardActionArea>
</Card>
);
};
// ── Fahrzeugtypen-Verwaltung (Einstellungen Tab) ─────────────────────────────
function FahrzeugTypenSettings() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: fahrzeugTypen = [], isLoading } = useQuery({
queryKey: ['fahrzeug-typen'],
queryFn: fahrzeugTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const [deleteError, setDeleteError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => fahrzeugTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp erstellt');
},
onError: () => showError('Fehler beim Erstellen'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<FahrzeugTyp> }) =>
fahrzeugTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => fahrzeugTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDeleteError(null);
showSuccess('Fahrzeugtyp gelöscht');
},
onError: (err: any) => {
const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.';
setDeleteError(msg);
},
});
const openCreate = () => {
setEditing(null);
setForm({ name: '', beschreibung: '', icon: '' });
setDialogOpen(true);
};
const openEdit = (t: FahrzeugTyp) => {
setEditing(t);
setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' });
setDialogOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) return;
if (editing) {
updateMutation.mutate({ id: editing.id, data: form });
} else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Fahrzeugtypen
</Typography>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
{deleteError}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Neuer Fahrzeugtyp
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
Keine Fahrzeugtypen vorhanden
</TableCell>
</TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? ''}</TableCell>
<TableCell>{t.icon ?? ''}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(t.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
<FormDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onSubmit={handleSubmit}
title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
isSubmitting={isSaving}
>
<TextField
label="Name *"
fullWidth
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<TextField
label="Beschreibung"
fullWidth
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
<TextField
label="Icon"
fullWidth
value={form.icon}
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck"
/>
</FormDialog>
</Box>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function Fahrzeuge() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
const { isAdmin } = usePermissions();
const { hasPermission } = usePermissionContext();
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
const canEditSettings = hasPermission('checklisten:manage_templates');
const fetchVehicles = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await vehiclesApi.getAll();
setVehicles(data);
} catch {
setError('Fahrzeuge konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchVehicles(); }, [fetchVehicles]);
useEffect(() => {
async function fetchWarnings() {
try {
const warnings = await equipmentApi.getVehicleWarnings();
const warningsMap = new Map<string, VehicleEquipmentWarning[]>();
warnings.forEach(w => {
const existing = warningsMap.get(w.fahrzeug_id) || [];
existing.push(w);
warningsMap.set(w.fahrzeug_id, existing);
});
setEquipmentWarnings(warningsMap);
} catch {
setEquipmentWarnings(new Map());
}
}
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();
return (
v.bezeichnung.toLowerCase().includes(q) ||
(v.kurzname?.toLowerCase().includes(q) ?? false) ||
(v.amtliches_kennzeichen?.toLowerCase().includes(q) ?? false) ||
(v.hersteller?.toLowerCase().includes(q) ?? false)
);
});
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
const hasOverdue = vehicles.some(
(v) =>
(v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) ||
(v.wartung_tage_bis_faelligkeit !== null && v.wartung_tage_bis_faelligkeit < 0)
);
return (
<DashboardLayout>
<Container maxWidth="xl">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Fahrzeugverwaltung
</Typography>
{!loading && (
<Typography variant="body2" color="text.secondary">
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
{' · '}
<Typography component="span" variant="body2" color="success.main" fontWeight={600}>
{einsatzbereit} einsatzbereit
</Typography>
</Typography>
)}
</Box>
{tab === 0 && (
<Button
variant="outlined"
size="small"
startIcon={<FileDownload />}
onClick={handleExportAlerts}
>
Prüfungen CSV
</Button>
)}
</Box>
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canEditSettings && <Tab label="Einstellungen" />}
</Tabs>
{tab === 0 && (
<>
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
</Alert>
)}
<TextField
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
size="small"
sx={{ mb: 3, maxWidth: 480 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{vehicles.length === 0
? 'Noch keine Fahrzeuge erfasst'
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
</Typography>
</Box>
)}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((vehicle) => (
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
<VehicleCard
vehicle={vehicle}
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
warnings={equipmentWarnings.get(vehicle.id) || []}
/>
</Grid>
))}
</Grid>
)}
{isAdmin && (
<ChatAwareFab
aria-label="Fahrzeug hinzufügen"
onClick={() => navigate('/fahrzeuge/neu')}
>
<Add />
</ChatAwareFab>
)}
</>
)}
{tab === 1 && canEditSettings && (
<FahrzeugTypenSettings />
)}
</Container>
</DashboardLayout>
);
}
export default Fahrzeuge;