rework vehicle handling
This commit is contained in:
@@ -22,13 +22,6 @@ import {
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Timeline,
|
||||
TimelineConnector,
|
||||
TimelineContent,
|
||||
TimelineDot,
|
||||
TimelineItem,
|
||||
TimelineOppositeContent,
|
||||
TimelineSeparator,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
@@ -39,6 +32,7 @@ import {
|
||||
Build,
|
||||
CheckCircle,
|
||||
DirectionsCar,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
LocalFireDepartment,
|
||||
PauseCircle,
|
||||
@@ -51,16 +45,12 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
FahrzeugDetail,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugWartungslog,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
PruefungArt,
|
||||
PruefungArtLabel,
|
||||
CreatePruefungPayload,
|
||||
CreateWartungslogPayload,
|
||||
UpdateStatusPayload,
|
||||
WartungslogArt,
|
||||
PruefungErgebnis,
|
||||
} from '../types/vehicle.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
@@ -125,11 +115,24 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const openDialog = () => {
|
||||
setNewStatus(vehicle.status);
|
||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||
setSaveError(null);
|
||||
setStatusDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setSaveError(null);
|
||||
setStatusDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleSaveStatus = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung });
|
||||
const payload: UpdateStatusPayload = { status: newStatus, bemerkung };
|
||||
await vehiclesApi.updateStatus(vehicle.id, payload);
|
||||
setStatusDialogOpen(false);
|
||||
onStatusUpdated();
|
||||
} catch {
|
||||
@@ -141,6 +144,12 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
|
||||
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
|
||||
|
||||
// Inspection deadline badges
|
||||
const inspItems: { label: string; faelligAm: string | null; tage: number | null }[] = [
|
||||
{ label: '§57a Periodische Prüfung', faelligAm: vehicle.paragraph57a_faellig_am, tage: vehicle.paragraph57a_tage_bis_faelligkeit },
|
||||
{ label: 'Nächste Wartung / Service', faelligAm: vehicle.naechste_wartung_am, tage: vehicle.wartung_tage_bis_faelligkeit },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isSchaden && (
|
||||
@@ -156,9 +165,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{STATUS_ICONS[vehicle.status]}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Aktueller Status
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
|
||||
<Chip
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
@@ -171,18 +178,11 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setNewStatus(vehicle.status);
|
||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||
setStatusDialogOpen(true);
|
||||
}}
|
||||
sx={{ display: canChangeStatus ? undefined : 'none' }}
|
||||
>
|
||||
Status ändern
|
||||
</Button>
|
||||
{canChangeStatus && (
|
||||
<Button variant="outlined" size="small" onClick={openDialog}>
|
||||
Status ändern
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -198,8 +198,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
|
||||
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
|
||||
{ label: 'Standort', value: vehicle.standort },
|
||||
{ label: '§57a fällig am', value: fmtDate(vehicle.paragraph57a_faellig_am) !== '—' ? fmtDate(vehicle.paragraph57a_faellig_am) : null },
|
||||
{ label: 'Nächste Wartung', value: fmtDate(vehicle.naechste_wartung_am) !== '—' ? fmtDate(vehicle.naechste_wartung_am) : null },
|
||||
].map(({ label, value }) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={label}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
@@ -210,48 +208,40 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Inspection status quick view */}
|
||||
{/* Inspection deadline quick view */}
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
||||
Prüffristen Übersicht
|
||||
Prüf- und Wartungsfristen
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => {
|
||||
const art = key.toUpperCase() as PruefungArt;
|
||||
const label = PruefungArtLabel[art] ?? key;
|
||||
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
|
||||
{inspItems.map(({ label, faelligAm, tage }) => {
|
||||
const color = inspectionBadgeColor(tage);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={3} key={key}>
|
||||
<Grid item xs={12} sm={6} key={label}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
{label}
|
||||
</Typography>
|
||||
{ps.faellig_am ? (
|
||||
{faelligAm ? (
|
||||
<>
|
||||
<Chip
|
||||
size="small"
|
||||
color={color}
|
||||
label={
|
||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
||||
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})`
|
||||
: `Fällig: ${fmtDate(ps.faellig_am)}`
|
||||
}
|
||||
icon={
|
||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
||||
? <Warning fontSize="small" />
|
||||
: undefined
|
||||
tage !== null && tage < 0
|
||||
? `ÜBERFÄLLIG (${fmtDate(faelligAm)})`
|
||||
: `Fällig: ${fmtDate(faelligAm)}`
|
||||
}
|
||||
icon={tage !== null && tage < 0 ? <Warning fontSize="small" /> : undefined}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
{ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && (
|
||||
{tage !== null && tage >= 0 && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
in {ps.tage_bis_faelligkeit} Tagen
|
||||
in {tage} Tagen
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
Keine Daten
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled">Kein Datum erfasst</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
@@ -260,12 +250,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
</Grid>
|
||||
|
||||
{/* Status change dialog */}
|
||||
<Dialog
|
||||
open={statusDialogOpen}
|
||||
onClose={() => setStatusDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
|
||||
<DialogContent>
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
@@ -278,9 +263,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
|
||||
>
|
||||
{Object.values(FahrzeugStatus).map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{FahrzeugStatusLabel[s]}
|
||||
</MenuItem>
|
||||
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -295,7 +278,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={closeDialog}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveStatus}
|
||||
@@ -310,247 +293,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
);
|
||||
};
|
||||
|
||||
// ── Prüfungen Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PruefungenTabProps {
|
||||
fahrzeugId: string;
|
||||
pruefungen: FahrzeugPruefung[];
|
||||
onAdded: () => void;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
|
||||
bestanden: 'Bestanden',
|
||||
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
|
||||
nicht_bestanden: 'Nicht bestanden',
|
||||
ausstehend: 'Ausstehend',
|
||||
};
|
||||
|
||||
const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error' | 'default'> = {
|
||||
bestanden: 'success',
|
||||
bestanden_mit_maengeln: 'warning',
|
||||
nicht_bestanden: 'error',
|
||||
ausstehend: 'default',
|
||||
};
|
||||
|
||||
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const emptyForm: CreatePruefungPayload = {
|
||||
pruefung_art: PruefungArt.HU,
|
||||
faellig_am: '',
|
||||
durchgefuehrt_am: '',
|
||||
ergebnis: 'ausstehend',
|
||||
pruefende_stelle: '',
|
||||
kosten: undefined,
|
||||
bemerkung: '',
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<CreatePruefungPayload>(emptyForm);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.faellig_am) {
|
||||
setSaveError('Fälligkeitsdatum ist erforderlich.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const payload: CreatePruefungPayload = {
|
||||
...form,
|
||||
durchgefuehrt_am: form.durchgefuehrt_am || undefined,
|
||||
kosten: form.kosten !== undefined && form.kosten !== null ? Number(form.kosten) : undefined,
|
||||
};
|
||||
await vehiclesApi.addPruefung(fahrzeugId, payload);
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
onAdded();
|
||||
} catch {
|
||||
setSaveError('Prüfung konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{pruefungen.length === 0 ? (
|
||||
<Typography color="text.secondary">Noch keine Prüfungen erfasst.</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{pruefungen.map((p) => {
|
||||
const ergebnis = (p.ergebnis ?? 'ausstehend') as PruefungErgebnis;
|
||||
const isFaellig = !p.durchgefuehrt_am && new Date(p.faellig_am) < new Date();
|
||||
return (
|
||||
<Box key={p.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<Box sx={{ minWidth: 140 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{PruefungArtLabel[p.pruefung_art] ?? p.pruefung_art}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
Fällig: {fmtDate(p.faellig_am)}
|
||||
</Typography>
|
||||
{isFaellig && !p.durchgefuehrt_am && (
|
||||
<Chip label="ÜBERFÄLLIG" color="error" size="small" sx={{ mt: 0.5 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
|
||||
<Chip
|
||||
label={ERGEBNIS_LABELS[ergebnis]}
|
||||
color={ERGEBNIS_COLORS[ergebnis]}
|
||||
size="small"
|
||||
/>
|
||||
{p.durchgefuehrt_am && (
|
||||
<Chip
|
||||
label={`Durchgeführt: ${fmtDate(p.durchgefuehrt_am)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{p.naechste_faelligkeit && (
|
||||
<Chip
|
||||
label={`Nächste: ${fmtDate(p.naechste_faelligkeit)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{p.pruefende_stelle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{p.pruefende_stelle}
|
||||
{p.kosten != null && ` · ${p.kosten.toFixed(2)} €`}
|
||||
</Typography>
|
||||
)}
|
||||
{p.bemerkung && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{p.bemerkung}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* FAB */}
|
||||
{canWrite && (
|
||||
<Fab
|
||||
color="primary"
|
||||
size="small"
|
||||
aria-label="Prüfung hinzufügen"
|
||||
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
)}
|
||||
|
||||
{/* Add inspection dialog */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Prüfung erfassen</DialogTitle>
|
||||
<DialogContent>
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Prüfungsart</InputLabel>
|
||||
<Select
|
||||
label="Prüfungsart"
|
||||
value={form.pruefung_art}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pruefung_art: e.target.value as PruefungArt }))}
|
||||
>
|
||||
{Object.values(PruefungArt).map((art) => (
|
||||
<MenuItem key={art} value={art}>{PruefungArtLabel[art]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Ergebnis</InputLabel>
|
||||
<Select
|
||||
label="Ergebnis"
|
||||
value={form.ergebnis ?? 'ausstehend'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ergebnis: e.target.value as PruefungErgebnis }))}
|
||||
>
|
||||
{(Object.keys(ERGEBNIS_LABELS) as PruefungErgebnis[]).map((e) => (
|
||||
<MenuItem key={e} value={e}>{ERGEBNIS_LABELS[e]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Fällig am *"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.faellig_am}
|
||||
onChange={(e) => setForm((f) => ({ ...f, faellig_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Durchgeführt am"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.durchgefuehrt_am ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Prüfende Stelle"
|
||||
fullWidth
|
||||
value={form.pruefende_stelle ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
|
||||
placeholder="z.B. TÜV Süd Stuttgart"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Kosten (€)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.kosten ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Bemerkung"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={form.bemerkung ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bemerkung: e.target.value }))}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Wartung Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface WartungTabProps {
|
||||
@@ -561,11 +303,11 @@ interface WartungTabProps {
|
||||
}
|
||||
|
||||
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
||||
Kraftstoff: <LocalFireDepartment color="action" />,
|
||||
Reparatur: <Build color="warning" />,
|
||||
Inspektion: <Assignment color="primary" />,
|
||||
Hauptuntersuchung:<CheckCircle color="success" />,
|
||||
default: <Build color="action" />,
|
||||
Kraftstoff: <LocalFireDepartment color="action" />,
|
||||
Reparatur: <Build color="warning" />,
|
||||
Inspektion: <Assignment color="primary" />,
|
||||
Hauptuntersuchung: <CheckCircle color="success" />,
|
||||
default: <Build color="action" />,
|
||||
};
|
||||
|
||||
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
|
||||
@@ -612,8 +354,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
||||
{wartungslog.length === 0 ? (
|
||||
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
|
||||
) : (
|
||||
// MUI Timeline is available via @mui/lab — using Paper list as fallback
|
||||
// since @mui/lab is not in current package.json
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{wartungslog.map((entry) => {
|
||||
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
|
||||
@@ -623,9 +363,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
||||
{entry.art && (
|
||||
<Chip label={entry.art} size="small" variant="outlined" />
|
||||
)}
|
||||
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
@@ -795,11 +533,7 @@ function FahrzeugDetail() {
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
</Container>
|
||||
@@ -807,10 +541,13 @@ function FahrzeugDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasOverdue =
|
||||
(vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) ||
|
||||
(vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Breadcrumb / back */}
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
@@ -820,7 +557,6 @@ function FahrzeugDetail() {
|
||||
Fahrzeugübersicht
|
||||
</Button>
|
||||
|
||||
{/* Page title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
|
||||
<Box>
|
||||
@@ -839,16 +575,26 @@ function FahrzeugDetail() {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={STATUS_ICONS[vehicle.status]}
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Tooltip title="Fahrzeug bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
|
||||
aria-label="Fahrzeug bearbeiten"
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -858,42 +604,35 @@ function FahrzeugDetail() {
|
||||
<Tab label="Übersicht" />
|
||||
<Tab
|
||||
label={
|
||||
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0
|
||||
hasOverdue
|
||||
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Prüfungen <Warning color="error" fontSize="small" />
|
||||
Wartung <Warning color="error" fontSize="small" />
|
||||
</Box>
|
||||
: 'Prüfungen'
|
||||
: 'Wartung'
|
||||
}
|
||||
/>
|
||||
<Tab label="Wartung" />
|
||||
<Tab label="Einsätze" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab content */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<PruefungenTab
|
||||
fahrzeugId={vehicle.id}
|
||||
pruefungen={vehicle.pruefungen}
|
||||
onAdded={fetchVehicle}
|
||||
canWrite={isAdmin}
|
||||
<UebersichtTab
|
||||
vehicle={vehicle}
|
||||
onStatusUpdated={fetchVehicle}
|
||||
canChangeStatus={canChangeStatus}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<WartungTab
|
||||
fahrzeugId={vehicle.id}
|
||||
wartungslog={vehicle.wartungslog}
|
||||
onAdded={fetchVehicle}
|
||||
canWrite={isAdmin}
|
||||
canWrite={canChangeStatus}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
|
||||
400
frontend/src/pages/FahrzeugForm.tsx
Normal file
400
frontend/src/pages/FahrzeugForm.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Save } from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
CreateFahrzeugPayload,
|
||||
UpdateFahrzeugPayload,
|
||||
} from '../types/vehicle.types';
|
||||
|
||||
// ── Form state shape ──────────────────────────────────────────────────────────
|
||||
|
||||
interface FormState {
|
||||
bezeichnung: string;
|
||||
kurzname: string;
|
||||
amtliches_kennzeichen: string;
|
||||
fahrgestellnummer: string;
|
||||
baujahr: string; // kept as string for input, parsed on submit
|
||||
hersteller: string;
|
||||
typ_schluessel: string;
|
||||
besatzung_soll: string;
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string;
|
||||
standort: string;
|
||||
bild_url: string;
|
||||
paragraph57a_faellig_am: string; // ISO date 'YYYY-MM-DD' or ''
|
||||
naechste_wartung_am: string; // ISO date 'YYYY-MM-DD' or ''
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
bezeichnung: '',
|
||||
kurzname: '',
|
||||
amtliches_kennzeichen: '',
|
||||
fahrgestellnummer: '',
|
||||
baujahr: '',
|
||||
hersteller: '',
|
||||
typ_schluessel: '',
|
||||
besatzung_soll: '',
|
||||
status: FahrzeugStatus.Einsatzbereit,
|
||||
status_bemerkung: '',
|
||||
standort: 'Feuerwehrhaus',
|
||||
bild_url: '',
|
||||
paragraph57a_faellig_am: '',
|
||||
naechste_wartung_am: '',
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */
|
||||
function toDateInput(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function FahrzeugForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isEditMode = Boolean(id);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [loading, setLoading] = useState(isEditMode);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
|
||||
const fetchVehicle = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const vehicle = await vehiclesApi.getById(id);
|
||||
setForm({
|
||||
bezeichnung: vehicle.bezeichnung,
|
||||
kurzname: vehicle.kurzname ?? '',
|
||||
amtliches_kennzeichen: vehicle.amtliches_kennzeichen ?? '',
|
||||
fahrgestellnummer: vehicle.fahrgestellnummer ?? '',
|
||||
baujahr: vehicle.baujahr?.toString() ?? '',
|
||||
hersteller: vehicle.hersteller ?? '',
|
||||
typ_schluessel: vehicle.typ_schluessel ?? '',
|
||||
besatzung_soll: vehicle.besatzung_soll ?? '',
|
||||
status: vehicle.status,
|
||||
status_bemerkung: vehicle.status_bemerkung ?? '',
|
||||
standort: vehicle.standort,
|
||||
bild_url: vehicle.bild_url ?? '',
|
||||
paragraph57a_faellig_am: toDateInput(vehicle.paragraph57a_faellig_am),
|
||||
naechste_wartung_am: toDateInput(vehicle.naechste_wartung_am),
|
||||
});
|
||||
} catch {
|
||||
setError('Fahrzeug konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) fetchVehicle();
|
||||
}, [isEditMode, fetchVehicle]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!form.bezeichnung.trim()) {
|
||||
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
|
||||
}
|
||||
if (form.baujahr && (isNaN(Number(form.baujahr)) || Number(form.baujahr) < 1950 || Number(form.baujahr) > 2100)) {
|
||||
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
if (isEditMode && id) {
|
||||
const payload: UpdateFahrzeugPayload = {
|
||||
bezeichnung: form.bezeichnung.trim() || undefined,
|
||||
kurzname: form.kurzname.trim() || undefined,
|
||||
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
|
||||
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
|
||||
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
|
||||
hersteller: form.hersteller.trim() || undefined,
|
||||
typ_schluessel: form.typ_schluessel.trim() || undefined,
|
||||
besatzung_soll: form.besatzung_soll.trim() || undefined,
|
||||
status: form.status,
|
||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||
standort: form.standort.trim() || 'Feuerwehrhaus',
|
||||
bild_url: form.bild_url.trim() || undefined,
|
||||
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
|
||||
naechste_wartung_am: form.naechste_wartung_am || undefined,
|
||||
};
|
||||
await vehiclesApi.update(id, payload);
|
||||
navigate(`/fahrzeuge/${id}`);
|
||||
} else {
|
||||
const payload: CreateFahrzeugPayload = {
|
||||
bezeichnung: form.bezeichnung.trim(),
|
||||
kurzname: form.kurzname.trim() || undefined,
|
||||
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
|
||||
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
|
||||
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
|
||||
hersteller: form.hersteller.trim() || undefined,
|
||||
typ_schluessel: form.typ_schluessel.trim() || undefined,
|
||||
besatzung_soll: form.besatzung_soll.trim() || undefined,
|
||||
status: form.status,
|
||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||
standort: form.standort.trim() || 'Feuerwehrhaus',
|
||||
bild_url: form.bild_url.trim() || undefined,
|
||||
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
|
||||
naechste_wartung_am: form.naechste_wartung_am || undefined,
|
||||
};
|
||||
const newVehicle = await vehiclesApi.create(payload);
|
||||
navigate(`/fahrzeuge/${newVehicle.id}`);
|
||||
}
|
||||
} catch {
|
||||
setSaveError(
|
||||
isEditMode
|
||||
? 'Fahrzeug konnte nicht gespeichert werden.'
|
||||
: 'Fahrzeug konnte nicht erstellt werden.'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const f = (field: keyof FormState) => ({
|
||||
value: form[field] as string,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value })),
|
||||
error: Boolean(fieldErrors[field]),
|
||||
helperText: fieldErrors[field],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
||||
Zurück
|
||||
</Button>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
{isEditMode ? 'Zurück zur Detailansicht' : 'Fahrzeugübersicht'}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
|
||||
</Typography>
|
||||
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Stammdaten</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Bezeichnung *"
|
||||
fullWidth
|
||||
{...f('bezeichnung')}
|
||||
placeholder="z.B. HLF 20/16"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Kurzname"
|
||||
fullWidth
|
||||
{...f('kurzname')}
|
||||
placeholder="z.B. HLF 1"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Amtl. Kennzeichen"
|
||||
fullWidth
|
||||
{...f('amtliches_kennzeichen')}
|
||||
placeholder="z.B. WN-FW 1"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Fahrgestellnummer (VIN)"
|
||||
fullWidth
|
||||
{...f('fahrgestellnummer')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Baujahr"
|
||||
type="number"
|
||||
fullWidth
|
||||
{...f('baujahr')}
|
||||
inputProps={{ min: 1950, max: 2100 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Hersteller"
|
||||
fullWidth
|
||||
{...f('hersteller')}
|
||||
placeholder="z.B. MAN TGM / Rosenbauer"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Typ-Schlüssel (DIN 14502)"
|
||||
fullWidth
|
||||
{...f('typ_schluessel')}
|
||||
placeholder="z.B. LF 10"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={3}>
|
||||
<TextField
|
||||
label="Besatzung (Soll)"
|
||||
fullWidth
|
||||
{...f('besatzung_soll')}
|
||||
placeholder="z.B. 1/8"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={3}>
|
||||
<TextField
|
||||
label="Standort"
|
||||
fullWidth
|
||||
{...f('standort')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value as FahrzeugStatus }))}
|
||||
>
|
||||
{Object.values(FahrzeugStatus).map((s) => (
|
||||
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Status-Bemerkung"
|
||||
fullWidth
|
||||
{...f('status_bemerkung')}
|
||||
placeholder="z.B. Fahrzeug in Werkstatt bis 01.03."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="§57a fällig am"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.paragraph57a_faellig_am}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
helperText="Periodische Begutachtung (§57a StVO)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Nächste Wartung am"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.naechste_wartung_am}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
helperText="Nächster geplanter Servicetermin"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Bild</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Bild-URL"
|
||||
fullWidth
|
||||
{...f('bild_url')}
|
||||
placeholder="https://..."
|
||||
helperText="Direktlink zu einem Fahrzeugfoto (https://)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={16} /> : <Save />}
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
>
|
||||
{isEditMode ? 'Änderungen speichern' : 'Fahrzeug erstellen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default FahrzeugForm;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
@@ -35,8 +35,6 @@ import {
|
||||
FahrzeugListItem,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
PruefungArt,
|
||||
PruefungArtLabel,
|
||||
} from '../types/vehicle.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
@@ -64,13 +62,23 @@ function inspBadgeColor(tage: number | null): InspBadgeColor {
|
||||
}
|
||||
|
||||
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}`;
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
@@ -81,15 +89,23 @@ interface VehicleCardProps {
|
||||
}
|
||||
|
||||
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
const status = vehicle.status as FahrzeugStatus;
|
||||
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: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am },
|
||||
{ art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am },
|
||||
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 (
|
||||
@@ -116,7 +132,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
onClick={() => onClick(vehicle.id)}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
{/* Vehicle image / placeholder */}
|
||||
{vehicle.bild_url ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
@@ -140,7 +155,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
)}
|
||||
|
||||
<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}>
|
||||
@@ -159,7 +173,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Status badge */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Chip
|
||||
icon={statusCfg.icon}
|
||||
@@ -170,7 +183,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Crew config */}
|
||||
{vehicle.besatzung_soll && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Besatzung: {vehicle.besatzung_soll}
|
||||
@@ -178,7 +190,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Inspection badges */}
|
||||
{inspBadges.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{inspBadges.map((b) => {
|
||||
@@ -188,11 +199,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
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')}`
|
||||
}`}
|
||||
title={inspTooltipTitle(b.fullLabel, b.tage, b.faelligAm)}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
@@ -249,16 +256,18 @@ function Fahrzeuge() {
|
||||
);
|
||||
});
|
||||
|
||||
// Summary counts
|
||||
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.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0
|
||||
(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">
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||
@@ -268,12 +277,7 @@ function Fahrzeuge() {
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
|
||||
{' · '}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="success.main"
|
||||
fontWeight={600}
|
||||
>
|
||||
<Typography component="span" variant="body2" color="success.main" fontWeight={600}>
|
||||
{einsatzbereit} einsatzbereit
|
||||
</Typography>
|
||||
</Typography>
|
||||
@@ -281,15 +285,12 @@ function Fahrzeuge() {
|
||||
</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.
|
||||
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<TextField
|
||||
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
||||
value={search}
|
||||
@@ -306,21 +307,18 @@ function Fahrzeuge() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 }} />
|
||||
@@ -332,7 +330,6 @@ function Fahrzeuge() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Vehicle grid */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<Grid container spacing={3}>
|
||||
{filtered.map((vehicle) => (
|
||||
@@ -346,7 +343,6 @@ function Fahrzeuge() {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
||||
{isAdmin && (
|
||||
<Fab
|
||||
color="primary"
|
||||
|
||||
Reference in New Issue
Block a user