feat: checklist multi-type assignments, tab layouts for Fahrzeuge/Ausruestung, admin cleanup

- Migration 074: convert checklist vorlage single FK fields to junction tables
  (vorlage_fahrzeug_typen, vorlage_fahrzeuge, vorlage_ausruestung_typen, vorlage_ausruestungen)
- Backend checklist service: multi-type create/update/query with array fields
- Backend cleanup service: add checklist-history and reset-checklist-history targets
- Frontend types/service: singular FK fields replaced with arrays (fahrzeug_typ_ids, etc.)
- Frontend Checklisten.tsx: multi-select Autocomplete pickers for all assignment types
- Fahrzeuge.tsx/Ausruestung.tsx: add tab layout (Uebersicht + Einstellungen), inline type CRUD
- FahrzeugEinstellungen/AusruestungEinstellungen: replaced with redirects to tab URLs
- Sidebar: add Uebersicht sub-items, update Einstellungen paths to tab URLs
- DataManagementTab: add checklist-history cleanup and reset sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-28 18:57:46 +01:00
parent 893fbe43a0
commit 4349de9bc9
14 changed files with 1078 additions and 1188 deletions

View File

@@ -1,434 +1,4 @@
import { useState } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { Add, ArrowBack, Delete, Edit, Save, Close } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen';
import { equipmentApi } from '../services/equipment';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
function AusruestungEinstellungen() {
const navigate = useNavigate();
const { hasPermission } = usePermissions();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const canManageTypes = hasPermission('ausruestung:manage_types');
// Data
const { data: typen = [], isLoading, isError } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
});
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTyp, setEditingTyp] = useState<AusruestungTyp | null>(null);
const [formName, setFormName] = useState('');
const [formBeschreibung, setFormBeschreibung] = useState('');
const [formIcon, setFormIcon] = useState('');
// Delete dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(null);
// Mutations
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;
// Permission guard
if (!canManageTypes) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>Keine Berechtigung</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstungstypen zu verwalten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="lg">
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/ausruestung')}
sx={{ mb: 2 }}
size="small"
>
Ausrüstungsübersicht
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Ausrüstungs-Einstellungen</Typography>
</Box>
{/* Ausrüstungstypen Section */}
<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={<Add />} onClick={openAddDialog}>
Neuer Typ
</Button>
</Box>
</Paper>
)}
{/* Add/Edit dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
<IconButton onClick={closeDialog} size="small"><Close /></IconButton>
</DialogTitle>
<DialogContent>
<TextField
label="Name *"
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
sx={{ mt: 1, mb: 2 }}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Beschreibung"
fullWidth
multiline
rows={2}
value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
label="Icon (MUI Icon-Name)"
fullWidth
value={formIcon}
onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formName.trim()}
startIcon={isSaving ? <CircularProgress size={16} /> : <Save />}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteMutation.isPending && setDeleteDialogOpen(false)}>
<DialogTitle>Typ löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen?
Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)} disabled={deleteMutation.isPending}>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
disabled={deleteMutation.isPending}
startIcon={deleteMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
<Divider sx={{ my: 4 }} />
<EquipmentTypeAssignment allTypes={typen} />
</Container>
</DashboardLayout>
);
}
export default AusruestungEinstellungen;
// ── Per-equipment type assignment ──────────────────────────────────────────────
function EquipmentTypeAssignment({ allTypes }: { allTypes: AusruestungTyp[] }) {
const { showSuccess, showError } = useNotification();
const { data: equipment = [], isLoading } = useQuery({
queryKey: ['equipment'],
queryFn: equipmentApi.getAll,
});
const [assignDialog, setAssignDialog] = useState<{ equipmentId: string; equipmentName: string } | null>(null);
const [selected, setSelected] = useState<AusruestungTyp[]>([]);
const [saving, setSaving] = useState(false);
const [equipmentTypesMap, setEquipmentTypesMap] = useState<Record<string, AusruestungTyp[]>>({});
const openAssign = async (equipmentId: string, equipmentName: string) => {
let current = equipmentTypesMap[equipmentId];
if (!current) {
try { current = await ausruestungTypenApi.getTypesForEquipment(equipmentId); }
catch { current = []; }
setEquipmentTypesMap((m) => ({ ...m, [equipmentId]: current }));
}
setSelected(current);
setAssignDialog({ equipmentId, equipmentName });
};
const handleSave = async () => {
if (!assignDialog) return;
try {
setSaving(true);
await ausruestungTypenApi.setTypesForEquipment(assignDialog.equipmentId, selected.map((t) => t.id));
setEquipmentTypesMap((m) => ({ ...m, [assignDialog.equipmentId]: selected }));
setAssignDialog(null);
showSuccess('Typen gespeichert');
} catch {
showError('Fehler beim Speichern');
} finally {
setSaving(false);
}
};
return (
<>
<Typography variant="h6" gutterBottom>Typzuweisung je Gerät</Typography>
{isLoading ? (
<CircularProgress size={24} />
) : (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Gerät</TableCell>
<TableCell>Zugewiesene Typen</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{equipment.map((e) => {
const types = equipmentTypesMap[e.id];
return (
<TableRow key={e.id} hover>
<TableCell>{e.bezeichnung}</TableCell>
<TableCell>
{types === undefined ? (
<Typography variant="body2" color="text.disabled"></Typography>
) : types.length === 0 ? (
<Typography variant="body2" color="text.disabled">Keine</Typography>
) : (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{types.map((t) => <Chip key={t.id} label={t.name} size="small" variant="outlined" />)}
</Box>
)}
</TableCell>
<TableCell align="right">
<Tooltip title="Typen zuweisen">
<IconButton size="small" onClick={() => openAssign(e.id, e.bezeichnung)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
<Dialog open={!!assignDialog} onClose={() => setAssignDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Typen für {assignDialog?.equipmentName}
<IconButton size="small" onClick={() => setAssignDialog(null)}><Close /></IconButton>
</DialogTitle>
<DialogContent sx={{ mt: 1 }}>
<Autocomplete
multiple
options={allTypes}
getOptionLabel={(o) => o.name}
value={selected}
onChange={(_e, val) => setSelected(val)}
isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Ausrüstungstypen" />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setAssignDialog(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : <Save />}>
Speichern
</Button>
</DialogActions>
</Dialog>
</>
);
import { Navigate } from 'react-router-dom';
export default function AusruestungEinstellungen() {
return <Navigate to="/ausruestung?tab=1" replace />;
}