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

@@ -10,16 +10,33 @@ import {
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
InputAdornment,
Paper,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
Add as AddIcon,
CheckCircle,
Delete as DeleteIcon,
DirectionsCar,
Edit as EditIcon,
Error as ErrorIcon,
FileDownload,
PauseCircle,
@@ -28,11 +45,13 @@ import {
Warning,
ReportProblem,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { VehicleEquipmentWarning } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import {
@@ -40,7 +59,10 @@ import {
FahrzeugStatus,
FahrzeugStatusLabel,
} from '../types/vehicle.types';
import type { FahrzeugTyp } from '../types/checklist.types';
import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
// ── Status chip config ────────────────────────────────────────────────────────
@@ -284,17 +306,203 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
);
};
// ── Fahrzeugtypen-Verwaltung (Einstellungen Tab) ─────────────────────────────
function FahrzeugTypenSettings() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: fahrzeugTypen = [], isLoading } = useQuery({
queryKey: ['fahrzeug-typen'],
queryFn: fahrzeugTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const [deleteError, setDeleteError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => fahrzeugTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp erstellt');
},
onError: () => showError('Fehler beim Erstellen'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<FahrzeugTyp> }) =>
fahrzeugTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => fahrzeugTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDeleteError(null);
showSuccess('Fahrzeugtyp gelöscht');
},
onError: (err: any) => {
const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.';
setDeleteError(msg);
},
});
const openCreate = () => {
setEditing(null);
setForm({ name: '', beschreibung: '', icon: '' });
setDialogOpen(true);
};
const openEdit = (t: FahrzeugTyp) => {
setEditing(t);
setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' });
setDialogOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) return;
if (editing) {
updateMutation.mutate({ id: editing.id, data: form });
} else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Fahrzeugtypen
</Typography>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
{deleteError}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Neuer Fahrzeugtyp
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
Keine Fahrzeugtypen vorhanden
</TableCell>
</TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? ''}</TableCell>
<TableCell>{t.icon ?? ''}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(t.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name *"
fullWidth
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<TextField
label="Beschreibung"
fullWidth
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
<TextField
label="Icon"
fullWidth
value={form.icon}
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function Fahrzeuge() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
const { isAdmin } = usePermissions();
const { hasPermission } = usePermissionContext();
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
const canEditSettings = hasPermission('checklisten:manage_templates');
const fetchVehicles = useCallback(async () => {
try {
setLoading(true);
@@ -310,7 +518,6 @@ function Fahrzeuge() {
useEffect(() => { fetchVehicles(); }, [fetchVehicles]);
// Fetch equipment warnings separately — must not block or delay vehicle list rendering
useEffect(() => {
async function fetchWarnings() {
try {
@@ -323,7 +530,6 @@ function Fahrzeuge() {
});
setEquipmentWarnings(warningsMap);
} catch {
// Silently fail — equipment warnings are non-critical
setEquipmentWarnings(new Map());
}
}
@@ -361,7 +567,6 @@ function Fahrzeuge() {
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
// An overdue inspection exists if §57a OR Wartung is past due
const hasOverdue = vehicles.some(
(v) =>
(v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) ||
@@ -371,7 +576,7 @@ function Fahrzeuge() {
return (
<DashboardLayout>
<Container maxWidth="xl">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Fahrzeugverwaltung
@@ -386,82 +591,101 @@ function Fahrzeuge() {
</Typography>
)}
</Box>
<Button
variant="outlined"
size="small"
startIcon={<FileDownload />}
onClick={handleExportAlerts}
>
Prüfungen CSV
</Button>
{tab === 0 && (
<Button
variant="outlined"
size="small"
startIcon={<FileDownload />}
onClick={handleExportAlerts}
>
Prüfungen CSV
</Button>
)}
</Box>
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
</Alert>
)}
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canEditSettings && <Tab label="Einstellungen" />}
</Tabs>
<TextField
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
size="small"
sx={{ mb: 3, maxWidth: 480 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
{tab === 0 && (
<>
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
</Alert>
)}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
<TextField
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
size="small"
sx={{ mb: 3, maxWidth: 480 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
{!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{vehicles.length === 0
? 'Noch keine Fahrzeuge erfasst'
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
</Typography>
</Box>
)}
{!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((vehicle) => (
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
<VehicleCard
vehicle={vehicle}
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
warnings={equipmentWarnings.get(vehicle.id) || []}
/>
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{vehicles.length === 0
? 'Noch keine Fahrzeuge erfasst'
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
</Typography>
</Box>
)}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((vehicle) => (
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
<VehicleCard
vehicle={vehicle}
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
warnings={equipmentWarnings.get(vehicle.id) || []}
/>
</Grid>
))}
</Grid>
))}
</Grid>
)}
{isAdmin && (
<ChatAwareFab
aria-label="Fahrzeug hinzufügen"
onClick={() => navigate('/fahrzeuge/neu')}
>
<Add />
</ChatAwareFab>
)}
</>
)}
{isAdmin && (
<ChatAwareFab
aria-label="Fahrzeug hinzufügen"
onClick={() => navigate('/fahrzeuge/neu')}
>
<Add />
</ChatAwareFab>
{tab === 1 && canEditSettings && (
<FahrzeugTypenSettings />
)}
</Container>
</DashboardLayout>