Files
dashboard/frontend/src/pages/Ausruestung.tsx

518 lines
18 KiB
TypeScript

import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Alert,
Box,
Button,
Card,
CardActionArea,
CardContent,
Chip,
CircularProgress,
Container,
FormControl,
FormControlLabel,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Select,
Switch,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
Build,
CheckCircle,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Search,
Settings as SettingsIcon,
Star,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import {
AusruestungListItem,
AusruestungKategorie,
AusruestungStatus,
AusruestungStatusLabel,
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import ChatAwareFab from '../components/shared/ChatAwareFab';
// ── 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, flexWrap: 'wrap' }}>
<Chip
label={item.kategorie_kurzname}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
{item.typen?.map((t) => (
<Chip
key={t.id}
label={t.name}
size="small"
color="primary"
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, hasPermission } = usePermissions();
const canManageTypes = hasPermission('ausruestung:manage_types');
// 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);
// Equipment types for filter
const { data: typen = [] } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
});
// Filter state
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedTyp, setSelectedTyp] = 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) => {
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;
}
if (selectedCategory && item.kategorie_id !== selectedCategory) return false;
if (selectedTyp) {
const typId = parseInt(selectedTyp, 10);
if (!item.typen?.some((t) => t.id === typId)) return false;
}
if (selectedStatus && item.status !== selectedStatus) return false;
if (nurWichtige && !item.ist_wichtig) return false;
if (pruefungFaellig) {
if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) return false;
}
return true;
});
}, [equipment, search, selectedCategory, selectedTyp, 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: 1 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
</Box>
{!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>
{canManageTypes && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=ausruestung-typen')}>
<SettingsIcon />
</IconButton>
</Tooltip>
)}
</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>Typ</InputLabel>
<Select
value={selectedTyp}
label="Typ"
onChange={(e) => setSelectedTyp(e.target.value)}
>
<MenuItem value="">Alle Typen</MenuItem>
{typen.map((t) => (
<MenuItem key={t.id} value={String(t.id)}>
{t.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 && (
<ChatAwareFab
color="primary"
aria-label="Ausrüstung hinzufügen"
onClick={() => navigate('/ausruestung/neu')}
>
<Add />
</ChatAwareFab>
)}
</>
</Container>
</DashboardLayout>
);
}
export default Ausruestung;