- Migration 074: convert checklist vorlage single FK fields to junction tables (vorlage_fahrzeug_typen, vorlage_fahrzeuge, vorlage_ausruestung_typen, vorlage_ausruestungen) - Backend checklist service: multi-type create/update/query with array fields - Backend cleanup service: add checklist-history and reset-checklist-history targets - Frontend types/service: singular FK fields replaced with arrays (fahrzeug_typ_ids, etc.) - Frontend Checklisten.tsx: multi-select Autocomplete pickers for all assignment types - Fahrzeuge.tsx/Ausruestung.tsx: add tab layout (Uebersicht + Einstellungen), inline type CRUD - FahrzeugEinstellungen/AusruestungEinstellungen: replaced with redirects to tab URLs - Sidebar: add Uebersicht sub-items, update Einstellungen paths to tab URLs - DataManagementTab: add checklist-history cleanup and reset sections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
696 lines
24 KiB
TypeScript
696 lines
24 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Alert,
|
||
Box,
|
||
Button,
|
||
Card,
|
||
CardActionArea,
|
||
CardContent,
|
||
CardMedia,
|
||
Chip,
|
||
CircularProgress,
|
||
Container,
|
||
Dialog,
|
||
DialogActions,
|
||
DialogContent,
|
||
DialogTitle,
|
||
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';
|
||
|
||
// ── 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>
|
||
</>
|
||
)}
|
||
|
||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||
<DialogTitle>
|
||
{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
|
||
</DialogTitle>
|
||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||
<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"
|
||
/>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||
<Button
|
||
variant="contained"
|
||
onClick={handleSubmit}
|
||
disabled={isSaving || !form.name.trim()}
|
||
>
|
||
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</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;
|