refine vehicle freatures

This commit is contained in:
Matthias Hochmeister
2026-02-28 17:19:18 +01:00
parent 0e81eabda6
commit e2be29c712
17 changed files with 4071 additions and 117 deletions

View File

@@ -1,66 +1,478 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Container,
Typography,
Card,
CardContent,
Alert,
Box,
Button,
Card,
CardActionArea,
CardContent,
Chip,
CircularProgress,
Container,
Fab,
FormControl,
FormControlLabel,
Grid,
InputAdornment,
InputLabel,
MenuItem,
Select,
Switch,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { Build } from '@mui/icons-material';
import {
Add,
Build,
CheckCircle,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Search,
Star,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import {
AusruestungListItem,
AusruestungKategorie,
AusruestungStatus,
AusruestungStatusLabel,
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
// ── Status chip config ────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<
AusruestungStatus,
{ color: 'success' | 'warning' | 'error' | 'default'; icon: React.ReactElement }
> = {
[AusruestungStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> },
[AusruestungStatus.Beschaedigt]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
[AusruestungStatus.InWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
[AusruestungStatus.AusserDienst]: { color: 'default', icon: <RemoveCircle 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(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 `Prüfung: ${date}`;
if (tage < 0) return `ÜBERFÄLLIG (${date})`;
if (tage === 0) return `Prüfung: heute`;
return `Prüfung: ${date}`;
}
function inspTooltipTitle(tage: number | null, faelligAm: string | null): string {
if (!faelligAm) return 'Keine Prüfung geplant';
const date = new Date(faelligAm).toLocaleDateString('de-DE');
if (tage !== null && tage < 0) {
return `Prüfung seit ${Math.abs(tage)} Tagen überfällig!`;
}
if (tage !== null && tage === 0) {
return 'Prüfung heute fällig';
}
if (tage !== null) {
return `Nächste Prüfung am ${date} (in ${tage} Tagen)`;
}
return `Nächste Prüfung am ${date}`;
}
// ── Equipment Card ────────────────────────────────────────────────────────────
interface EquipmentCardProps {
item: AusruestungListItem;
onClick: (id: string) => void;
}
const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
const status = item.status as AusruestungStatus;
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[AusruestungStatus.Einsatzbereit];
const isBeschaedigt = status === AusruestungStatus.Beschaedigt;
const pruefungLabel = inspBadgeLabel(item.pruefung_tage_bis_faelligkeit, item.naechste_pruefung_am);
const pruefungColor = inspBadgeColor(item.pruefung_tage_bis_faelligkeit);
const pruefungTooltip = inspTooltipTitle(item.pruefung_tage_bis_faelligkeit, item.naechste_pruefung_am);
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: isBeschaedigt ? '2px solid' : undefined,
borderColor: isBeschaedigt ? 'error.main' : undefined,
position: 'relative',
}}
>
{item.ist_wichtig && (
<Tooltip title="Wichtige Ausrüstung">
<Star
sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1, color: 'warning.main' }}
/>
</Tooltip>
)}
<CardActionArea
onClick={() => onClick(item.id)}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
<Box
sx={{
height: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
<Build sx={{ fontSize: 48, 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 sx={{ minWidth: 0 }}>
<Typography variant="h6" component="div" lineHeight={1.2} noWrap>
{item.bezeichnung}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Chip
label={item.kategorie_kurzname}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
</Box>
</Box>
</Box>
{/* Location */}
<Box sx={{ mt: 1 }}>
{item.fahrzeug_bezeichnung ? (
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LinkRounded fontSize="small" />
{item.fahrzeug_bezeichnung}
{item.fahrzeug_kurzname && ` (${item.fahrzeug_kurzname})`}
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
{item.standort}
</Typography>
)}
</Box>
{/* Serial number */}
{item.seriennummer && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, fontSize: '0.75rem' }}>
SN: {item.seriennummer}
</Typography>
)}
{/* Status chip */}
<Box sx={{ mt: 1, mb: 0.5 }}>
<Chip
icon={statusCfg.icon}
label={AusruestungStatusLabel[status]}
color={statusCfg.color}
size="small"
variant="outlined"
/>
</Box>
{/* Inspection badge */}
{pruefungLabel && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
<Tooltip title={pruefungTooltip}>
<Chip
size="small"
label={pruefungLabel}
color={pruefungColor}
variant={pruefungColor === 'default' ? 'outlined' : 'filled'}
icon={item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
? <Warning fontSize="small" />
: undefined}
sx={{ fontSize: '0.7rem' }}
/>
</Tooltip>
</Box>
)}
</CardContent>
</CardActionArea>
</Card>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function Ausruestung() {
const navigate = useNavigate();
const { canManageEquipment } = usePermissions();
// Data state
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
const [categories, setCategories] = useState<AusruestungKategorie[]>([]);
const [stats, setStats] = useState<EquipmentStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter state
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedStatus, setSelectedStatus] = useState('');
const [nurWichtige, setNurWichtige] = useState(false);
const [pruefungFaellig, setPruefungFaellig] = useState(false);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [equipmentData, categoriesData, statsData] = await Promise.all([
equipmentApi.getAll(),
equipmentApi.getCategories(),
equipmentApi.getStats(),
]);
setEquipment(equipmentData);
setCategories(categoriesData);
setStats(statsData);
} catch {
setError('Ausrüstung konnte nicht geladen werden. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
// Client-side filtering
const filtered = useMemo(() => {
return equipment.filter((item) => {
// Text search
if (search.trim()) {
const q = search.toLowerCase();
const matches =
item.bezeichnung.toLowerCase().includes(q) ||
(item.seriennummer?.toLowerCase().includes(q) ?? false) ||
(item.inventarnummer?.toLowerCase().includes(q) ?? false) ||
(item.hersteller?.toLowerCase().includes(q) ?? false);
if (!matches) return false;
}
// Category filter
if (selectedCategory && item.kategorie_id !== selectedCategory) {
return false;
}
// Status filter
if (selectedStatus && item.status !== selectedStatus) {
return false;
}
// Nur wichtige
if (nurWichtige && !item.ist_wichtig) {
return false;
}
// Prüfung fällig (within 30 days or overdue)
if (pruefungFaellig) {
if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) {
return false;
}
}
return true;
});
}, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]);
const hasOverdue = equipment.some(
(item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
);
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Ausrüstungsverwaltung
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Build color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Ausrüstung</Typography>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
{!loading && stats && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
{stats.total} Gesamt
</Typography>
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
<Typography variant="body2" color="success.main" fontWeight={600}>
{stats.einsatzbereit} Einsatzbereit
</Typography>
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
<Typography variant="body2" color="error.main" fontWeight={600}>
{stats.beschaedigt} Beschädigt
</Typography>
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
<Typography variant="body2" color={stats.inspectionsDue > 0 ? 'warning.main' : 'text.secondary'} fontWeight={stats.inspectionsDue > 0 ? 600 : 400}>
{stats.inspectionsDue + stats.inspectionsOverdue} Prüfung fällig
</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">
Inventarverwaltung
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Wartungsprüfungen und -protokolle
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Prüffristen und Erinnerungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Schutzausrüstung (PSA)
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Atemschutzgeräte und -wartung
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
)}
</Box>
</Box>
{/* Overdue alert */}
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist.
</Alert>
)}
{/* Filter controls */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 3, alignItems: 'center' }}>
<TextField
placeholder="Suchen (Bezeichnung, Seriennr., Inventarnr., Hersteller...)"
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Kategorie</InputLabel>
<Select
value={selectedCategory}
label="Kategorie"
onChange={(e) => setSelectedCategory(e.target.value)}
>
<MenuItem value="">Alle Kategorien</MenuItem>
{categories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={selectedStatus}
label="Status"
onChange={(e) => setSelectedStatus(e.target.value)}
>
<MenuItem value="">Alle Status</MenuItem>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>
{AusruestungStatusLabel[s]}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={nurWichtige}
onChange={(e) => setNurWichtige(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Nur wichtige</Typography>}
/>
<FormControlLabel
control={
<Switch
checked={pruefungFaellig}
onChange={(e) => setPruefungFaellig(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Prüfung fällig</Typography>}
/>
</Box>
{/* Loading state */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* Error state */}
{!loading && error && (
<Alert
severity="error"
sx={{ mb: 2 }}
action={
<Button color="inherit" size="small" onClick={fetchData}>
Erneut versuchen
</Button>
}
>
{error}
</Alert>
)}
{/* Empty states */}
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Build sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{equipment.length === 0
? 'Keine Ausrüstung vorhanden'
: 'Keine Ausrüstung gefunden'}
</Typography>
</Box>
)}
{/* Equipment grid */}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((item) => (
<Grid item key={item.id} xs={12} sm={6} md={4} lg={3}>
<EquipmentCard
item={item}
onClick={(id) => navigate(`/ausruestung/${id}`)}
/>
</Grid>
))}
</Grid>
)}
{/* FAB for adding new equipment */}
{canManageEquipment && (
<Fab
color="primary"
aria-label="Ausrüstung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => navigate('/ausruestung/neu')}
>
<Add />
</Fab>
)}
</Container>
</DashboardLayout>
);

