refine vehicle freatures
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
762
frontend/src/pages/AusruestungDetail.tsx
Normal file
762
frontend/src/pages/AusruestungDetail.tsx
Normal 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 '{equipment.bezeichnung}' 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;
|
||||
521
frontend/src/pages/AusruestungForm.tsx
Normal file
521
frontend/src/pages/AusruestungForm.tsx
Normal 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 & 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 & 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;
|
||||
@@ -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 '{vehicle.bezeichnung}' 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user