feat: vehicle/equipment type system, equipment checklist support, and checklist overview redesign

This commit is contained in:
Matthias Hochmeister
2026-03-28 17:27:01 +01:00
parent 692093cc85
commit 6b46e97eb6
25 changed files with 2230 additions and 494 deletions

View File

@@ -9,10 +9,6 @@ import {
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Grid,
@@ -22,12 +18,6 @@ import {
MenuItem,
Select,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
@@ -36,22 +26,20 @@ import {
Add,
Build,
CheckCircle,
Close,
Delete,
Edit,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Save,
Search,
Settings,
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,
@@ -60,7 +48,6 @@ import {
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
// ── Status chip config ────────────────────────────────────────────────────────
@@ -169,13 +156,23 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
<Typography variant="h6" component="div" lineHeight={1.2} noWrap>
{item.bezeichnung}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<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>
@@ -236,186 +233,12 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
);
};
// ── Category Management Dialog ───────────────────────────────────────────────
interface CategoryDialogProps {
open: boolean;
onClose: () => void;
categories: AusruestungKategorie[];
onRefresh: () => void;
}
const CategoryManagementDialog: React.FC<CategoryDialogProps> = ({ open, onClose, categories, onRefresh }) => {
const { showSuccess, showError } = useNotification();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editKurzname, setEditKurzname] = useState('');
const [editMotor, setEditMotor] = useState(false);
const [newName, setNewName] = useState('');
const [newKurzname, setNewKurzname] = useState('');
const [newMotor, setNewMotor] = useState(false);
const [saving, setSaving] = useState(false);
const startEdit = (cat: AusruestungKategorie) => {
setEditingId(cat.id);
setEditName(cat.name);
setEditKurzname(cat.kurzname);
setEditMotor(cat.motorisiert);
};
const cancelEdit = () => {
setEditingId(null);
setEditName('');
setEditKurzname('');
setEditMotor(false);
};
const saveEdit = async () => {
if (!editingId || !editName.trim() || !editKurzname.trim()) return;
setSaving(true);
try {
await equipmentApi.updateCategory(editingId, { name: editName.trim(), kurzname: editKurzname.trim(), motorisiert: editMotor });
showSuccess('Kategorie aktualisiert');
cancelEdit();
onRefresh();
} catch {
showError('Kategorie konnte nicht aktualisiert werden');
} finally {
setSaving(false);
}
};
const handleCreate = async () => {
if (!newName.trim() || !newKurzname.trim()) return;
setSaving(true);
try {
await equipmentApi.createCategory({ name: newName.trim(), kurzname: newKurzname.trim(), motorisiert: newMotor });
showSuccess('Kategorie erstellt');
setNewName('');
setNewKurzname('');
setNewMotor(false);
onRefresh();
} catch {
showError('Kategorie konnte nicht erstellt werden');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
setSaving(true);
try {
await equipmentApi.deleteCategory(id);
showSuccess('Kategorie gelöscht');
onRefresh();
} catch (err: any) {
const msg = err?.response?.data?.message || 'Kategorie konnte nicht gelöscht werden';
showError(msg);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Kategorien verwalten
<IconButton onClick={onClose} size="small"><Close /></IconButton>
</DialogTitle>
<DialogContent dividers>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Kurzname</TableCell>
<TableCell>Motorisiert</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{categories.map((cat) => (
<TableRow key={cat.id}>
{editingId === cat.id ? (
<>
<TableCell>
<TextField size="small" value={editName} onChange={(e) => setEditName(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<TextField size="small" value={editKurzname} onChange={(e) => setEditKurzname(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<Switch checked={editMotor} onChange={(e) => setEditMotor(e.target.checked)} size="small" />
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={saveEdit} disabled={saving || !editName.trim() || !editKurzname.trim()} color="primary">
<Save fontSize="small" />
</IconButton>
<IconButton size="small" onClick={cancelEdit} disabled={saving}>
<Close fontSize="small" />
</IconButton>
</TableCell>
</>
) : (
<>
<TableCell>{cat.name}</TableCell>
<TableCell>{cat.kurzname}</TableCell>
<TableCell>{cat.motorisiert ? 'Ja' : 'Nein'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => startEdit(cat)} disabled={saving}>
<Edit fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(cat.id)} disabled={saving} color="error">
<Delete fontSize="small" />
</IconButton>
</TableCell>
</>
)}
</TableRow>
))}
{/* New category row */}
<TableRow>
<TableCell>
<TextField size="small" placeholder="Name" value={newName} onChange={(e) => setNewName(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<TextField size="small" placeholder="Kurzname" value={newKurzname} onChange={(e) => setNewKurzname(e.target.value)} fullWidth />
</TableCell>
<TableCell>
<Switch checked={newMotor} onChange={(e) => setNewMotor(e.target.checked)} size="small" />
</TableCell>
<TableCell align="right">
<Button
variant="contained"
size="small"
startIcon={<Add />}
onClick={handleCreate}
disabled={saving || !newName.trim() || !newKurzname.trim()}
>
Hinzufügen
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function Ausruestung() {
const navigate = useNavigate();
const { canManageEquipment, hasPermission } = usePermissions();
const canManageCategories = hasPermission('ausruestung:manage_categories');
// Category dialog state
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const canManageTypes = hasPermission('ausruestung:manage_types');
// Data state
const [equipment, setEquipment] = useState<AusruestungListItem[]>([]);
@@ -424,9 +247,16 @@ function Ausruestung() {
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);
@@ -471,6 +301,14 @@ function Ausruestung() {
return false;
}
// Type filter
if (selectedTyp) {
const typId = parseInt(selectedTyp, 10);
if (!item.typen?.some((t) => t.id === typId)) {
return false;
}
}
// Status filter
if (selectedStatus && item.status !== selectedStatus) {
return false;
@@ -490,7 +328,7 @@ function Ausruestung() {
return true;
});
}, [equipment, search, selectedCategory, selectedStatus, nurWichtige, pruefungFaellig]);
}, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]);
const hasOverdue = equipment.some(
(item) => item.pruefung_tage_bis_faelligkeit !== null && item.pruefung_tage_bis_faelligkeit < 0
@@ -506,9 +344,9 @@ function Ausruestung() {
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung
</Typography>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton onClick={() => setCategoryDialogOpen(true)} size="small">
{canManageTypes && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/ausruestung/einstellungen')} size="small">
<Settings />
</IconButton>
</Tooltip>
@@ -576,6 +414,22 @@ function Ausruestung() {
</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
@@ -673,15 +527,6 @@ function Ausruestung() {
<Add />
</ChatAwareFab>
)}
{/* Category management dialog */}
{canManageCategories && (
<CategoryManagementDialog
open={categoryDialogOpen}
onClose={() => setCategoryDialogOpen(false)}
categories={categories}
onRefresh={fetchData}
/>
)}
</Container>
</DashboardLayout>
);

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
@@ -44,14 +45,18 @@ import {
MoreHoriz,
PauseCircle,
RemoveCircle,
Save,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate, useParams } 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 { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import type { AusruestungTyp } from '../services/ausruestungTypen';
import { fromGermanDate } from '../utils/dateInput';
import {
AusruestungDetail,
@@ -197,9 +202,10 @@ interface UebersichtTabProps {
equipment: AusruestungDetail;
onStatusUpdated: () => void;
canChangeStatus: boolean;
canWrite: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus }) => {
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus, canWrite }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
@@ -406,6 +412,109 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdate
</Dialog>
<StatusHistorySection equipmentId={equipment.id} />
<TypenSection equipmentId={equipment.id} canWrite={canWrite} />
</Box>
);
};
// -- Typen Section (equipment type assignment) --------------------------------
interface TypenSectionProps {
equipmentId: string;
canWrite: boolean;
}
const TypenSection: React.FC<TypenSectionProps> = ({ equipmentId, canWrite }) => {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const [editing, setEditing] = useState(false);
const [selectedTypen, setSelectedTypen] = useState<AusruestungTyp[]>([]);
const { data: assignedTypen = [] } = useQuery({
queryKey: ['ausruestungTypen', 'equipment', equipmentId],
queryFn: () => ausruestungTypenApi.getTypesForEquipment(equipmentId),
});
const { data: allTypen = [] } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
enabled: editing,
});
const saveMutation = useMutation({
mutationFn: (typIds: number[]) =>
ausruestungTypenApi.setTypesForEquipment(equipmentId, typIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen', 'equipment', equipmentId] });
showSuccess('Typen aktualisiert');
setEditing(false);
},
onError: () => showError('Typen konnten nicht gespeichert werden'),
});
const startEditing = () => {
setSelectedTypen(assignedTypen);
setEditing(true);
};
const handleSave = () => {
saveMutation.mutate(selectedTypen.map((t) => t.id));
};
return (
<Box sx={{ mt: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Typen</Typography>
{canWrite && !editing && (
<Button size="small" startIcon={<Edit />} onClick={startEditing}>
Bearbeiten
</Button>
)}
</Box>
{editing ? (
<Box>
<Autocomplete
multiple
options={allTypen}
getOptionLabel={(o) => o.name}
value={selectedTypen}
onChange={(_, newVal) => setSelectedTypen(newVal)}
isOptionEqualToValue={(o, v) => o.id === v.id}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip label={option.name} size="small" {...getTagProps({ index })} key={option.id} />
))
}
renderInput={(params) => <TextField {...params} label="Typen zuordnen" size="small" />}
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={saveMutation.isPending ? <CircularProgress size={14} /> : <Save />}
onClick={handleSave}
disabled={saveMutation.isPending}
>
Speichern
</Button>
<Button size="small" onClick={() => setEditing(false)} disabled={saveMutation.isPending}>
Abbrechen
</Button>
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{assignedTypen.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Typen zugeordnet</Typography>
) : (
assignedTypen.map((t) => (
<Chip key={t.id} label={t.name} size="small" variant="outlined" />
))
)}
</Box>
)}
</Box>
);
};
@@ -845,6 +954,7 @@ function AusruestungDetailPage() {
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canWrite}
canWrite={canWrite}
/>
</TabPanel>

View File

@@ -0,0 +1,310 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
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 { 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>
</Container>
</DashboardLayout>
);
}
export default AusruestungEinstellungen;

View File

@@ -57,14 +57,15 @@ export default function ChecklistAusfuehrung() {
useEffect(() => {
if (!isNew || startingExecution) return;
const fahrzeugId = searchParams.get('fahrzeug');
const ausruestungId = searchParams.get('ausruestung');
const vorlageId = searchParams.get('vorlage');
if (!fahrzeugId || !vorlageId) {
showError('Fahrzeug und Vorlage sind erforderlich');
if (!vorlageId || (!fahrzeugId && !ausruestungId)) {
showError('Vorlage und entweder Fahrzeug oder Ausrüstung sind erforderlich');
navigate('/checklisten');
return;
}
setStartingExecution(true);
checklistenApi.startExecution(fahrzeugId, Number(vorlageId))
checklistenApi.startExecution(Number(vorlageId), { fahrzeugId: fahrzeugId ?? undefined, ausruestungId: ausruestungId ?? undefined })
.then((exec) => navigate(`/checklisten/ausfuehrung/${exec.id}`, { replace: true }))
.catch(() => { showError('Checkliste konnte nicht gestartet werden'); navigate('/checklisten'); });
}, [isNew, searchParams, navigate, showError, startingExecution]);

View File

@@ -1,9 +1,13 @@
import React, { useState, useEffect } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Autocomplete,
Badge,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Dialog,
@@ -12,10 +16,16 @@ import {
DialogTitle,
FormControl,
FormControlLabel,
FormLabel,
IconButton,
InputLabel,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
Radio,
RadioGroup,
Select,
Switch,
Tab,
@@ -32,12 +42,12 @@ import {
} from '@mui/material';
import {
Add as AddIcon,
CheckCircle,
BuildCircle,
Delete as DeleteIcon,
DirectionsCar,
Edit as EditIcon,
ExpandMore,
PlayArrow,
Schedule,
Warning,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -46,7 +56,9 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import {
CHECKLIST_STATUS_LABELS,
CHECKLIST_STATUS_COLORS,
@@ -54,12 +66,14 @@ import {
import type {
ChecklistVorlage,
ChecklistAusfuehrung,
ChecklistFaelligkeit,
ChecklistOverviewItem,
ChecklistOverviewChecklist,
FahrzeugTyp,
CreateVorlagePayload,
UpdateVorlagePayload,
CreateVorlageItemPayload,
} from '../types/checklist.types';
import type { AusruestungTyp } from '../services/ausruestungTypen';
// ── Helpers ──
@@ -73,6 +87,40 @@ const INTERVALL_LABELS: Record<string, string> = {
custom: 'Benutzerdefiniert',
};
type AssignmentType = 'global' | 'fahrzeug_typ' | 'fahrzeug' | 'ausruestung_typ' | 'ausruestung';
function getAssignmentType(v: ChecklistVorlage): AssignmentType {
if (v.fahrzeug_id) return 'fahrzeug';
if (v.fahrzeug_typ_id) return 'fahrzeug_typ';
if (v.ausruestung_id) return 'ausruestung';
if (v.ausruestung_typ_id) return 'ausruestung_typ';
return 'global';
}
function getAssignmentLabel(v: ChecklistVorlage): string {
if (v.fahrzeug_id) return v.fahrzeug_name ? `Fahrzeug: ${v.fahrzeug_name}` : 'Fahrzeug (direkt)';
if (v.fahrzeug_typ_id) return v.fahrzeug_typ?.name ? `Fahrzeugtyp: ${v.fahrzeug_typ.name}` : 'Fahrzeugtyp';
if (v.ausruestung_id) return v.ausruestung_name ? `Ausrüstung: ${v.ausruestung_name}` : 'Ausrüstung (direkt)';
if (v.ausruestung_typ_id) return v.ausruestung_typ ? `Ausrüstungstyp: ${v.ausruestung_typ}` : 'Ausrüstungstyp';
return 'Global';
}
function getDueColor(nextDue?: string): 'error' | 'warning' | 'success' {
if (!nextDue) return 'success';
const daysUntil = Math.ceil((new Date(nextDue).getTime() - Date.now()) / 86400000);
if (daysUntil < 0) return 'error';
if (daysUntil <= 3) return 'warning';
return 'success';
}
function getDueLabel(nextDue?: string): string {
if (!nextDue) return 'Aktuell';
const daysUntil = Math.ceil((new Date(nextDue).getTime() - Date.now()) / 86400000);
if (daysUntil < 0) return `${Math.abs(daysUntil)}d überfällig`;
if (daysUntil === 0) return 'Heute fällig';
return `in ${daysUntil}d fällig`;
}
// ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
@@ -95,8 +143,8 @@ export default function Checklisten() {
const canManageTemplates = hasPermission('checklisten:manage_templates');
const canExecute = hasPermission('checklisten:execute');
// Tabs: 0=Übersicht, 1=Vorlagen (if perm), 2=Fahrzeugtypen (if perm), 3=Historie
const manageTabs = canManageTemplates ? 2 : 0;
// Tabs: 0=Übersicht, 1=Vorlagen (if perm), 2=Historie
const manageTabs = canManageTemplates ? 1 : 0;
const TAB_COUNT = 2 + manageTabs;
const [tab, setTab] = useState(() => {
@@ -109,15 +157,10 @@ export default function Checklisten() {
}, [searchParams, TAB_COUNT]);
// ── Queries ──
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ['vehicles', 'checklisten-overview'],
queryFn: () => vehiclesApi.getAll(),
});
const { data: overdue = [] } = useQuery({
queryKey: ['checklisten-faellig'],
queryFn: checklistenApi.getOverdue,
refetchInterval: 5 * 60 * 1000,
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['checklisten', 'overview'],
queryFn: checklistenApi.getOverview,
refetchInterval: 60000,
});
const { data: vorlagen = [], isLoading: vorlagenLoading } = useQuery({
@@ -137,17 +180,9 @@ export default function Checklisten() {
queryFn: () => checklistenApi.getExecutions(),
});
// Build overdue lookup: fahrzeugId -> ChecklistFaelligkeit[]
const overdueByVehicle = overdue.reduce<Record<string, ChecklistFaelligkeit[]>>((acc, f) => {
if (!acc[f.fahrzeug_id]) acc[f.fahrzeug_id] = [];
acc[f.fahrzeug_id].push(f);
return acc;
}, {});
// ── Tab indices ──
const vorlagenTabIdx = canManageTemplates ? 1 : -1;
const typenTabIdx = canManageTemplates ? 2 : -1;
const historieTabIdx = canManageTemplates ? 3 : 1;
const historieTabIdx = canManageTemplates ? 2 : 1;
return (
<DashboardLayout>
@@ -162,73 +197,18 @@ export default function Checklisten() {
>
<Tab label="Übersicht" />
{canManageTemplates && <Tab label="Vorlagen" />}
{canManageTemplates && <Tab label="Fahrzeugtypen" />}
<Tab label="Historie" />
</Tabs>
</Box>
{/* Tab 0: Übersicht */}
<TabPanel value={tab} index={0}>
{vehiclesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : vehicles.length === 0 ? (
<Typography color="text.secondary">Keine Fahrzeuge vorhanden.</Typography>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 2 }}>
{vehicles.map((v) => {
const vOverdue = overdueByVehicle[v.id] || [];
return (
<Card key={v.id} variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6">{v.bezeichnung ?? v.kurzname}</Typography>
{vOverdue.length > 0 && (
<Chip
icon={<Warning />}
label={`${vOverdue.length} fällig`}
color="error"
size="small"
/>
)}
</Box>
{vOverdue.length > 0 ? (
vOverdue.map((f) => {
const days = Math.ceil((Date.now() - new Date(f.naechste_faellig_am).getTime()) / 86400000);
return (
<Box key={f.vorlage_id} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Warning fontSize="small" color="error" />
<Typography variant="body2">{f.vorlage_name}</Typography>
<Typography variant="caption" color="error.main">
({days > 0 ? `${days}d überfällig` : 'heute fällig'})
</Typography>
</Box>
{canExecute && (
<Tooltip title="Checkliste starten">
<IconButton
size="small"
color="primary"
onClick={() => navigate(`/checklisten/ausfuehrung/new?fahrzeug=${v.id}&vorlage=${f.vorlage_id}`)}
>
<PlayArrow fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
);
})
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle fontSize="small" color="success" />
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography>
</Box>
)}
</CardContent>
</Card>
);
})}
</Box>
)}
<OverviewTab
overview={overview}
loading={overviewLoading}
canExecute={canExecute}
navigate={navigate}
/>
</TabPanel>
{/* Tab 1: Vorlagen (templates) */}
@@ -245,19 +225,7 @@ export default function Checklisten() {
</TabPanel>
)}
{/* Tab 2: Fahrzeugtypen */}
{canManageTemplates && (
<TabPanel value={tab} index={typenTabIdx}>
<FahrzeugTypenTab
fahrzeugTypen={fahrzeugTypen}
queryClient={queryClient}
showSuccess={showSuccess}
showError={showError}
/>
</TabPanel>
)}
{/* Tab 3: Historie */}
{/* Tab 2: Historie */}
<TabPanel value={tab} index={historieTabIdx}>
<HistorieTab
executions={executions}
@@ -269,6 +237,113 @@ export default function Checklisten() {
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Overview Tab
// ══════════════════════════════════════════════════════════════════════════════
interface OverviewTabProps {
overview?: { vehicles: ChecklistOverviewItem[]; equipment: ChecklistOverviewItem[] };
loading: boolean;
canExecute: boolean;
navigate: ReturnType<typeof useNavigate>;
}
function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabProps) {
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
}
const vehicles = overview?.vehicles ?? [];
const equipment = overview?.equipment ?? [];
if (vehicles.length === 0 && equipment.length === 0) {
return <Alert severity="success" sx={{ mt: 1 }}>Keine offenen oder fälligen Checks</Alert>;
}
const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung') => {
const color = getDueColor(cl.next_due);
const label = getDueLabel(cl.next_due);
const param = type === 'fahrzeug' ? `fahrzeug=${itemId}` : `ausruestung=${itemId}`;
return (
<ListItem
key={cl.vorlage_id}
sx={{ py: 0.5, px: 1 }}
secondaryAction={
canExecute ? (
<Tooltip title="Checkliste starten">
<IconButton
size="small"
color="primary"
onClick={() => navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
>
<PlayArrow fontSize="small" />
</IconButton>
</Tooltip>
) : undefined
}
>
<ListItemText
primary={cl.vorlage_name}
primaryTypographyProps={{ variant: 'body2' }}
/>
<Chip
label={label}
color={color}
size="small"
variant="outlined"
sx={{ mr: canExecute ? 4 : 0, ml: 1 }}
/>
</ListItem>
);
};
const renderSection = (
title: string,
icon: React.ReactNode,
items: ChecklistOverviewItem[],
type: 'fahrzeug' | 'ausruestung',
) => {
if (items.length === 0) return null;
return (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
{icon} {title}
</Typography>
{items.map((item) => {
const totalDue = item.checklists.length;
const hasOverdue = item.checklists.some((cl) => getDueColor(cl.next_due) === 'error');
return (
<Accordion key={item.id} variant="outlined" disableGutters>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%' }}>
<Typography variant="subtitle1">{item.name}</Typography>
<Badge
badgeContent={totalDue}
color={hasOverdue ? 'error' : 'warning'}
sx={{ ml: 'auto', mr: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<List dense disablePadding>
{item.checklists.map((cl) => renderChecklistRow(cl, item.id, type))}
</List>
</AccordionDetails>
</Accordion>
);
})}
</Box>
);
};
return (
<Box>
{renderSection('Fahrzeuge', <DirectionsCar color="action" />, vehicles, 'fahrzeug')}
{renderSection('Ausrüstung', <BuildCircle color="action" />, equipment, 'ausruestung')}
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Vorlagen Tab
// ══════════════════════════════════════════════════════════════════════════════
@@ -287,9 +362,33 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
const [editingVorlage, setEditingVorlage] = useState<ChecklistVorlage | null>(null);
const [expandedVorlageId, setExpandedVorlageId] = useState<number | null>(null);
const emptyForm: CreateVorlagePayload = { name: '', fahrzeug_typ_id: undefined, intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true };
// Assignment form state
const [assignmentType, setAssignmentType] = useState<AssignmentType>('global');
const emptyForm: CreateVorlagePayload = {
name: '', fahrzeug_typ_id: undefined, fahrzeug_id: undefined,
ausruestung_typ_id: undefined, ausruestung_id: undefined,
intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true,
};
const [form, setForm] = useState<CreateVorlagePayload>(emptyForm);
// Fetch vehicle / equipment lists for the assignment pickers
const { data: vehiclesList = [] } = useQuery({
queryKey: ['vehicles', 'list'],
queryFn: () => vehiclesApi.getAll(),
enabled: dialogOpen && assignmentType === 'fahrzeug',
});
const { data: equipmentList = [] } = useQuery({
queryKey: ['equipment', 'list'],
queryFn: () => equipmentApi.getAll(),
enabled: dialogOpen && assignmentType === 'ausruestung',
});
const { data: ausruestungTypen = [] } = useQuery({
queryKey: ['ausruestung-typen'],
queryFn: ausruestungTypenApi.getAll,
enabled: dialogOpen && assignmentType === 'ausruestung_typ',
});
const createMutation = useMutation({
mutationFn: (data: CreateVorlagePayload) => checklistenApi.createVorlage(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage erstellt'); },
@@ -308,22 +407,67 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
onError: () => showError('Fehler beim Löschen der Vorlage'),
});
const openCreate = () => { setEditingVorlage(null); setForm(emptyForm); setDialogOpen(true); };
const openCreate = () => {
setEditingVorlage(null);
setForm(emptyForm);
setAssignmentType('global');
setDialogOpen(true);
};
const openEdit = (v: ChecklistVorlage) => {
setEditingVorlage(v);
setForm({ name: v.name, fahrzeug_typ_id: v.fahrzeug_typ_id, intervall: v.intervall, intervall_tage: v.intervall_tage, beschreibung: v.beschreibung ?? '', aktiv: v.aktiv });
setAssignmentType(getAssignmentType(v));
setForm({
name: v.name,
fahrzeug_typ_id: v.fahrzeug_typ_id,
fahrzeug_id: v.fahrzeug_id,
ausruestung_typ_id: v.ausruestung_typ_id,
ausruestung_id: v.ausruestung_id,
intervall: v.intervall,
intervall_tage: v.intervall_tage,
beschreibung: v.beschreibung ?? '',
aktiv: v.aktiv,
});
setDialogOpen(true);
};
const buildPayload = (): CreateVorlagePayload => {
const base: CreateVorlagePayload = {
name: form.name,
intervall: form.intervall,
intervall_tage: form.intervall_tage,
beschreibung: form.beschreibung,
aktiv: form.aktiv,
};
switch (assignmentType) {
case 'fahrzeug_typ': return { ...base, fahrzeug_typ_id: form.fahrzeug_typ_id };
case 'fahrzeug': return { ...base, fahrzeug_id: form.fahrzeug_id };
case 'ausruestung_typ': return { ...base, ausruestung_typ_id: form.ausruestung_typ_id };
case 'ausruestung': return { ...base, ausruestung_id: form.ausruestung_id };
default: return base;
}
};
const handleSubmit = () => {
if (!form.name.trim()) return;
const payload = buildPayload();
if (editingVorlage) {
updateMutation.mutate({ id: editingVorlage.id, data: form });
updateMutation.mutate({ id: editingVorlage.id, data: payload });
} else {
createMutation.mutate(form);
createMutation.mutate(payload);
}
};
const handleAssignmentTypeChange = (newType: AssignmentType) => {
setAssignmentType(newType);
setForm((f) => ({
...f,
fahrzeug_typ_id: undefined,
fahrzeug_id: undefined,
ausruestung_typ_id: undefined,
ausruestung_id: undefined,
}));
};
const isSaving = createMutation.isPending || updateMutation.isPending;
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
@@ -339,7 +483,7 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Fahrzeugtyp</TableCell>
<TableCell>Zuordnung</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
@@ -353,7 +497,7 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
<React.Fragment key={v.id}>
<TableRow hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedVorlageId(expandedVorlageId === v.id ? null : v.id)}>
<TableCell>{v.name}</TableCell>
<TableCell>{v.fahrzeug_typ?.name ?? ''}</TableCell>
<TableCell>{getAssignmentLabel(v)}</TableCell>
<TableCell>
{v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : ''}
{v.intervall === 'custom' && v.intervall_tage ? ` (${v.intervall_tage} Tage)` : ''}
@@ -385,13 +529,64 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
<DialogTitle>{editingVorlage ? 'Vorlage bearbeiten' : 'Neue Vorlage'}</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 }))} />
<FormControl fullWidth>
<InputLabel>Fahrzeugtyp</InputLabel>
<Select label="Fahrzeugtyp" value={form.fahrzeug_typ_id ?? ''} onChange={(e) => setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))}>
<MenuItem value="">Alle (global)</MenuItem>
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}
</Select>
{/* Assignment type */}
<FormControl>
<FormLabel>Zuordnung</FormLabel>
<RadioGroup
row
value={assignmentType}
onChange={(e) => handleAssignmentTypeChange(e.target.value as AssignmentType)}
>
<FormControlLabel value="global" control={<Radio size="small" />} label="Global" />
<FormControlLabel value="fahrzeug_typ" control={<Radio size="small" />} label="Fahrzeugtyp" />
<FormControlLabel value="fahrzeug" control={<Radio size="small" />} label="Fahrzeug" />
<FormControlLabel value="ausruestung_typ" control={<Radio size="small" />} label="Ausrüstungstyp" />
<FormControlLabel value="ausruestung" control={<Radio size="small" />} label="Ausrüstung" />
</RadioGroup>
</FormControl>
{/* Assignment picker based on type */}
{assignmentType === 'fahrzeug_typ' && (
<FormControl fullWidth>
<InputLabel>Fahrzeugtyp</InputLabel>
<Select
label="Fahrzeugtyp"
value={form.fahrzeug_typ_id ?? ''}
onChange={(e) => setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))}
>
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}
</Select>
</FormControl>
)}
{assignmentType === 'fahrzeug' && (
<Autocomplete
options={vehiclesList}
getOptionLabel={(v) => v.bezeichnung ?? v.kurzname ?? String(v.id)}
value={vehiclesList.find((v) => v.id === form.fahrzeug_id) ?? null}
onChange={(_e, v) => setForm((f) => ({ ...f, fahrzeug_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Fahrzeug" />}
/>
)}
{assignmentType === 'ausruestung_typ' && (
<Autocomplete
options={ausruestungTypen}
getOptionLabel={(t: AusruestungTyp) => t.name}
value={ausruestungTypen.find((t: AusruestungTyp) => t.id === form.ausruestung_typ_id) ?? null}
onChange={(_e, t: AusruestungTyp | null) => setForm((f) => ({ ...f, ausruestung_typ_id: t?.id }))}
renderInput={(params) => <TextField {...params} label="Ausrüstungstyp" />}
/>
)}
{assignmentType === 'ausruestung' && (
<Autocomplete
options={equipmentList}
getOptionLabel={(eq) => eq.bezeichnung ?? String(eq.id)}
value={equipmentList.find((eq) => eq.id === form.ausruestung_id) ?? null}
onChange={(_e, eq) => setForm((f) => ({ ...f, ausruestung_id: eq?.id }))}
renderInput={(params) => <TextField {...params} label="Ausrüstung" />}
/>
)}
<FormControl fullWidth>
<InputLabel>Intervall</InputLabel>
<Select label="Intervall" value={form.intervall ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall: (e.target.value || undefined) as CreateVorlagePayload['intervall'] }))}>
@@ -465,108 +660,6 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }:
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Fahrzeugtypen Tab
// ══════════════════════════════════════════════════════════════════════════════
interface FahrzeugTypenTabProps {
fahrzeugTypen: FahrzeugTyp[];
queryClient: ReturnType<typeof useQueryClient>;
showSuccess: (msg: string) => void;
showError: (msg: string) => void;
}
function FahrzeugTypenTab({ fahrzeugTypen, queryClient, showSuccess, showError }: FahrzeugTypenTabProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
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'] }); showSuccess('Fahrzeugtyp gelöscht'); },
onError: () => showError('Fehler beim Löschen'),
});
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>
<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>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Historie Tab
// ══════════════════════════════════════════════════════════════════════════════
@@ -579,15 +672,18 @@ interface HistorieTabProps {
function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
const [statusFilter, setStatusFilter] = useState<string>('');
const [vehicleFilter, setVehicleFilter] = useState<string>('');
const [targetFilter, setTargetFilter] = useState<string>('');
const filtered = executions.filter((e) => {
if (statusFilter && e.status !== statusFilter) return false;
if (vehicleFilter && e.fahrzeug_name !== vehicleFilter) return false;
const targetName = e.fahrzeug_name || e.ausruestung_name || '';
if (targetFilter && targetName !== targetFilter) return false;
return true;
});
const uniqueVehicles = [...new Set(executions.map((e) => e.fahrzeug_name).filter(Boolean))] as string[];
const uniqueTargets = [...new Set(
executions.map((e) => e.fahrzeug_name || e.ausruestung_name).filter(Boolean)
)] as string[];
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
@@ -603,11 +699,11 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Fahrzeug</InputLabel>
<Select label="Fahrzeug" value={vehicleFilter} onChange={(e) => setVehicleFilter(e.target.value)}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Fahrzeug / Ausrüstung</InputLabel>
<Select label="Fahrzeug / Ausrüstung" value={targetFilter} onChange={(e) => setTargetFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{uniqueVehicles.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
{uniqueTargets.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
</Select>
</FormControl>
</Box>
@@ -616,7 +712,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fahrzeug</TableCell>
<TableCell>Fahrzeug / Ausrüstung</TableCell>
<TableCell>Vorlage</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
@@ -630,7 +726,7 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
) : (
filtered.map((e) => (
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
<TableCell>{e.fahrzeug_name ?? ''}</TableCell>
<TableCell>{e.fahrzeug_name || e.ausruestung_name || ''}</TableCell>
<TableCell>{e.vorlage_name ?? ''}</TableCell>
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
@@ -60,6 +61,8 @@ import { vehiclesApi } from '../services/vehicles';
import GermanDateField from '../components/shared/GermanDateField';
import { fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { FahrzeugTyp } from '../types/checklist.types';
import {
FahrzeugDetail as FahrzeugDetailType,
FahrzeugWartungslog,
@@ -187,9 +190,10 @@ interface UebersichtTabProps {
vehicle: FahrzeugDetailType;
onStatusUpdated: () => void;
canChangeStatus: boolean;
canEdit: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus }) => {
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
@@ -203,6 +207,43 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
const [saveError, setSaveError] = useState<string | null>(null);
const [overlappingBookings, setOverlappingBookings] = useState<OverlappingBooking[]>([]);
// ── Fahrzeugtypen ──
const [allTypes, setAllTypes] = useState<FahrzeugTyp[]>([]);
const [vehicleTypes, setVehicleTypes] = useState<FahrzeugTyp[]>([]);
const [typesLoading, setTypesLoading] = useState(true);
const [editingTypes, setEditingTypes] = useState(false);
const [selectedTypes, setSelectedTypes] = useState<FahrzeugTyp[]>([]);
const [typesSaving, setTypesSaving] = useState(false);
useEffect(() => {
let cancelled = false;
Promise.all([
fahrzeugTypenApi.getAll(),
fahrzeugTypenApi.getTypesForVehicle(vehicle.id),
])
.then(([all, assigned]) => {
if (cancelled) return;
setAllTypes(all);
setVehicleTypes(assigned);
})
.catch(() => {})
.finally(() => { if (!cancelled) setTypesLoading(false); });
return () => { cancelled = true; };
}, [vehicle.id]);
const handleSaveTypes = async () => {
try {
setTypesSaving(true);
await fahrzeugTypenApi.setTypesForVehicle(vehicle.id, selectedTypes.map((t) => t.id));
setVehicleTypes(selectedTypes);
setEditingTypes(false);
} catch {
// silent — keep dialog open
} finally {
setTypesSaving(false);
}
};
const isAusserDienst = (s: FahrzeugStatus) =>
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
@@ -342,6 +383,50 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
))}
</Grid>
{/* Fahrzeugtypen */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Fahrzeugtypen
</Typography>
{typesLoading ? (
<CircularProgress size={20} />
) : editingTypes ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Autocomplete
multiple
options={allTypes}
getOptionLabel={(o) => o.name}
value={selectedTypes}
onChange={(_e, val) => setSelectedTypes(val)}
isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Fahrzeugtypen" size="small" />}
sx={{ minWidth: 300, flexGrow: 1 }}
/>
<Button variant="contained" size="small" onClick={handleSaveTypes} disabled={typesSaving}>
{typesSaving ? <CircularProgress size={16} /> : 'Speichern'}
</Button>
<Button size="small" onClick={() => setEditingTypes(false)}>Abbrechen</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{vehicleTypes.length === 0 ? (
<Typography variant="body2" color="text.disabled">Keine Typen zugewiesen</Typography>
) : (
vehicleTypes.map((t) => (
<Chip key={t.id} label={t.name} size="small" variant="outlined" />
))
)}
{canEdit && (
<IconButton
size="small"
onClick={() => { setSelectedTypes(vehicleTypes); setEditingTypes(true); }}
aria-label="Fahrzeugtypen bearbeiten"
>
<Edit fontSize="small" />
</IconButton>
)}
</Box>
)}
{/* Inspection deadline quick view */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Prüf- und Wartungsfristen
@@ -1047,6 +1132,7 @@ function FahrzeugDetail() {
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
canEdit={hasPermission('fahrzeuge:edit')}
/>
</TabPanel>

View File

@@ -0,0 +1,235 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
Settings,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { FahrzeugTyp } from '../types/checklist.types';
export default function FahrzeugEinstellungen() {
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const canEdit = hasPermission('fahrzeuge:edit');
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;
if (!canEdit) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">Keine Berechtigung für diese Seite.</Alert>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Settings color="action" />
<Typography variant="h4" component="h1">
Fahrzeug-Einstellungen
</Typography>
</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>
</Container>
</DashboardLayout>
);
}