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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user