View File

@@ -0,0 +1,762 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
FormControl,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
ArrowBack,
Build,
CheckCircle,
DeleteOutline,
Edit,
Error as ErrorIcon,
MoreHoriz,
PauseCircle,
RemoveCircle,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import {
AusruestungDetail,
AusruestungWartungslog,
AusruestungWartungslogArt,
AusruestungStatus,
AusruestungStatusLabel,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
// -- Tab Panel ----------------------------------------------------------------
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
// -- Status config ------------------------------------------------------------
const STATUS_ICONS: Record<AusruestungStatus, React.ReactElement> = {
[AusruestungStatus.Einsatzbereit]: <CheckCircle color="success" />,
[AusruestungStatus.Beschaedigt]: <ErrorIcon color="error" />,
[AusruestungStatus.InWartung]: <PauseCircle color="warning" />,
[AusruestungStatus.AusserDienst]: <RemoveCircle color="action" />,
};
const STATUS_CHIP_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
[AusruestungStatus.Einsatzbereit]: 'success',
[AusruestungStatus.Beschaedigt]: 'error',
[AusruestungStatus.InWartung]: 'warning',
[AusruestungStatus.AusserDienst]: 'default',
};
// -- Date helpers -------------------------------------------------------------
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '---';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
}
// -- Wartungslog Art config ---------------------------------------------------
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
'Prüfung': 'info',
'Reparatur': 'warning',
'Sonstiges': 'default',
};
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
'Prüfung': <Verified color="info" />,
'Reparatur': <Build color="warning" />,
'Sonstiges': <MoreHoriz color="action" />,
default: <Build color="action" />,
};
const ERGEBNIS_CHIP_COLOR: Record<string, 'success' | 'warning' | 'error'> = {
bestanden: 'success',
bestanden_mit_maengeln: 'warning',
nicht_bestanden: 'error',
};
const ERGEBNIS_LABEL: Record<string, string> = {
bestanden: 'Bestanden',
bestanden_mit_maengeln: 'Bestanden (mit Mängeln)',
nicht_bestanden: 'Nicht bestanden',
};
// -- Uebersicht Tab -----------------------------------------------------------
interface UebersichtTabProps {
equipment: AusruestungDetail;
onStatusUpdated: () => void;
canChangeStatus: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const openDialog = () => {
setNewStatus(equipment.status);
setBemerkung(equipment.status_bemerkung ?? '');
setSaveError(null);
setStatusDialogOpen(true);
};
const closeDialog = () => {
setSaveError(null);
setStatusDialogOpen(false);
};
const handleSaveStatus = async () => {
try {
setSaving(true);
setSaveError(null);
const payload: UpdateAusruestungStatusPayload = { status: newStatus, bemerkung };
await equipmentApi.updateStatus(equipment.id, payload);
setStatusDialogOpen(false);
onStatusUpdated();
} catch {
setSaveError('Status konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
const isBeschaedigt = equipment.status === AusruestungStatus.Beschaedigt;
// Inspection deadline
const pruefTage = equipment.pruefung_tage_bis_faelligkeit;
// Data grid fields
const dataFields: { label: string; value: React.ReactNode }[] = [
{ label: 'Kategorie', value: equipment.kategorie_name },
{ label: 'Seriennummer', value: equipment.seriennummer ?? '---' },
{ label: 'Inventarnummer', value: equipment.inventarnummer ?? '---' },
{ label: 'Hersteller', value: equipment.hersteller ?? '---' },
{ label: 'Baujahr', value: equipment.baujahr ?? '---' },
{
label: 'Fahrzeug',
value: equipment.fahrzeug_id ? (
<Typography
component={RouterLink}
to={`/fahrzeuge/${equipment.fahrzeug_id}`}
variant="body1"
sx={{ color: 'primary.main', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
{equipment.fahrzeug_bezeichnung}
</Typography>
) : '---',
},
...(!equipment.fahrzeug_id ? [{ label: 'Standort', value: equipment.standort || '---' }] : []),
{
label: 'Wichtig',
value: equipment.ist_wichtig ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Star fontSize="small" color="warning" /> Ja
</Box>
) : 'Nein',
},
{
label: 'Prüfintervall',
value: equipment.pruef_intervall_monate
? `${equipment.pruef_intervall_monate} Monate`
: '---',
},
{ label: 'Letzte Prüfung', value: fmtDate(equipment.letzte_pruefung_am) },
{
label: 'Nächste Prüfung',
value: equipment.naechste_pruefung_am ? (
<Box>
<Typography variant="body1">{fmtDate(equipment.naechste_pruefung_am)}</Typography>
{pruefTage !== null && pruefTage < 0 && (
<Chip
size="small"
color="error"
icon={<Warning fontSize="small" />}
label={`${Math.abs(pruefTage)} Tage überfällig`}
sx={{ mt: 0.5 }}
/>
)}
{pruefTage !== null && pruefTage >= 0 && pruefTage <= 30 && (
<Chip
size="small"
color="warning"
label={`in ${pruefTage} Tagen`}
sx={{ mt: 0.5 }}
/>
)}
{pruefTage !== null && pruefTage > 30 && (
<Chip
size="small"
color="success"
label={`in ${pruefTage} Tagen`}
sx={{ mt: 0.5 }}
/>
)}
</Box>
) : (
<Chip size="small" color="default" label="Nicht festgelegt" />
),
},
{ label: 'Bemerkung', value: equipment.bemerkung ?? '---' },
];
return (
<Box>
{isBeschaedigt && (
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 2 }}>
<strong>Beschädigt</strong> --- dieses Gerät ist nicht einsatzbereit.
{equipment.status_bemerkung && ` Bemerkung: ${equipment.status_bemerkung}`}
</Alert>
)}
{/* Status panel */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{STATUS_ICONS[equipment.status]}
<Box>
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
<Chip
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
size="small"
/>
{equipment.status_bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{equipment.status_bemerkung}
</Typography>
)}
</Box>
</Box>
{canChangeStatus && (
<Button variant="outlined" size="small" onClick={openDialog}>
Status ändern
</Button>
)}
</Box>
</Paper>
{/* Equipment data grid */}
<Grid container spacing={2}>
{dataFields.map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
{typeof value === 'string' || typeof value === 'number' ? (
<Typography variant="body1">{value}</Typography>
) : (
<Box>{value}</Box>
)}
</Grid>
))}
</Grid>
{/* Status change dialog */}
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>Gerätestatus ändern</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<FormControl fullWidth sx={{ mb: 2, mt: 1 }}>
<InputLabel id="status-select-label">Neuer Status</InputLabel>
<Select
labelId="status-select-label"
label="Neuer Status"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as AusruestungStatus)}
>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Bemerkung (optional)"
fullWidth
multiline
rows={3}
value={bemerkung}
onChange={(e) => setBemerkung(e.target.value)}
placeholder="z.B. Gerät zur Reparatur eingeschickt, voraussichtlich ab 01.03. wieder einsatzbereit"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveStatus}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// -- Wartung Tab --------------------------------------------------------------
interface WartungTabProps {
equipmentId: string;
wartungslog: AusruestungWartungslog[];
onAdded: () => void;
canWrite: boolean;
}
const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const emptyForm: CreateAusruestungWartungslogPayload = {
datum: '',
art: 'Prüfung' as AusruestungWartungslogArt,
beschreibung: '',
ergebnis: undefined,
kosten: undefined,
pruefende_stelle: undefined,
};
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
const handleSubmit = async () => {
if (!form.datum || !form.art || !form.beschreibung.trim()) {
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
await equipmentApi.addWartungslog(equipmentId, {
...form,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
});
setDialogOpen(false);
setForm(emptyForm);
onAdded();
} catch {
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
// Sort wartungslog by datum DESC
const sorted = [...wartungslog].sort(
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
);
return (
<Box>
{sorted.length === 0 ? (
<Typography color="text.secondary">Keine Wartungseinträge vorhanden.</Typography>
) : (
<Stack divider={<Divider />} spacing={0}>
{sorted.map((entry) => {
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
const ergebnisColor = entry.ergebnis ? ERGEBNIS_CHIP_COLOR[entry.ergebnis] : undefined;
const ergebnisLabel = entry.ergebnis ? ERGEBNIS_LABEL[entry.ergebnis] : undefined;
return (
<Box key={entry.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ mt: 0.25 }}>{artIcon}</Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && (
<Chip
label={entry.art}
size="small"
color={WARTUNG_ART_CHIP_COLOR[entry.art] ?? 'default'}
variant="outlined"
/>
)}
{ergebnisLabel && ergebnisColor && (
<Chip
label={ergebnisLabel}
size="small"
color={ergebnisColor}
/>
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`,
entry.pruefende_stelle && entry.pruefende_stelle,
].filter(Boolean).join(' · ')}
</Typography>
</Box>
</Box>
);
})}
</Stack>
)}
{canWrite && (
<Fab
color="primary"
size="small"
aria-label="Wartung eintragen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
>
<Add />
</Fab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Prüfung eintragen</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}>
<TextField
label="Datum *"
type="date"
fullWidth
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Art *</InputLabel>
<Select
label="Art *"
value={form.art ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
art: (e.target.value || undefined) as AusruestungWartungslogArt,
}))
}
>
<MenuItem value="">--- Bitte wählen ---</MenuItem>
{(['Prüfung', 'Reparatur', 'Sonstiges'] as AusruestungWartungslogArt[]).map((a) => (
<MenuItem key={a} value={a}>{a}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<TextField
label="Beschreibung *"
fullWidth
multiline
rows={3}
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
ergebnis: e.target.value || undefined,
}))
}
>
<MenuItem value="">--- Kein Ergebnis ---</MenuItem>
<MenuItem value="bestanden">Bestanden</MenuItem>
<MenuItem value="bestanden_mit_maengeln">Bestanden (mit Mängeln)</MenuItem>
<MenuItem value="nicht_bestanden">Nicht bestanden</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Kosten (EUR)"
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="Prüfende Stelle"
fullWidth
value={form.pruefende_stelle ?? ''}
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
placeholder="Name der prüfenden Stelle oder Person"
/>
</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>
);
};
// -- Main Page ----------------------------------------------------------------
function AusruestungDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const notification = useNotification();
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fetchEquipment = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await equipmentApi.getById(id);
setEquipment(data);
} catch {
setError('Gerät konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { fetchEquipment(); }, [fetchEquipment]);
const handleDelete = async () => {
if (!id) return;
try {
setDeleteLoading(true);
await equipmentApi.delete(id);
notification.showSuccess('Gerät wurde erfolgreich gelöscht.');
navigate('/ausruestung');
} catch {
notification.showError('Gerät konnte nicht gelöscht werden.');
setDeleteDialogOpen(false);
setDeleteLoading(false);
}
};
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error || !equipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">{error ?? 'Gerät nicht gefunden.'}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
Zurück zur Übersicht
</Button>
</Container>
</DashboardLayout>
);
}
const hasOverdue =
equipment.pruefung_tage_bis_faelligkeit !== null &&
equipment.pruefung_tage_bis_faelligkeit < 0;
const subtitle = [
equipment.kategorie_name,
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
].filter(Boolean).join(' · ');
return (
<DashboardLayout>
<Container maxWidth="lg">
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/ausruestung')}
sx={{ mb: 2 }}
size="small"
>
Ausrüstungsübersicht
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Build sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
<Typography variant="h4" component="h1">
{equipment.bezeichnung}
</Typography>
{subtitle && (
<Typography variant="subtitle1" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
icon={STATUS_ICONS[equipment.status]}
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
/>
{canChangeStatus && (
<Tooltip title="Gerät bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
aria-label="Gerät bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Gerät löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Gerät löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Ausrüstung Detailansicht"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
}
/>
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canChangeStatus}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canChangeStatus}
/>
</TabPanel>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Gerät löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie &apos;{equipment.bezeichnung}&apos; wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteLoading}
autoFocus
>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);
}
export default AusruestungDetailPage;

View File

@@ -0,0 +1,521 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
FormControl,
FormControlLabel,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Switch,
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 { equipmentApi } from '../services/equipment';
import { vehiclesApi } from '../services/vehicles';
import {
AusruestungStatus,
AusruestungStatusLabel,
CreateAusruestungPayload,
UpdateAusruestungPayload,
AusruestungKategorie,
} from '../types/equipment.types';
import type { FahrzeugListItem } from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// -- Form state shape ---------------------------------------------------------
interface FormState {
bezeichnung: string;
kategorie_id: string;
seriennummer: string;
inventarnummer: string;
hersteller: string;
baujahr: string; // stored as string for input, converted to number on submit
status: AusruestungStatus;
status_bemerkung: string;
ist_wichtig: boolean;
fahrzeug_id: string;
standort: string;
pruef_intervall_monate: string;
letzte_pruefung_am: string;
naechste_pruefung_am: string;
bemerkung: string;
}
const EMPTY_FORM: FormState = {
bezeichnung: '',
kategorie_id: '',
seriennummer: '',
inventarnummer: '',
hersteller: '',
baujahr: '',
status: AusruestungStatus.Einsatzbereit,
status_bemerkung: '',
ist_wichtig: false,
fahrzeug_id: '',
standort: 'Lager',
pruef_intervall_monate: '',
letzte_pruefung_am: '',
naechste_pruefung_am: '',
bemerkung: '',
};
// -- 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 AusruestungForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { canChangeStatus } = usePermissions();
const isEditMode = Boolean(id);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canChangeStatus) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
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>>>({});
// -- Lookup data ------------------------------------------------------------
const [categories, setCategories] = useState<AusruestungKategorie[]>([]);
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const fetchLookups = useCallback(async () => {
try {
const [cats, vehs] = await Promise.all([
equipmentApi.getCategories(),
vehiclesApi.getAll(),
]);
setCategories(cats);
setVehicles(vehs);
} catch {
// Non-critical: dropdowns will be empty but form still usable
}
}, []);
const fetchEquipment = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const equipment = await equipmentApi.getById(id);
setForm({
bezeichnung: equipment.bezeichnung,
kategorie_id: equipment.kategorie_id,
seriennummer: equipment.seriennummer ?? '',
inventarnummer: equipment.inventarnummer ?? '',
hersteller: equipment.hersteller ?? '',
baujahr: equipment.baujahr?.toString() ?? '',
status: equipment.status,
status_bemerkung: equipment.status_bemerkung ?? '',
ist_wichtig: equipment.ist_wichtig,
fahrzeug_id: equipment.fahrzeug_id ?? '',
standort: equipment.standort ?? 'Lager',
pruef_intervall_monate: equipment.pruef_intervall_monate?.toString() ?? '',
letzte_pruefung_am: toDateInput(equipment.letzte_pruefung_am),
naechste_pruefung_am: toDateInput(equipment.naechste_pruefung_am),
bemerkung: equipment.bemerkung ?? '',
});
} catch {
setError('Ausrüstung konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
fetchLookups();
}, [fetchLookups]);
useEffect(() => {
if (isEditMode) fetchEquipment();
}, [isEditMode, fetchEquipment]);
// -- Validation -------------------------------------------------------------
const validate = (): boolean => {
const errors: Partial<Record<keyof FormState, string>> = {};
if (!form.bezeichnung.trim()) {
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
}
if (!form.kategorie_id) {
errors.kategorie_id = 'Kategorie ist erforderlich.';
}
if (form.baujahr) {
const year = parseInt(form.baujahr, 10);
if (isNaN(year) || year < 1950 || year > 2100) {
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
}
}
if (form.pruef_intervall_monate) {
const months = parseInt(form.pruef_intervall_monate, 10);
if (isNaN(months) || months < 1 || months > 120) {
errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.';
}
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
// -- Submit -----------------------------------------------------------------
const handleSubmit = async () => {
if (!validate()) return;
try {
setSaving(true);
setSaveError(null);
if (isEditMode && id) {
const payload: UpdateAusruestungPayload = {
bezeichnung: form.bezeichnung.trim() || undefined,
kategorie_id: form.kategorie_id || undefined,
seriennummer: form.seriennummer.trim() || undefined,
inventarnummer: form.inventarnummer.trim() || undefined,
hersteller: form.hersteller.trim() || undefined,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
ist_wichtig: form.ist_wichtig,
fahrzeug_id: form.fahrzeug_id || null,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
bemerkung: form.bemerkung.trim() || undefined,
};
await equipmentApi.update(id, payload);
navigate(`/ausruestung/${id}`);
} else {
const payload: CreateAusruestungPayload = {
bezeichnung: form.bezeichnung.trim(),
kategorie_id: form.kategorie_id,
seriennummer: form.seriennummer.trim() || undefined,
inventarnummer: form.inventarnummer.trim() || undefined,
hersteller: form.hersteller.trim() || undefined,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
ist_wichtig: form.ist_wichtig,
fahrzeug_id: form.fahrzeug_id || undefined,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
bemerkung: form.bemerkung.trim() || undefined,
};
const created = await equipmentApi.create(payload);
navigate(`/ausruestung/${created.id}`);
}
} catch {
setSaveError(
isEditMode
? 'Ausrüstung konnte nicht gespeichert werden.'
: 'Ausrüstung konnte nicht erstellt werden.'
);
} finally {
setSaving(false);
}
};
// -- Field helper -----------------------------------------------------------
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],
});
// -- Loading / Error early returns ------------------------------------------
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('/ausruestung')} sx={{ mt: 2 }}>
Zurück
</Button>
</Container>
</DashboardLayout>
);
}
// -- Render -----------------------------------------------------------------
return (
<DashboardLayout>
<Container maxWidth="md">
<Button
startIcon={<ArrowBack />}
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
sx={{ mb: 2 }}
size="small"
>
{isEditMode ? 'Zurück zur Detailansicht' : 'Ausrüstungsübersicht'}
</Button>
<Typography variant="h4" gutterBottom>
{isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
</Typography>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Paper variant="outlined" sx={{ p: 3 }}>
{/* ── Section: Grunddaten ──────────────────────────────────────────── */}
<Typography variant="h6" gutterBottom>Grunddaten</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={8}>
<TextField
label="Bezeichnung *"
fullWidth
{...f('bezeichnung')}
inputProps={{ maxLength: 200 }}
placeholder="z.B. Atemschutzgerät Dräger PSS 5000"
/>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth error={Boolean(fieldErrors.kategorie_id)}>
<InputLabel>Kategorie *</InputLabel>
<Select
label="Kategorie *"
value={form.kategorie_id}
onChange={(e) => setForm((prev) => ({ ...prev, kategorie_id: e.target.value as string }))}
>
{categories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{cat.name}
</MenuItem>
))}
</Select>
{fieldErrors.kategorie_id && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>
{fieldErrors.kategorie_id}
</Typography>
)}
</FormControl>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Seriennummer"
fullWidth
{...f('seriennummer')}
inputProps={{ maxLength: 100 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Inventarnummer"
fullWidth
{...f('inventarnummer')}
inputProps={{ maxLength: 50 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Hersteller"
fullWidth
{...f('hersteller')}
inputProps={{ maxLength: 150 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Baujahr"
type="number"
fullWidth
{...f('baujahr')}
inputProps={{ min: 1950, max: 2100 }}
/>
</Grid>
</Grid>
{/* ── Section: Status & Zuordnung ──────────────────────────────────── */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status &amp; Zuordnung</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 AusruestungStatus }))}
>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{form.status !== AusruestungStatus.Einsatzbereit && (
<Grid item xs={12} sm={8}>
<TextField
label="Status-Bemerkung"
fullWidth
multiline
{...f('status_bemerkung')}
placeholder="z.B. Defektes Ventil, Reparatur beauftragt"
/>
</Grid>
)}
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={form.ist_wichtig}
onChange={(e) => setForm((prev) => ({ ...prev, ist_wichtig: e.target.checked }))}
/>
}
label="Wichtiges Gerät (Warnung auf Fahrzeugkarte wenn nicht einsatzbereit)"
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Fahrzeug</InputLabel>
<Select
label="Fahrzeug"
value={form.fahrzeug_id}
onChange={(e) => setForm((prev) => ({ ...prev, fahrzeug_id: e.target.value as string }))}
>
<MenuItem value="">Kein Fahrzeug (Lager)</MenuItem>
{vehicles.map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}{v.kurzname ? ` (${v.kurzname})` : ''}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{!form.fahrzeug_id && (
<Grid item xs={12} sm={6}>
<TextField
label="Standort"
fullWidth
{...f('standort')}
placeholder="z.B. Lager, Regal A3"
/>
</Grid>
)}
</Grid>
{/* ── Section: Pruefung & Wartung ───────────────────────────────────── */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüfung &amp; Wartung</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField
label="Prüfintervall (Monate)"
type="number"
fullWidth
{...f('pruef_intervall_monate')}
inputProps={{ min: 1, max: 120 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Letzte Prüfung"
type="date"
fullWidth
value={form.letzte_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Nächste Prüfung"
type="date"
fullWidth
value={form.naechste_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
{/* ── Section: Bemerkungen ──────────────────────────────────────────── */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Bemerkungen</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Bemerkung"
fullWidth
multiline
rows={3}
{...f('bemerkung')}
placeholder="Zusätzliche Informationen zum Gerät"
/>
</Grid>
</Grid>
</Paper>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
<Button
variant="outlined"
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
>
Abbrechen
</Button>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={16} /> : <Save />}
onClick={handleSubmit}
disabled={saving}
>
{isEditMode ? 'Änderungen speichern' : 'Gerät erstellen'}
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
export default AusruestungForm;

View File

@@ -9,6 +9,7 @@ import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
@@ -16,11 +17,18 @@ import {
Grid,
IconButton,
InputLabel,
Link,
MenuItem,
Paper,
Select,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
@@ -32,6 +40,7 @@ import {
Assignment,
Build,
CheckCircle,
DeleteOutline,
DirectionsCar,
Edit,
Error as ErrorIcon,
@@ -40,12 +49,14 @@ import {
PauseCircle,
ReportProblem,
School,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import {
FahrzeugDetail,
FahrzeugWartungslog,
@@ -55,7 +66,10 @@ import {
UpdateStatusPayload,
WartungslogArt,
} from '../types/vehicle.types';
import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
// ── Tab Panel ─────────────────────────────────────────────────────────────────
@@ -195,12 +209,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
{ label: 'Kurzname', value: vehicle.kurzname },
{ label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen },
{ label: 'Fahrgestellnr.', value: vehicle.fahrgestellnummer },
{ label: 'Baujahr', value: vehicle.baujahr?.toString() },
{ label: 'Hersteller', value: vehicle.hersteller },
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort },
].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
@@ -492,17 +500,153 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
);
};
// ── Ausrüstung Tab ───────────────────────────────────────────────────────────
const EQUIPMENT_STATUS_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
[AusruestungStatus.Einsatzbereit]: 'success',
[AusruestungStatus.Beschaedigt]: 'error',
[AusruestungStatus.InWartung]: 'warning',
[AusruestungStatus.AusserDienst]: 'default',
};
function pruefungBadgeColor(tage: number | null): 'success' | 'warning' | 'error' | 'default' {
if (tage === null) return 'default';
if (tage < 0) return 'error';
if (tage <= 30) return 'warning';
return 'success';
}
interface AusruestungTabProps {
equipment: AusruestungListItem[];
vehicleId: string;
}
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId }) => {
const navigate = useNavigate();
const hasProblems = equipment.some(
(e) => e.status === AusruestungStatus.Beschaedigt || e.status === AusruestungStatus.InWartung
);
if (equipment.length === 0) {
return (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Assignment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Keine Ausrüstung zugewiesen
</Typography>
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
Diesem Fahrzeug ist derzeit keine Ausrüstung zugeordnet.
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => navigate('/ausruestung')}
>
Zur Ausrüstungsverwaltung
</Button>
</Box>
);
}
return (
<Box>
{hasProblems && (
<Alert severity="warning" icon={<Warning />} sx={{ mb: 2 }}>
<strong>Achtung:</strong> Eine oder mehrere Ausrüstungen dieses Fahrzeugs sind beschädigt oder in Wartung.
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell>Status</TableCell>
<TableCell align="center">Wichtig</TableCell>
<TableCell>Nächste Prüfung</TableCell>
</TableRow>
</TableHead>
<TableBody>
{equipment.map((item) => {
const statusColor = EQUIPMENT_STATUS_COLOR[item.status] ?? 'default';
const pruefTage = item.pruefung_tage_bis_faelligkeit;
const pruefColor = pruefungBadgeColor(pruefTage);
return (
<TableRow key={item.id} hover>
<TableCell>
<Link
component="button"
variant="body2"
fontWeight={600}
underline="hover"
onClick={() => navigate(`/ausruestung/${item.id}`)}
sx={{ textAlign: 'left' }}
>
{item.bezeichnung}
</Link>
</TableCell>
<TableCell>
<Chip label={item.kategorie_kurzname} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Chip
label={AusruestungStatusLabel[item.status]}
size="small"
color={statusColor}
/>
</TableCell>
<TableCell align="center">
{item.ist_wichtig && (
<Tooltip title="Wichtige Ausrüstung">
<Star fontSize="small" color="warning" />
</Tooltip>
)}
</TableCell>
<TableCell>
{item.naechste_pruefung_am ? (
<Chip
size="small"
color={pruefColor}
variant={pruefColor === 'default' ? 'outlined' : 'filled'}
label={
pruefTage !== null && pruefTage < 0
? `ÜBERFÄLLIG (${fmtDate(item.naechste_pruefung_am)})`
: fmtDate(item.naechste_pruefung_am)
}
icon={pruefTage !== null && pruefTage < 0 ? <Warning fontSize="small" /> : undefined}
/>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function FahrzeugDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const notification = useNotification();
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]);
const fetchVehicle = useCallback(async () => {
if (!id) return;
@@ -511,6 +655,13 @@ function FahrzeugDetail() {
setError(null);
const data = await vehiclesApi.getById(id);
setVehicle(data);
// Fetch equipment separately — failure must not break the page
try {
const eq = await equipmentApi.getByVehicle(id);
setVehicleEquipment(eq);
} catch {
setVehicleEquipment([]);
}
} catch {
setError('Fahrzeug konnte nicht geladen werden.');
} finally {
@@ -520,6 +671,20 @@ function FahrzeugDetail() {
useEffect(() => { fetchVehicle(); }, [fetchVehicle]);
const handleDeleteVehicle = async () => {
if (!id) return;
try {
setDeleteLoading(true);
await vehiclesApi.delete(id);
notification.showSuccess('Fahrzeug wurde erfolgreich gelöscht.');
navigate('/fahrzeuge');
} catch {
notification.showError('Fahrzeug konnte nicht gelöscht werden.');
setDeleteDialogOpen(false);
setDeleteLoading(false);
}
};
if (loading) {
return (
<DashboardLayout>
@@ -573,7 +738,6 @@ function FahrzeugDetail() {
{vehicle.amtliches_kennzeichen && (
<Typography variant="subtitle1" color="text.secondary">
{vehicle.amtliches_kennzeichen}
{vehicle.hersteller && ` · ${vehicle.hersteller}`}
</Typography>
)}
</Box>
@@ -594,6 +758,18 @@ function FahrzeugDetail() {
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Fahrzeug löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Fahrzeug löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
@@ -614,6 +790,7 @@ function FahrzeugDetail() {
}
/>
<Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
</Tabs>
</Box>
@@ -645,6 +822,38 @@ function FahrzeugDetail() {
</Typography>
</Box>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
</TabPanel>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Fahrzeug löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie das Fahrzeug &apos;{vehicle.bezeichnung}&apos; wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteLoading}
autoFocus
>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteVehicle}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);

View File

@@ -24,6 +24,7 @@ import {
CreateFahrzeugPayload,
UpdateFahrzeugPayload,
} from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// ── Form state shape ──────────────────────────────────────────────────────────
@@ -74,8 +75,30 @@ function toDateInput(iso: string | null | undefined): string {
function FahrzeugForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin } = usePermissions();
const isEditMode = Boolean(id);
// ── Permission guard: only admins may create or edit vehicles ──────────────
if (!isAdmin) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
Zurück zur Fahrzeugübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
@@ -121,9 +144,6 @@ function FahrzeugForm() {
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;
};
@@ -261,53 +281,6 @@ function FahrzeugForm() {
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>

View File

@@ -31,6 +31,9 @@ import {
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
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,
@@ -86,9 +89,10 @@ function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: str
interface VehicleCardProps {
vehicle: FahrzeugListItem;
onClick: (id: string) => void;
warnings?: VehicleEquipmentWarning[];
}
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
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;
@@ -183,13 +187,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
/>
</Box>
{vehicle.besatzung_soll && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Besatzung: {vehicle.besatzung_soll}
{vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`}
</Typography>
)}
{inspBadges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{inspBadges.map((b) => {
@@ -214,6 +211,18 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
})}
</Box>
)}
{warnings.length > 0 && (
<Tooltip title={warnings.map(w => `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`).join('\n')}>
<Chip
size="small"
icon={<Warning />}
label={`${warnings.length} Ausrüstung nicht bereit`}
color={warnings.some(w => w.status === AusruestungStatus.Beschaedigt) ? 'error' : 'warning'}
sx={{ mt: 0.5 }}
/>
</Tooltip>
)}
</CardContent>
</CardActionArea>
</Card>
@@ -229,6 +238,7 @@ function Fahrzeuge() {
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 {
@@ -245,6 +255,26 @@ function Fahrzeuge() {
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 filtered = vehicles.filter((v) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
@@ -337,6 +367,7 @@ function Fahrzeuge() {
<VehicleCard
vehicle={vehicle}
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
warnings={equipmentWarnings.get(vehicle.id) || []}
/>
</Grid>
))}