add features
This commit is contained in:
@@ -1,66 +1,360 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Fab,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { DirectionsCar } from '@mui/icons-material';
|
||||
import {
|
||||
Add,
|
||||
CheckCircle,
|
||||
DirectionsCar,
|
||||
Error as ErrorIcon,
|
||||
PauseCircle,
|
||||
School,
|
||||
Search,
|
||||
Warning,
|
||||
ReportProblem,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
FahrzeugListItem,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
PruefungArt,
|
||||
PruefungArtLabel,
|
||||
} from '../types/vehicle.types';
|
||||
|
||||
// ── 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" /> },
|
||||
[FahrzeugStatus.InLehrgang]: { color: 'info', icon: <School 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 {
|
||||
const artShort = art; // 'HU', 'AU', etc.
|
||||
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 `${artShort}: ${date}`;
|
||||
if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`;
|
||||
if (tage === 0) return `${artShort}: heute (${date})`;
|
||||
return `${artShort}: ${date}`;
|
||||
}
|
||||
|
||||
// ── Vehicle Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface VehicleCardProps {
|
||||
vehicle: FahrzeugListItem;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
const status = vehicle.status as FahrzeugStatus;
|
||||
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
||||
|
||||
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
||||
|
||||
// Collect inspection badges (only for types where a faellig_am exists)
|
||||
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
|
||||
{ art: 'HU', tage: vehicle.hu_tage_bis_faelligkeit, faelligAm: vehicle.hu_faellig_am },
|
||||
{ art: 'AU', tage: vehicle.au_tage_bis_faelligkeit, faelligAm: vehicle.au_faellig_am },
|
||||
{ art: 'UVV', tage: vehicle.uvv_tage_bis_faelligkeit, faelligAm: vehicle.uvv_faellig_am },
|
||||
{ art: 'Leiter', tage: vehicle.leiter_tage_bis_faelligkeit, faelligAm: vehicle.leiter_faellig_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 image / placeholder */}
|
||||
{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' }}>
|
||||
{/* Title row */}
|
||||
<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>
|
||||
|
||||
{/* Status badge */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Chip
|
||||
icon={statusCfg.icon}
|
||||
label={FahrzeugStatusLabel[status]}
|
||||
color={statusCfg.color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Crew config */}
|
||||
{vehicle.besatzung_soll && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Besatzung: {vehicle.besatzung_soll}
|
||||
{vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Inspection badges */}
|
||||
{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={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${
|
||||
b.tage !== null && b.tage < 0
|
||||
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
|
||||
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Fahrzeuge() {
|
||||
const navigate = useNavigate();
|
||||
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
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]);
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
// Summary counts
|
||||
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
|
||||
const hasOverdue = vehicles.some(
|
||||
(v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Fahrzeugverwaltung
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<DirectionsCar color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h6">Fahrzeuge</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Diese Funktion wird in Kürze verfügbar sein
|
||||
<Container maxWidth="xl">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Geplante Features:
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fahrzeugliste mit Details
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Wartungspläne und -historie
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tankbuch und Kilometerstände
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
TÜV/HU Erinnerungen
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fahrzeugdokumentation
|
||||
</Typography>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Overdue inspection global warning */}
|
||||
{hasOverdue && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist.
|
||||
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<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 state */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{/* Vehicle grid */}
|
||||
{!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}`)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="Fahrzeug hinzufügen"
|
||||
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||
onClick={() => navigate('/fahrzeuge/neu')}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user