Files
dashboard/frontend/src/pages/Fahrzeuge.tsx
Matthias Hochmeister f3ad989a9e update
2026-03-16 15:01:09 +01:00

472 lines
16 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Card,
CardActionArea,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
Grid,
InputAdornment,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
CheckCircle,
DirectionsCar,
Error as ErrorIcon,
FileDownload,
PauseCircle,
School,
Search,
Warning,
ReportProblem,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import type { VehicleEquipmentWarning } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import {
FahrzeugListItem,
FahrzeugStatus,
FahrzeugStatusLabel,
} from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// ── 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>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function Fahrzeuge() {
const navigate = useNavigate();
const { isAdmin } = usePermissions();
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 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]);
// Fetch equipment warnings separately — must not block or delay vehicle list rendering
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 {
// Silently fail — equipment warnings are non-critical
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;
// An overdue inspection exists if §57a OR Wartung is past due
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: 3 }}>
<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>
<Button
variant="outlined"
size="small"
startIcon={<FileDownload />}
onClick={handleExportAlerts}
>
Prüfungen CSV
</Button>
</Box>
{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>
)}
</Container>
</DashboardLayout>
);
}
export default Fahrzeuge;