482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
|
import {
|
|
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 {
|
|
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">
|
|
{/* 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">
|
|
{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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|
|
|
|
export default Ausruestung;
|