feat: vehicle/equipment type system, equipment checklist support, and checklist overview redesign
This commit is contained in:
@@ -19,6 +19,7 @@ import BookingFormPage from './pages/BookingFormPage';
|
||||
import Ausruestung from './pages/Ausruestung';
|
||||
import AusruestungForm from './pages/AusruestungForm';
|
||||
import AusruestungDetail from './pages/AusruestungDetail';
|
||||
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
|
||||
import Atemschutz from './pages/Atemschutz';
|
||||
import Mitglieder from './pages/Mitglieder';
|
||||
import MitgliedDetail from './pages/MitgliedDetail';
|
||||
@@ -37,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel
|
||||
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
||||
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||
import Checklisten from './pages/Checklisten';
|
||||
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
||||
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
||||
import Issues from './pages/Issues';
|
||||
import IssueDetail from './pages/IssueDetail';
|
||||
@@ -120,6 +122,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fahrzeuge/einstellungen"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FahrzeugEinstellungen />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fahrzeuge/:id/bearbeiten"
|
||||
element={
|
||||
@@ -152,6 +162,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ausruestung/einstellungen"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AusruestungEinstellungen />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ausruestung/:id/bearbeiten"
|
||||
element={
|
||||
|
||||
@@ -63,10 +63,11 @@ function ChecklistWidget() {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{overdueItems.slice(0, 5).map((item) => {
|
||||
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
|
||||
const targetName = item.fahrzeug_name || item.ausruestung_name || '–';
|
||||
return (
|
||||
<Box key={`${item.fahrzeug_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box key={`${item.fahrzeug_id || item.ausruestung_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
|
||||
{item.fahrzeug_name}
|
||||
{targetName}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
|
||||
|
||||
@@ -192,12 +192,17 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
||||
|
||||
const vehicleSubItems: SubItem[] = useMemo(
|
||||
() =>
|
||||
(vehicleList ?? []).map((v) => ({
|
||||
() => {
|
||||
const items: SubItem[] = (vehicleList ?? []).map((v) => ({
|
||||
text: v.bezeichnung ?? v.kurzname,
|
||||
path: `/fahrzeuge/${v.id}`,
|
||||
})),
|
||||
[vehicleList],
|
||||
}));
|
||||
if (hasPermission('fahrzeuge:edit')) {
|
||||
items.push({ text: 'Einstellungen', path: '/fahrzeuge/einstellungen' });
|
||||
}
|
||||
return items;
|
||||
},
|
||||
[vehicleList, hasPermission],
|
||||
);
|
||||
|
||||
const navigationItems = useMemo((): NavigationItem[] => {
|
||||
@@ -234,13 +239,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
];
|
||||
if (hasPermission('checklisten:manage_templates')) {
|
||||
checklistenSubItems.push({ text: 'Vorlagen', path: '/checklisten?tab=1' });
|
||||
checklistenSubItems.push({ text: 'Fahrzeugtypen', path: '/checklisten?tab=2' });
|
||||
}
|
||||
checklistenSubItems.push({ text: 'Historie', path: `/checklisten?tab=${checklistenSubItems.length}` });
|
||||
|
||||
const items = baseNavigationItems
|
||||
.map((item) => {
|
||||
if (item.path === '/fahrzeuge') return fahrzeugeItem;
|
||||
if (item.path === '/ausruestung') {
|
||||
const ausruestungSubs: SubItem[] = [];
|
||||
if (hasPermission('ausruestung:manage_types')) {
|
||||
ausruestungSubs.push({ text: 'Einstellungen', path: '/ausruestung/einstellungen' });
|
||||
}
|
||||
return ausruestungSubs.length > 0 ? { ...item, subItems: ausruestungSubs } : item;
|
||||
}
|
||||
if (item.path === '/ausruestungsanfrage') {
|
||||
const canSeeAusruestung =
|
||||
hasPermission('ausruestungsanfrage:view') ||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
310
frontend/src/pages/AusruestungEinstellungen.tsx
Normal file
310
frontend/src/pages/AusruestungEinstellungen.tsx
Normal 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 "{deletingTyp?.name}" 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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
235
frontend/src/pages/FahrzeugEinstellungen.tsx
Normal file
235
frontend/src/pages/FahrzeugEinstellungen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/services/ausruestungTypen.ts
Normal file
37
frontend/src/services/ausruestungTypen.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface AusruestungTyp {
|
||||
id: number;
|
||||
name: string;
|
||||
beschreibung?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const ausruestungTypenApi = {
|
||||
getAll: async (): Promise<AusruestungTyp[]> => {
|
||||
const r = await api.get('/api/ausruestung-typen');
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
getById: async (id: number): Promise<AusruestungTyp> => {
|
||||
const r = await api.get(`/api/ausruestung-typen/${id}`);
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
create: async (data: { name: string; beschreibung?: string; icon?: string }): Promise<AusruestungTyp> => {
|
||||
const r = await api.post('/api/ausruestung-typen', data);
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
update: async (id: number, data: Partial<{ name: string; beschreibung: string; icon: string }>): Promise<AusruestungTyp> => {
|
||||
const r = await api.patch(`/api/ausruestung-typen/${id}`, data);
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/api/ausruestung-typen/${id}`);
|
||||
},
|
||||
getTypesForEquipment: async (ausruestungId: string): Promise<AusruestungTyp[]> => {
|
||||
const r = await api.get(`/api/ausruestung-typen/equipment/${ausruestungId}`);
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
setTypesForEquipment: async (ausruestungId: string, typIds: number[]): Promise<void> => {
|
||||
await api.put(`/api/ausruestung-typen/equipment/${ausruestungId}`, { typIds });
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
FahrzeugChecklistItem,
|
||||
ChecklistAusfuehrung,
|
||||
ChecklistFaelligkeit,
|
||||
ChecklistOverviewResponse,
|
||||
ChecklistVorlageFilter,
|
||||
ChecklistAusfuehrungFilter,
|
||||
CreateVorlagePayload,
|
||||
@@ -17,6 +18,12 @@ import type {
|
||||
} from '../types/checklist.types';
|
||||
|
||||
export const checklistenApi = {
|
||||
// ── Overview ──
|
||||
getOverview: async (): Promise<ChecklistOverviewResponse> => {
|
||||
const r = await api.get('/api/checklisten/overview');
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
|
||||
// ── Vorlagen (Templates) ──
|
||||
getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -86,6 +93,31 @@ export const checklistenApi = {
|
||||
await api.delete(`/api/checklisten/fahrzeug-items/${id}`);
|
||||
},
|
||||
|
||||
// ── Equipment-specific Items ──
|
||||
getTemplatesForEquipment: async (ausruestungId: string): Promise<ChecklistVorlage[]> => {
|
||||
const r = await api.get(`/api/checklisten/equipment/${ausruestungId}/vorlagen`);
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
getEquipmentItems: async (ausruestungId: string): Promise<FahrzeugChecklistItem[]> => {
|
||||
const r = await api.get(`/api/checklisten/equipment/${ausruestungId}/items`);
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
addEquipmentItem: async (ausruestungId: string, data: CreateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
|
||||
const r = await api.post(`/api/checklisten/equipment/${ausruestungId}/items`, data);
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
updateEquipmentItem: async (ausruestungId: string, itemId: number, data: UpdateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
|
||||
const r = await api.patch(`/api/checklisten/equipment/${ausruestungId}/items/${itemId}`, data);
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
deleteEquipmentItem: async (ausruestungId: string, itemId: number): Promise<void> => {
|
||||
await api.delete(`/api/checklisten/equipment/${ausruestungId}/items/${itemId}`);
|
||||
},
|
||||
|
||||
// ── Checklists for a Vehicle ──
|
||||
getChecklistenForVehicle: async (fahrzeugId: string): Promise<ChecklistVorlage[]> => {
|
||||
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`);
|
||||
@@ -93,8 +125,12 @@ export const checklistenApi = {
|
||||
},
|
||||
|
||||
// ── Executions ──
|
||||
startExecution: async (fahrzeugId: string, vorlageId: number): Promise<ChecklistAusfuehrung> => {
|
||||
const r = await api.post('/api/checklisten/ausfuehrungen', { fahrzeug_id: fahrzeugId, vorlage_id: vorlageId });
|
||||
startExecution: async (vorlageId: number, opts: { fahrzeugId?: string; ausruestungId?: string }): Promise<ChecklistAusfuehrung> => {
|
||||
const r = await api.post('/api/checklisten/ausfuehrungen', {
|
||||
vorlage_id: vorlageId,
|
||||
fahrzeugId: opts.fahrzeugId,
|
||||
ausruestungId: opts.ausruestungId,
|
||||
});
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -25,4 +25,13 @@ export const fahrzeugTypenApi = {
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/api/fahrzeug-typen/${id}`);
|
||||
},
|
||||
|
||||
getTypesForVehicle: async (fahrzeugId: string): Promise<FahrzeugTyp[]> => {
|
||||
const r = await api.get(`/api/fahrzeug-typen/vehicle/${fahrzeugId}`);
|
||||
return r.data.data ?? r.data;
|
||||
},
|
||||
|
||||
setTypesForVehicle: async (fahrzeugId: string, typIds: number[]): Promise<void> => {
|
||||
await api.put(`/api/fahrzeug-typen/vehicle/${fahrzeugId}`, { typIds });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,11 +15,24 @@ export interface ChecklistVorlageItem {
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface AusruestungTyp {
|
||||
id: number;
|
||||
name: string;
|
||||
beschreibung?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ChecklistVorlage {
|
||||
id: number;
|
||||
name: string;
|
||||
fahrzeug_typ_id?: number;
|
||||
fahrzeug_typ?: FahrzeugTyp;
|
||||
fahrzeug_id?: string;
|
||||
fahrzeug_name?: string;
|
||||
ausruestung_id?: string;
|
||||
ausruestung_name?: string;
|
||||
ausruestung_typ_id?: number;
|
||||
ausruestung_typ?: string;
|
||||
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
intervall_tage?: number;
|
||||
beschreibung?: string;
|
||||
@@ -52,8 +65,10 @@ export interface ChecklistAusfuehrungItem {
|
||||
|
||||
export interface ChecklistAusfuehrung {
|
||||
id: string;
|
||||
fahrzeug_id: string;
|
||||
fahrzeug_id?: string;
|
||||
fahrzeug_name?: string;
|
||||
ausruestung_id?: string;
|
||||
ausruestung_name?: string;
|
||||
vorlage_id?: number;
|
||||
vorlage_name?: string;
|
||||
status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben';
|
||||
@@ -69,8 +84,10 @@ export interface ChecklistAusfuehrung {
|
||||
}
|
||||
|
||||
export interface ChecklistFaelligkeit {
|
||||
fahrzeug_id: string;
|
||||
fahrzeug_name: string;
|
||||
fahrzeug_id?: string;
|
||||
fahrzeug_name?: string;
|
||||
ausruestung_id?: string;
|
||||
ausruestung_name?: string;
|
||||
vorlage_id: number;
|
||||
vorlage_name: string;
|
||||
naechste_faellig_am: string;
|
||||
@@ -107,6 +124,9 @@ export interface ChecklistAusfuehrungFilter {
|
||||
export interface CreateVorlagePayload {
|
||||
name: string;
|
||||
fahrzeug_typ_id?: number;
|
||||
fahrzeug_id?: string;
|
||||
ausruestung_typ_id?: number;
|
||||
ausruestung_id?: string;
|
||||
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
intervall_tage?: number;
|
||||
beschreibung?: string;
|
||||
@@ -116,6 +136,9 @@ export interface CreateVorlagePayload {
|
||||
export interface UpdateVorlagePayload {
|
||||
name?: string;
|
||||
fahrzeug_typ_id?: number | null;
|
||||
fahrzeug_id?: string | null;
|
||||
ausruestung_typ_id?: number | null;
|
||||
ausruestung_id?: string | null;
|
||||
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null;
|
||||
intervall_tage?: number | null;
|
||||
beschreibung?: string | null;
|
||||
@@ -160,3 +183,21 @@ export interface ChecklistWidgetSummary {
|
||||
overdue: ChecklistFaelligkeit[];
|
||||
dueSoon: ChecklistFaelligkeit[];
|
||||
}
|
||||
|
||||
export interface ChecklistOverviewChecklist {
|
||||
vorlage_id: number;
|
||||
vorlage_name: string;
|
||||
next_due?: string;
|
||||
}
|
||||
|
||||
export interface ChecklistOverviewItem {
|
||||
id: string;
|
||||
name: string;
|
||||
kurzname?: string;
|
||||
checklists: ChecklistOverviewChecklist[];
|
||||
}
|
||||
|
||||
export interface ChecklistOverviewResponse {
|
||||
vehicles: ChecklistOverviewItem[];
|
||||
equipment: ChecklistOverviewItem[];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface AusruestungKategorie {
|
||||
motorisiert: boolean;
|
||||
}
|
||||
|
||||
// ── Equipment Type (many-to-many) ───────────────────────────────────────────
|
||||
|
||||
export interface AusruestungTyp {
|
||||
id: number;
|
||||
name: string;
|
||||
beschreibung?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// ── API Response Shapes ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AusruestungListItem {
|
||||
@@ -52,6 +61,7 @@ export interface AusruestungListItem {
|
||||
pruefung_tage_bis_faelligkeit: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
typen?: AusruestungTyp[];
|
||||
}
|
||||
|
||||
export interface AusruestungDetail extends AusruestungListItem {
|
||||
|
||||
Reference in New Issue
Block a user