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

749 lines
26 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,
Paper,
Select,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
Add as AddIcon,
Build,
CheckCircle,
Delete,
Edit,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Search,
Star,
Warning,
} from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen';
import {
AusruestungListItem,
AusruestungKategorie,
AusruestungStatus,
AusruestungStatusLabel,
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { ConfirmDialog, FormDialog } from '../components/templates';
// ── 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>
);
};
// ── Ausrüstungstypen-Verwaltung (Einstellungen Tab) ──────────────────────────
function AusruestungTypenSettings() {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const { data: typen = [], isLoading, isError } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTyp, setEditingTyp] = useState<AusruestungTyp | null>(null);
const [formName, setFormName] = useState('');
const [formBeschreibung, setFormBeschreibung] = useState('');
const [formIcon, setFormIcon] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(null);
const createMutation = useMutation({
mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) =>
ausruestungTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ erstellt');
closeDialog();
},
onError: () => showError('Typ konnte nicht erstellt werden'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) =>
ausruestungTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ aktualisiert');
closeDialog();
},
onError: () => showError('Typ konnte nicht aktualisiert werden'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => ausruestungTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ gelöscht');
setDeleteDialogOpen(false);
setDeletingTyp(null);
},
onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'),
});
const openAddDialog = () => {
setEditingTyp(null);
setFormName('');
setFormBeschreibung('');
setFormIcon('');
setDialogOpen(true);
};
const openEditDialog = (typ: AusruestungTyp) => {
setEditingTyp(typ);
setFormName(typ.name);
setFormBeschreibung(typ.beschreibung ?? '');
setFormIcon(typ.icon ?? '');
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingTyp(null);
};
const handleSave = () => {
if (!formName.trim()) return;
const data = {
name: formName.trim(),
beschreibung: formBeschreibung.trim() || undefined,
icon: formIcon.trim() || undefined,
};
if (editingTyp) {
updateMutation.mutate({ id: editingTyp.id, data });
} else {
createMutation.mutate(data);
}
};
const openDeleteDialog = (typ: AusruestungTyp) => {
setDeletingTyp(typ);
setDeleteDialogOpen(true);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Typen konnten nicht geladen werden.
</Alert>
)}
{!isLoading && !isError && (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{typen.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Noch keine Typen vorhanden.
</Typography>
</TableCell>
</TableRow>
)}
{typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell>{typ.name}</TableCell>
<TableCell>{typ.beschreibung || '---'}</TableCell>
<TableCell>{typ.icon || '---'}</TableCell>
<TableCell align="right">
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(typ)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" startIcon={<AddIcon />} onClick={openAddDialog}>
Neuer Typ
</Button>
</Box>
</Paper>
)}
{/* Add/Edit dialog */}
<FormDialog
open={dialogOpen}
onClose={closeDialog}
onSubmit={handleSave}
title={editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
isSubmitting={isSaving}
>
<TextField
label="Name *"
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Beschreibung"
fullWidth
multiline
rows={2}
value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)}
/>
<TextField
label="Icon (MUI Icon-Name)"
fullWidth
value={formIcon}
onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment"
/>
</FormDialog>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
title="Typ löschen"
message={<>Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
confirmLabel="Löschen"
confirmColor="error"
isLoading={deleteMutation.isPending}
/>
</Box>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function Ausruestung() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
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>
</Box>
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canManageTypes && <Tab label="Einstellungen" />}
</Tabs>
{tab === 0 && (
<>
{/* 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>
)}
</>
)}
{tab === 1 && canManageTypes && (
<AusruestungTypenSettings />
)}
</Container>
</DashboardLayout>
);
}
export default Ausruestung;