749 lines
26 KiB
TypeScript
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 "{deletingTyp?.name}" 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;
|