feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system

This commit is contained in:
Matthias Hochmeister
2026-03-28 15:19:41 +01:00
parent a1cda5be51
commit 0c2ea829aa
42 changed files with 4804 additions and 201 deletions

View File

@@ -36,6 +36,8 @@ import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung';
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Checklisten from './pages/Checklisten';
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
import Issues from './pages/Issues';
import IssueDetail from './pages/IssueDetail';
import IssueNeu from './pages/IssueNeu';
@@ -342,6 +344,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/checklisten"
element={
<ProtectedRoute>
<Checklisten />
</ProtectedRoute>
}
/>
<Route
path="/checklisten/ausfuehrung/:id"
element={
<ProtectedRoute>
<ChecklistAusfuehrung />
</ProtectedRoute>
}
/>
<Route
path="/issues/neu"
element={

View File

@@ -112,6 +112,12 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
'Admin': ['edit_settings'],
},
checklisten: {
'Ansehen': ['view'],
'Ausführen': ['execute', 'approve'],
'Verwaltung': ['manage_templates'],
'Widget': ['widget'],
},
admin: {
'Allgemein': ['view', 'write'],
'Services': ['view_services', 'edit_services'],

View File

@@ -0,0 +1,92 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { AssignmentTurnedIn, Warning } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { checklistenApi } from '../../services/checklisten';
function ChecklistWidget() {
const navigate = useNavigate();
const { data: overdue, isLoading, isError } = useQuery({
queryKey: ['checklist-overdue'],
queryFn: checklistenApi.getOverdue,
refetchInterval: 5 * 60 * 1000,
retry: 1,
});
const overdueItems = overdue ?? [];
if (isLoading) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Checklisten</Typography>
<Skeleton variant="rectangular" height={40} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Checklisten</Typography>
<Typography variant="body2" color="text.secondary">
Checklisten konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/checklisten')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6">Checklisten</Typography>
{overdueItems.length > 0 && (
<Chip
icon={<Warning />}
label={overdueItems.length}
color="error"
size="small"
/>
)}
</Box>
<AssignmentTurnedIn fontSize="small" color="action" />
</Box>
{overdueItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography>
) : (
<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);
return (
<Box key={`${item.fahrzeug_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
{item.fahrzeug_name}
</Typography>
<Chip
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'}
size="small"
variant="outlined"
/>
</Box>
);
})}
{overdueItems.length > 5 && (
<Typography variant="caption" color="text.secondary">
+ {overdueItems.length - 5} weitere
</Typography>
)}
</Box>
)}
</CardContent>
</Card>
);
}
export default ChecklistWidget;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Box, IconButton } from '@mui/material';
import { DragIndicator } from '@mui/icons-material';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface SortableWidgetProps {
id: string;
editMode: boolean;
children: React.ReactNode;
}
export default function SortableWidget({ id, editMode, children }: SortableWidgetProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !editMode });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
position: 'relative',
};
return (
<Box ref={setNodeRef} style={style}>
{editMode && (
<IconButton
size="small"
{...attributes}
{...listeners}
sx={{
position: 'absolute',
top: 4,
left: 4,
zIndex: 10,
cursor: 'grab',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<DragIndicator fontSize="small" />
</IconButton>
)}
{children}
</Box>
);
}

View File

@@ -22,3 +22,5 @@ export { default as BestellungenWidget } from './BestellungenWidget';
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
export { default as IssueOverviewWidget } from './IssueOverviewWidget';
export { default as ChecklistWidget } from './ChecklistWidget';
export { default as SortableWidget } from './SortableWidget';

View File

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControlLabel,
IconButton,
Paper,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add, Delete as DeleteIcon, PlayArrow, Warning } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { checklistenApi } from '../../services/checklisten';
import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../../types/checklist.types';
import type { CreateFahrzeugItemPayload } from '../../types/checklist.types';
// ── Helpers ──
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '\u2013';
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
interface FahrzeugChecklistTabProps {
fahrzeugId: string;
}
const FahrzeugChecklistTab: React.FC<FahrzeugChecklistTabProps> = ({ fahrzeugId }) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const canManage = hasPermission('checklisten:manage_templates');
const canExecute = hasPermission('checklisten:execute');
// ── Queries ──
const { data: vehicleItems = [], isLoading: itemsLoading } = useQuery({
queryKey: ['checklisten-fahrzeug-items', fahrzeugId],
queryFn: () => checklistenApi.getVehicleItems(fahrzeugId),
});
const { data: templates = [], isLoading: templatesLoading } = useQuery({
queryKey: ['checklisten-fahrzeug-checklisten', fahrzeugId],
queryFn: () => checklistenApi.getChecklistenForVehicle(fahrzeugId),
});
const { data: executions = [], isLoading: executionsLoading } = useQuery({
queryKey: ['checklisten-ausfuehrungen', { fahrzeug_id: fahrzeugId }],
queryFn: () => checklistenApi.getExecutions({ fahrzeug_id: fahrzeugId }),
});
const { data: overdue = [] } = useQuery({
queryKey: ['checklisten-faellig'],
queryFn: checklistenApi.getOverdue,
});
const vehicleOverdue = overdue.filter((f) => f.fahrzeug_id === fahrzeugId);
// ── Vehicle items management ──
const [newItem, setNewItem] = useState<CreateFahrzeugItemPayload>({ bezeichnung: '', pflicht: false, sort_order: 0 });
const addItemMutation = useMutation({
mutationFn: (data: CreateFahrzeugItemPayload) => checklistenApi.addVehicleItem(fahrzeugId, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-fahrzeug-items', fahrzeugId] }); setNewItem({ bezeichnung: '', pflicht: false, sort_order: 0 }); showSuccess('Item hinzugef\u00fcgt'); },
onError: () => showError('Fehler beim Hinzuf\u00fcgen'),
});
const deleteItemMutation = useMutation({
mutationFn: (id: number) => checklistenApi.deleteVehicleItem(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-fahrzeug-items', fahrzeugId] }); showSuccess('Item entfernt'); },
onError: () => showError('Fehler beim Entfernen'),
});
const toggleItemMutation = useMutation({
mutationFn: ({ id, aktiv }: { id: number; aktiv: boolean }) => checklistenApi.updateVehicleItem(id, { aktiv }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['checklisten-fahrzeug-items', fahrzeugId] }),
onError: () => showError('Fehler beim Aktualisieren'),
});
return (
<Box>
{/* Section 1: Vehicle-specific items */}
{canManage && (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>Fahrzeugspezifische Checklisten-Items</Typography>
{itemsLoading ? (
<CircularProgress size={24} />
) : (
<>
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell align="center">Pflicht</TableCell>
<TableCell align="center">Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vehicleItems.length === 0 ? (
<TableRow><TableCell colSpan={4} align="center">Keine fahrzeugspezifischen Items</TableCell></TableRow>
) : (
vehicleItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.bezeichnung}</TableCell>
<TableCell align="center">{item.pflicht ? <Chip label="Pflicht" size="small" color="warning" /> : '\u2013'}</TableCell>
<TableCell align="center">
<Switch
size="small"
checked={item.aktiv}
onChange={() => toggleItemMutation.mutate({ id: item.id, aktiv: !item.aktiv })}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" color="error" onClick={() => deleteItemMutation.mutate(item.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" placeholder="Neues Item..." value={newItem.bezeichnung} onChange={(e) => setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" />
<Button size="small" variant="outlined" startIcon={<Add />} disabled={!newItem.bezeichnung.trim() || addItemMutation.isPending} onClick={() => addItemMutation.mutate(newItem)}>
Hinzuf\u00fcgen
</Button>
</Box>
</>
)}
</Box>
)}
{/* Section 2: Applicable templates */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>Anwendbare Vorlagen</Typography>
{templatesLoading ? (
<CircularProgress size={24} />
) : templates.length === 0 ? (
<Typography color="text.secondary">Keine Vorlagen f\u00fcr dieses Fahrzeug.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Vorlage</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>N\u00e4chste F\u00e4lligkeit</TableCell>
<TableCell align="right">Aktion</TableCell>
</TableRow>
</TableHead>
<TableBody>
{templates.map((t) => {
const due = vehicleOverdue.find((f) => f.vorlage_id === t.id);
return (
<TableRow key={t.id}>
<TableCell>{t.name}</TableCell>
<TableCell>
{t.intervall === 'weekly' ? 'W\u00f6chentlich' : t.intervall === 'monthly' ? 'Monatlich' : t.intervall === 'yearly' ? 'J\u00e4hrlich' : t.intervall === 'custom' ? `${t.intervall_tage ?? '?'} Tage` : '\u2013'}
</TableCell>
<TableCell>
{due ? (
<Chip icon={<Warning />} label={`F\u00e4llig: ${formatDate(due.naechste_faellig_am)}`} color="error" size="small" />
) : (
<Typography variant="body2" color="text.secondary">Aktuell</Typography>
)}
</TableCell>
<TableCell align="right">
{canExecute && (
<Button
size="small"
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => navigate(`/checklisten/ausfuehrung/new?fahrzeug=${fahrzeugId}&vorlage=${t.id}`)}
>
Ausf\u00fchren
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* Section 3: Recent executions */}
<Box>
<Typography variant="h6" sx={{ mb: 1.5 }}>Letzte Ausf\u00fchrungen</Typography>
{executionsLoading ? (
<CircularProgress size={24} />
) : executions.length === 0 ? (
<Typography color="text.secondary">Noch keine Ausf\u00fchrungen f\u00fcr dieses Fahrzeug.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Vorlage</TableCell>
<TableCell>Status</TableCell>
<TableCell>Ausgef\u00fchrt von</TableCell>
<TableCell>Freigegeben von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{executions.slice(0, 20).map((e) => (
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>{e.vorlage_name ?? '\u2013'}</TableCell>
<TableCell>
<Chip label={CHECKLIST_STATUS_LABELS[e.status]} color={CHECKLIST_STATUS_COLORS[e.status]} size="small" />
</TableCell>
<TableCell>{e.ausgefuehrt_von_name ?? '\u2013'}</TableCell>
<TableCell>{e.freigegeben_von_name ?? '\u2013'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</Box>
);
};
export default FahrzeugChecklistTab;

View File

@@ -0,0 +1,328 @@
import { useMemo } from 'react';
import {
Box, Paper, Typography, Chip, Avatar,
} from '@mui/material';
import {
BugReport, FiberNew, HelpOutline,
AccessTime as AccessTimeIcon,
} from '@mui/icons-material';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useState } from 'react';
import type { Issue, IssueStatusDef, IssuePriorityDef } from '../../types/issue.types';
// ── Helpers ──
const PRIO_COLORS: Record<string, string> = {
hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e',
};
const MUI_THEME_COLORS: Record<string, string> = {
default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0',
error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02',
};
const ICON_MAP: Record<string, JSX.Element> = {
BugReport: <BugReport fontSize="small" />,
FiberNew: <FiberNew fontSize="small" />,
HelpOutline: <HelpOutline fontSize="small" />,
};
function getPrioColor(priorities: IssuePriorityDef[], key: string) {
return priorities.find(p => p.schluessel === key)?.farbe ?? PRIO_COLORS[key] ?? '#9e9e9e';
}
function isOverdue(issue: Issue): boolean {
if (!issue.faellig_am) return false;
return new Date(issue.faellig_am) < new Date();
}
// ── Kanban Card ──
function KanbanCard({
issue,
priorities,
onClick,
isDragging,
}: {
issue: Issue;
priorities: IssuePriorityDef[];
onClick: (id: number) => void;
isDragging?: boolean;
}) {
const prioColor = getPrioColor(priorities, issue.prioritaet);
const icon = ICON_MAP[issue.typ_icon || ''] || <HelpOutline fontSize="small" />;
const overdue = isOverdue(issue);
return (
<Paper
variant="outlined"
onClick={() => onClick(issue.id)}
sx={{
p: 1.5,
cursor: 'pointer',
borderLeft: `3px solid ${prioColor}`,
opacity: isDragging ? 0.5 : 1,
'&:hover': { bgcolor: 'action.hover' },
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
{/* Title row with type icon */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<Box sx={{ display: 'inline-flex', mt: 0.25, color: `${issue.typ_farbe || 'action'}.main` }}>
{icon}
</Box>
<Typography variant="body2" sx={{ fontWeight: 500, flex: 1, lineHeight: 1.3 }}>
{issue.titel}
</Typography>
</Box>
{/* Bottom row: assignee + overdue */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{issue.zugewiesen_an_name && (
<Avatar sx={{ width: 20, height: 20, fontSize: 10 }}>
{issue.zugewiesen_an_name.charAt(0).toUpperCase()}
</Avatar>
)}
<Typography variant="caption" color="text.secondary">
{issue.zugewiesen_an_name || ''}
</Typography>
</Box>
{overdue && (
<Chip
icon={<AccessTimeIcon sx={{ fontSize: 14 }} />}
label="Überfällig"
size="small"
color="error"
variant="outlined"
sx={{ height: 20, '& .MuiChip-label': { px: 0.5, fontSize: 11 } }}
/>
)}
</Box>
</Paper>
);
}
// ── Sortable wrapper ──
function SortableCard({
issue,
priorities,
onClick,
}: {
issue: Issue;
priorities: IssuePriorityDef[];
onClick: (id: number) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: issue.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Box ref={setNodeRef} style={style} {...attributes} {...listeners}>
<KanbanCard issue={issue} priorities={priorities} onClick={onClick} isDragging={isDragging} />
</Box>
);
}
// ── Column ──
function KanbanColumn({
statusDef,
issues,
priorities,
onClick,
}: {
statusDef: IssueStatusDef;
issues: Issue[];
priorities: IssuePriorityDef[];
onClick: (id: number) => void;
}) {
const chipColor = MUI_THEME_COLORS[statusDef.farbe] ?? statusDef.farbe ?? '#9e9e9e';
return (
<Box
sx={{
minWidth: 260,
maxWidth: 320,
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
borderRadius: 1,
overflow: 'hidden',
}}
>
{/* Column header */}
<Box sx={{ px: 1.5, py: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: chipColor, flexShrink: 0 }} />
<Typography variant="subtitle2" sx={{ flex: 1 }}>{statusDef.bezeichnung}</Typography>
<Typography variant="caption" color="text.secondary">{issues.length}</Typography>
</Box>
{/* Card list */}
<SortableContext items={issues.map(i => i.id)} strategy={verticalListSortingStrategy}>
<Box
sx={{
p: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
minHeight: 100,
overflowY: 'auto',
flex: 1,
}}
>
{issues.length === 0 && (
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', py: 2 }}>
Keine Issues
</Typography>
)}
{issues.map((issue) => (
<SortableCard key={issue.id} issue={issue} priorities={priorities} onClick={onClick} />
))}
</Box>
</SortableContext>
</Box>
);
}
// ── Main Board ──
interface KanbanBoardProps {
issues: Issue[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
onNavigate: (issueId: number) => void;
onStatusChange: (issueId: number, newStatus: string) => void;
}
export default function KanbanBoard({
issues,
statuses,
priorities,
onNavigate,
onStatusChange,
}: KanbanBoardProps) {
const [activeId, setActiveId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
// Active statuses sorted by sort_order
const activeStatuses = useMemo(
() => statuses.filter(s => s.aktiv).sort((a, b) => a.sort_order - b.sort_order),
[statuses],
);
// Group issues by status
const issuesByStatus = useMemo(() => {
const map: Record<string, Issue[]> = {};
for (const s of activeStatuses) map[s.schluessel] = [];
for (const issue of issues) {
if (map[issue.status]) map[issue.status].push(issue);
}
return map;
}, [issues, activeStatuses]);
const activeIssue = activeId ? issues.find(i => i.id === activeId) : null;
// Find which column (status) an issue id belongs to
function findStatusForIssue(id: number): string | undefined {
for (const [status, items] of Object.entries(issuesByStatus)) {
if (items.some(i => i.id === id)) return status;
}
return undefined;
}
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as number);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const activeIssueId = active.id as number;
const overId = over.id;
// Determine target status: if dropped over a column (status key) or over another card
let targetStatus: string | undefined;
// Check if overId is a status key (column droppable)
if (typeof overId === 'string' && activeStatuses.some(s => s.schluessel === overId)) {
targetStatus = overId;
} else {
// Dropped over another card — find that card's status
targetStatus = findStatusForIssue(overId as number);
}
if (!targetStatus) return;
const currentStatus = findStatusForIssue(activeIssueId);
if (currentStatus === targetStatus) return;
onStatusChange(activeIssueId, targetStatus);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Box
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
pb: 1,
minHeight: 400,
}}
>
{activeStatuses.map((s) => (
<KanbanColumn
key={s.schluessel}
statusDef={s}
issues={issuesByStatus[s.schluessel] || []}
priorities={priorities}
onClick={onNavigate}
/>
))}
</Box>
<DragOverlay>
{activeIssue ? (
<KanbanCard issue={activeIssue} priorities={priorities} onClick={() => {}} />
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -29,6 +29,7 @@ import {
BugReport,
BookOnline,
Forum,
AssignmentTurnedIn,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
@@ -136,6 +137,12 @@ const baseNavigationItems: NavigationItem[] = [
// subItems computed dynamically in navigationItems useMemo
permission: 'ausruestungsanfrage:view',
},
{
text: 'Checklisten',
icon: <AssignmentTurnedIn />,
path: '/checklisten',
permission: 'checklisten:view',
},
{
text: 'Issues',
icon: <BugReport />,

View File

@@ -17,6 +17,7 @@ export const WIDGETS = [
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
] as const;
export type WidgetKey = typeof WIDGETS[number]['key'];

View File

@@ -0,0 +1,314 @@
import { useState, useEffect } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Divider,
Paper,
Radio,
RadioGroup,
FormControlLabel,
TextField,
Typography,
} from '@mui/material';
import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-material';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten';
import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../types/checklist.types';
import type { ChecklistAusfuehrungItem } from '../types/checklist.types';
// ── Helpers ──
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '\u2013';
const ERGEBNIS_ICONS: Record<string, JSX.Element> = {
ok: <CheckCircle fontSize="small" color="success" />,
nok: <Cancel fontSize="small" color="error" />,
na: <RemoveCircle fontSize="small" color="disabled" />,
};
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function ChecklistAusfuehrung() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const isNew = id === 'new';
const canApprove = hasPermission('checklisten:approve');
// ── Start new execution ──
const [startingExecution, setStartingExecution] = useState(false);
useEffect(() => {
if (!isNew || startingExecution) return;
const fahrzeugId = searchParams.get('fahrzeug');
const vorlageId = searchParams.get('vorlage');
if (!fahrzeugId || !vorlageId) {
showError('Fahrzeug und Vorlage sind erforderlich');
navigate('/checklisten');
return;
}
setStartingExecution(true);
checklistenApi.startExecution(fahrzeugId, Number(vorlageId))
.then((exec) => navigate(`/checklisten/ausfuehrung/${exec.id}`, { replace: true }))
.catch(() => { showError('Checkliste konnte nicht gestartet werden'); navigate('/checklisten'); });
}, [isNew, searchParams, navigate, showError, startingExecution]);
// ── Fetch existing execution ──
const { data: execution, isLoading, isError } = useQuery({
queryKey: ['checklisten-ausfuehrung', id],
queryFn: () => checklistenApi.getExecution(id!),
enabled: !isNew && !!id,
});
// ── Item state ──
const [itemResults, setItemResults] = useState<Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }>>({});
const [notizen, setNotizen] = useState('');
useEffect(() => {
if (!execution?.items) return;
const results: Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }> = {};
for (const item of execution.items) {
results[item.id] = { ergebnis: item.ergebnis ?? 'ok', kommentar: item.kommentar ?? '' };
}
setItemResults(results);
setNotizen(execution.notizen ?? '');
}, [execution]);
const setItemResult = (itemId: number, ergebnis: 'ok' | 'nok' | 'na') => {
setItemResults((prev) => ({ ...prev, [itemId]: { ...prev[itemId], ergebnis } }));
};
const setItemComment = (itemId: number, kommentar: string) => {
setItemResults((prev) => ({ ...prev, [itemId]: { ...prev[itemId], kommentar } }));
};
// ── Submit ──
const submitMutation = useMutation({
mutationFn: () => checklistenApi.submitExecution(id!, {
items: Object.entries(itemResults).map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })),
notizen: notizen || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] });
queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] });
queryClient.invalidateQueries({ queryKey: ['checklisten-faellig'] });
showSuccess('Checkliste abgeschlossen');
},
onError: () => showError('Fehler beim Abschlie\u00dfen'),
});
// ── Approve ──
const approveMutation = useMutation({
mutationFn: () => checklistenApi.approveExecution(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] });
queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] });
showSuccess('Checkliste freigegeben');
},
onError: () => showError('Fehler bei der Freigabe'),
});
// ── Loading states ──
if (isNew || startingExecution) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
</DashboardLayout>
);
}
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
</DashboardLayout>
);
}
if (isError || !execution) {
return (
<DashboardLayout>
<Alert severity="error">Checkliste konnte nicht geladen werden.</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mt: 2 }}>Zur\u00fcck</Button>
</DashboardLayout>
);
}
const isReadOnly = execution.status === 'abgeschlossen' || execution.status === 'freigegeben';
const items = execution.items ?? [];
const vorlageItems = items.filter((i) => i.vorlage_item_id != null);
const vehicleItems = items.filter((i) => i.fahrzeug_item_id != null);
const renderItemGroup = (groupItems: ChecklistAusfuehrungItem[], title: string) => {
if (groupItems.length === 0) return null;
return (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>{title}</Typography>
{groupItems.map((item) => {
const result = itemResults[item.id];
return (
<Paper key={item.id} variant="outlined" sx={{ p: 2, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, flexGrow: 1 }}>
{item.bezeichnung}
{/* Indicate pflicht with asterisk - we don't have pflicht on ausfuehrung items directly but could check */}
</Typography>
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
</Box>
{isReadOnly ? (
<Box>
<Chip
label={result?.ergebnis === 'ok' ? 'OK' : result?.ergebnis === 'nok' ? 'Nicht OK' : 'N/A'}
color={result?.ergebnis === 'ok' ? 'success' : result?.ergebnis === 'nok' ? 'error' : 'default'}
size="small"
/>
{result?.kommentar && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{result.kommentar}</Typography>
)}
</Box>
) : (
<Box>
<RadioGroup
row
value={result?.ergebnis ?? ''}
onChange={(e) => setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')}
>
<FormControlLabel value="ok" control={<Radio size="small" />} label="OK" />
<FormControlLabel value="nok" control={<Radio size="small" />} label="Nicht OK" />
<FormControlLabel value="na" control={<Radio size="small" />} label="N/A" />
</RadioGroup>
<TextField
size="small"
placeholder="Kommentar (optional)"
fullWidth
value={result?.kommentar ?? ''}
onChange={(e) => setItemComment(item.id, e.target.value)}
sx={{ mt: 0.5 }}
/>
</Box>
)}
</Paper>
);
})}
</Box>
);
};
return (
<DashboardLayout>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small">
Checklisten
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box>
<Typography variant="h4">
{execution.vorlage_name ?? 'Checkliste'}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{execution.fahrzeug_name ?? '\u2013'} &middot; {formatDate(execution.ausgefuehrt_am ?? execution.created_at)}
</Typography>
</Box>
<Chip
label={CHECKLIST_STATUS_LABELS[execution.status]}
color={CHECKLIST_STATUS_COLORS[execution.status]}
/>
</Box>
{execution.status === 'unvollstaendig' && (
<Alert severity="warning" sx={{ mb: 2 }}>
Diese Checkliste wurde als unvollst\u00e4ndig abgeschlossen. Einige Pflicht-Items wurden nicht mit &quot;OK&quot; bewertet.
</Alert>
)}
<Card sx={{ mb: 3 }}>
<CardContent>
{renderItemGroup(vorlageItems, 'Vorlage-Items')}
{vorlageItems.length > 0 && vehicleItems.length > 0 && <Divider sx={{ my: 2 }} />}
{renderItemGroup(vehicleItems, 'Fahrzeugspezifische Items')}
{items.length === 0 && (
<Typography color="text.secondary">Keine Items in dieser Checkliste.</Typography>
)}
</CardContent>
</Card>
{/* Notes */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>Notizen</Typography>
{isReadOnly ? (
<Typography variant="body2" color="text.secondary">{execution.notizen || 'Keine Notizen'}</Typography>
) : (
<TextField
fullWidth
multiline
rows={3}
placeholder="Zus\u00e4tzliche Notizen..."
value={notizen}
onChange={(e) => setNotizen(e.target.value)}
/>
)}
</CardContent>
</Card>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 2 }}>
{execution.status === 'offen' && (
<Button
variant="contained"
onClick={() => submitMutation.mutate()}
disabled={submitMutation.isPending}
startIcon={submitMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Abschlie\u00dfen
</Button>
)}
{canApprove && execution.status === 'abgeschlossen' && (
<Button
variant="contained"
color="success"
onClick={() => approveMutation.mutate()}
disabled={approveMutation.isPending}
startIcon={approveMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Freigeben
</Button>
)}
</Box>
{/* Metadata */}
{(execution.ausgefuehrt_von_name || execution.freigegeben_von_name) && (
<Paper variant="outlined" sx={{ p: 2, mt: 3 }}>
{execution.ausgefuehrt_von_name && (
<Typography variant="body2" color="text.secondary">
Ausgef\u00fchrt von: {execution.ausgefuehrt_von_name} am {formatDate(execution.ausgefuehrt_am)}
</Typography>
)}
{execution.freigegeben_von_name && (
<Typography variant="body2" color="text.secondary">
Freigegeben von: {execution.freigegeben_von_name} am {formatDate(execution.freigegeben_am)}
</Typography>
)}
</Paper>
)}
</DashboardLayout>
);
}

View File

@@ -0,0 +1,649 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add as AddIcon,
CheckCircle,
Delete as DeleteIcon,
Edit as EditIcon,
PlayArrow,
Schedule,
Warning,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import { vehiclesApi } from '../services/vehicles';
import {
CHECKLIST_STATUS_LABELS,
CHECKLIST_STATUS_COLORS,
} from '../types/checklist.types';
import type {
ChecklistVorlage,
ChecklistAusfuehrung,
ChecklistFaelligkeit,
FahrzeugTyp,
CreateVorlagePayload,
UpdateVorlagePayload,
CreateVorlageItemPayload,
} from '../types/checklist.types';
// ── Helpers ──
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '\u2013';
const INTERVALL_LABELS: Record<string, string> = {
weekly: 'W\u00f6chentlich',
monthly: 'Monatlich',
yearly: 'J\u00e4hrlich',
custom: 'Benutzerdefiniert',
};
// ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
function TabPanel({ children, value, index }: TabPanelProps) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function Checklisten() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const canManageTemplates = hasPermission('checklisten:manage_templates');
const canExecute = hasPermission('checklisten:execute');
// Tabs: 0=\u00dcbersicht, 1=Vorlagen (if perm), 2=Fahrzeugtypen (if perm), 3=Historie
const manageTabs = canManageTemplates ? 2 : 0;
const TAB_COUNT = 2 + manageTabs;
const [tab, setTab] = useState(() => {
const t = Number(searchParams.get('tab'));
return t >= 0 && t < TAB_COUNT ? t : 0;
});
useEffect(() => {
const t = Number(searchParams.get('tab'));
if (t >= 0 && t < TAB_COUNT) setTab(t);
}, [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: vorlagen = [], isLoading: vorlagenLoading } = useQuery({
queryKey: ['checklisten-vorlagen'],
queryFn: () => checklistenApi.getVorlagen(),
enabled: canManageTemplates,
});
const { data: fahrzeugTypen = [] } = useQuery({
queryKey: ['fahrzeug-typen'],
queryFn: fahrzeugTypenApi.getAll,
enabled: canManageTemplates,
});
const { data: executions = [], isLoading: executionsLoading } = useQuery({
queryKey: ['checklisten-ausfuehrungen'],
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;
return (
<DashboardLayout>
<Typography variant="h4" sx={{ mb: 3 }}>Checklisten</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={tab}
onChange={(_e, v) => { setTab(v); navigate(`/checklisten?tab=${v}`, { replace: true }); }}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="\u00dcbersicht" />
{canManageTemplates && <Tab label="Vorlagen" />}
{canManageTemplates && <Tab label="Fahrzeugtypen" />}
<Tab label="Historie" />
</Tabs>
</Box>
{/* Tab 0: \u00dcbersicht */}
<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\u00e4llig`}
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 \u00fcberf\u00e4llig` : 'heute f\u00e4llig'})
</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>
)}
</TabPanel>
{/* Tab 1: Vorlagen (templates) */}
{canManageTemplates && (
<TabPanel value={tab} index={vorlagenTabIdx}>
<VorlagenTab
vorlagen={vorlagen}
loading={vorlagenLoading}
fahrzeugTypen={fahrzeugTypen}
queryClient={queryClient}
showSuccess={showSuccess}
showError={showError}
/>
</TabPanel>
)}
{/* Tab 2: Fahrzeugtypen */}
{canManageTemplates && (
<TabPanel value={tab} index={typenTabIdx}>
<FahrzeugTypenTab
fahrzeugTypen={fahrzeugTypen}
queryClient={queryClient}
showSuccess={showSuccess}
showError={showError}
/>
</TabPanel>
)}
{/* Tab 3: Historie */}
<TabPanel value={tab} index={historieTabIdx}>
<HistorieTab
executions={executions}
loading={executionsLoading}
navigate={navigate}
/>
</TabPanel>
</DashboardLayout>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// Vorlagen Tab
// ══════════════════════════════════════════════════════════════════════════════
interface VorlagenTabProps {
vorlagen: ChecklistVorlage[];
loading: boolean;
fahrzeugTypen: FahrzeugTyp[];
queryClient: ReturnType<typeof useQueryClient>;
showSuccess: (msg: string) => void;
showError: (msg: string) => void;
}
function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSuccess, showError }: VorlagenTabProps) {
const [dialogOpen, setDialogOpen] = useState(false);
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 };
const [form, setForm] = useState<CreateVorlagePayload>(emptyForm);
const createMutation = useMutation({
mutationFn: (data: CreateVorlagePayload) => checklistenApi.createVorlage(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage erstellt'); },
onError: () => showError('Fehler beim Erstellen der Vorlage'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateVorlagePayload }) => checklistenApi.updateVorlage(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); setDialogOpen(false); showSuccess('Vorlage aktualisiert'); },
onError: () => showError('Fehler beim Aktualisieren der Vorlage'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => checklistenApi.deleteVorlage(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlagen'] }); showSuccess('Vorlage gel\u00f6scht'); },
onError: () => showError('Fehler beim L\u00f6schen der Vorlage'),
});
const openCreate = () => { setEditingVorlage(null); setForm(emptyForm); 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 });
setDialogOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) return;
if (editingVorlage) {
updateMutation.mutate({ id: editingVorlage.id, data: form });
} else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>Neue Vorlage</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Fahrzeugtyp</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vorlagen.length === 0 ? (
<TableRow><TableCell colSpan={5} align="center">Keine Vorlagen vorhanden</TableCell></TableRow>
) : (
vorlagen.map((v) => (
<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 ?? '\u2013'}</TableCell>
<TableCell>
{v.intervall ? INTERVALL_LABELS[v.intervall] || v.intervall : '\u2013'}
{v.intervall === 'custom' && v.intervall_tage ? ` (${v.intervall_tage} Tage)` : ''}
</TableCell>
<TableCell>
<Chip label={v.aktiv ? 'Aktiv' : 'Inaktiv'} color={v.aktiv ? 'success' : 'default'} size="small" />
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); openEdit(v); }}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(v.id); }}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
{expandedVorlageId === v.id && (
<TableRow>
<TableCell colSpan={5} sx={{ p: 2, bgcolor: 'action.hover' }}>
<VorlageItemsSection vorlageId={v.id} queryClient={queryClient} showSuccess={showSuccess} showError={showError} />
</TableCell>
</TableRow>
)}
</React.Fragment>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<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>
</FormControl>
<FormControl fullWidth>
<InputLabel>Intervall</InputLabel>
<Select label="Intervall" value={form.intervall ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall: (e.target.value || undefined) as CreateVorlagePayload['intervall'] }))}>
<MenuItem value="">Kein Intervall</MenuItem>
<MenuItem value="weekly">W\u00f6chentlich</MenuItem>
<MenuItem value="monthly">Monatlich</MenuItem>
<MenuItem value="yearly">J\u00e4hrlich</MenuItem>
<MenuItem value="custom">Benutzerdefiniert</MenuItem>
</Select>
</FormControl>
{form.intervall === 'custom' && (
<TextField label="Intervall (Tage)" type="number" fullWidth value={form.intervall_tage ?? ''} onChange={(e) => setForm((f) => ({ ...f, intervall_tage: e.target.value ? Number(e.target.value) : undefined }))} inputProps={{ min: 1 }} />
)}
<TextField label="Beschreibung" fullWidth multiline rows={2} value={form.beschreibung ?? ''} onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))} />
<FormControlLabel control={<Switch checked={form.aktiv !== false} onChange={(e) => setForm((f) => ({ ...f, aktiv: e.target.checked }))} />} label="Aktiv" />
</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>
);
}
// ── Vorlage Items Sub-section ──
function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }: { vorlageId: number; queryClient: ReturnType<typeof useQueryClient>; showSuccess: (msg: string) => void; showError: (msg: string) => void }) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['checklisten-vorlage-items', vorlageId],
queryFn: () => checklistenApi.getVorlageItems(vorlageId),
});
const [newItem, setNewItem] = useState<CreateVorlageItemPayload>({ bezeichnung: '', pflicht: false, sort_order: 0 });
const addMutation = useMutation({
mutationFn: (data: CreateVorlageItemPayload) => checklistenApi.addVorlageItem(vorlageId, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlage-items', vorlageId] }); setNewItem({ bezeichnung: '', pflicht: false, sort_order: 0 }); showSuccess('Item hinzugef\u00fcgt'); },
onError: () => showError('Fehler beim Hinzuf\u00fcgen'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => checklistenApi.deleteVorlageItem(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlage-items', vorlageId] }); showSuccess('Item entfernt'); },
onError: () => showError('Fehler beim Entfernen'),
});
if (isLoading) return <CircularProgress size={20} />;
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Checklisten-Items</Typography>
{items.map((item) => (
<Box key={item.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{item.bezeichnung} {item.pflicht && <Chip label="Pflicht" size="small" color="warning" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" color="error" onClick={() => deleteMutation.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
<TextField size="small" placeholder="Neues Item..." value={newItem.bezeichnung} onChange={(e) => setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" />
<Button size="small" variant="outlined" disabled={!newItem.bezeichnung.trim() || addMutation.isPending} onClick={() => addMutation.mutate(newItem)}>
Hinzuf\u00fcgen
</Button>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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\u00f6scht'); },
onError: () => showError('Fehler beim L\u00f6schen'),
});
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 ?? '\u2013'}</TableCell>
<TableCell>{t.icon ?? '\u2013'}</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
// ══════════════════════════════════════════════════════════════════════════════
interface HistorieTabProps {
executions: ChecklistAusfuehrung[];
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}
function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
const [statusFilter, setStatusFilter] = useState<string>('');
const [vehicleFilter, setVehicleFilter] = useState<string>('');
const filtered = executions.filter((e) => {
if (statusFilter && e.status !== statusFilter) return false;
if (vehicleFilter && e.fahrzeug_name !== vehicleFilter) return false;
return true;
});
const uniqueVehicles = [...new Set(executions.map((e) => e.fahrzeug_name).filter(Boolean))] as string[];
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
return (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select label="Status" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{Object.entries(CHECKLIST_STATUS_LABELS).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Fahrzeug</InputLabel>
<Select label="Fahrzeug" value={vehicleFilter} onChange={(e) => setVehicleFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{uniqueVehicles.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
</Select>
</FormControl>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fahrzeug</TableCell>
<TableCell>Vorlage</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
<TableCell>Ausgef\u00fchrt von</TableCell>
<TableCell>Freigegeben von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} align="center">Keine Eintr\u00e4ge</TableCell></TableRow>
) : (
filtered.map((e) => (
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
<TableCell>{e.fahrzeug_name ?? '\u2013'}</TableCell>
<TableCell>{e.vorlage_name ?? '\u2013'}</TableCell>
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>
<Chip label={CHECKLIST_STATUS_LABELS[e.status]} color={CHECKLIST_STATUS_COLORS[e.status]} size="small" />
</TableCell>
<TableCell>{e.ausgefuehrt_von_name ?? '\u2013'}</TableCell>
<TableCell>{e.freigegeben_von_name ?? '\u2013'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}

View File

@@ -1,10 +1,26 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Container,
Box,
Fade,
IconButton,
Tooltip,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { Edit as EditIcon, Check as CheckIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
SortableContext,
rectSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -28,18 +44,52 @@ import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
import LinksWidget from '../components/dashboard/LinksWidget';
import BannerWidget from '../components/dashboard/BannerWidget';
import WidgetGroup from '../components/dashboard/WidgetGroup';
import SortableWidget from '../components/dashboard/SortableWidget';
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
import { preferencesApi } from '../services/settings';
import { configApi } from '../services/config';
import { WidgetKey } from '../constants/widgets';
// ── Widget definitions per group ──
interface WidgetDef {
key: string;
widgetKey?: WidgetKey;
permission?: string;
component: React.ReactNode;
}
type GroupName = 'status' | 'kalender' | 'dienste' | 'information';
const GROUP_ORDER: { name: GroupName; title: string }[] = [
{ name: 'status', title: 'Status' },
{ name: 'kalender', title: 'Kalender' },
{ name: 'dienste', title: 'Dienste' },
{ name: 'information', title: 'Information' },
];
// Default widget order per group (used when no preference is set)
const DEFAULT_ORDER: Record<GroupName, string[]> = {
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue'],
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
information: ['links', 'bannerWidget'],
};
function Dashboard() {
const { user } = useAuth();
const { hasPermission } = usePermissionContext();
const queryClient = useQueryClient();
const [dataLoading, setDataLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const { data: preferences } = useQuery({
queryKey: ['user-preferences'],
@@ -60,203 +110,214 @@ function Dashboard() {
return preferences?.widgets?.[key] !== false;
};
// Build widget definitions for each group
const widgetDefs: Record<GroupName, WidgetDef[]> = useMemo(() => ({
status: [
{ key: 'vehicles', widgetKey: 'vehicles', permission: 'fahrzeuge:widget', component: <VehicleDashboardCard /> },
{ key: 'equipment', widgetKey: 'equipment', permission: 'ausruestung:widget', component: <EquipmentDashboardCard /> },
{ key: 'atemschutz', widgetKey: 'atemschutz', permission: 'atemschutz:widget', component: <AtemschutzDashboardCard /> },
{ key: 'adminStatus', widgetKey: 'adminStatus', permission: 'admin:view', component: <AdminStatusWidget /> },
{ key: 'bestellungen', widgetKey: 'bestellungen', permission: 'bestellungen:widget', component: <BestellungenWidget /> },
{ key: 'ausruestungsanfragen', widgetKey: 'ausruestungsanfragen', permission: 'ausruestungsanfrage:widget', component: <AusruestungsanfrageWidget /> },
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
],
kalender: [
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },
{ key: 'vehicleBookingList', widgetKey: 'vehicleBookingList', permission: 'fahrzeugbuchungen:view', component: <VehicleBookingListWidget /> },
{ key: 'vehicleBooking', widgetKey: 'vehicleBooking', permission: 'fahrzeugbuchungen:manage', component: <VehicleBookingQuickAddWidget /> },
{ key: 'eventQuickAdd', widgetKey: 'eventQuickAdd', permission: 'kalender:create', component: <EventQuickAddWidget /> },
],
dienste: [
{ key: 'bookstackRecent', widgetKey: 'bookstackRecent', permission: 'wissen:view', component: <BookStackRecentWidget /> },
{ key: 'bookstackSearch', widgetKey: 'bookstackSearch', permission: 'wissen:view', component: <BookStackSearchWidget /> },
{ key: 'vikunjaTasks', widgetKey: 'vikunjaTasks', permission: 'vikunja:widget_tasks', component: <VikunjaMyTasksWidget /> },
{ key: 'vikunjaQuickAdd', widgetKey: 'vikunjaQuickAdd', permission: 'vikunja:widget_quick_add', component: <VikunjaQuickAddWidget /> },
{ key: 'issueQuickAdd', widgetKey: 'issueQuickAdd', permission: 'issues:widget', component: <IssueQuickAddWidget /> },
],
information: [
// Links are handled specially (dynamic collections), but we keep a placeholder
{ key: 'links', widgetKey: 'links', permission: 'dashboard:widget_links', component: null },
{ key: 'bannerWidget', permission: 'dashboard:widget_banner', component: <BannerWidget /> },
],
}), []);
// Widget order from preferences, falling back to defaults
const [localOrder, setLocalOrder] = useState<Record<GroupName, string[]>>(DEFAULT_ORDER);
useEffect(() => {
if (preferences?.widgetOrder) {
setLocalOrder((prev) => {
const merged = { ...prev };
for (const group of Object.keys(DEFAULT_ORDER) as GroupName[]) {
if (preferences.widgetOrder[group]) {
// Merge: saved order first, then any new widgets not in saved order
const saved = preferences.widgetOrder[group] as string[];
const allKeys = DEFAULT_ORDER[group];
const ordered = saved.filter((k: string) => allKeys.includes(k));
const remaining = allKeys.filter((k) => !ordered.includes(k));
merged[group] = [...ordered, ...remaining];
}
}
return merged;
});
}
}, [preferences?.widgetOrder]);
// Get sorted + filtered widgets for a group
const getVisibleWidgets = useCallback((group: GroupName) => {
const order = localOrder[group];
const defs = widgetDefs[group];
return order
.map((key) => defs.find((d) => d.key === key))
.filter((d): d is WidgetDef => {
if (!d) return false;
if (d.permission && !hasPermission(d.permission)) return false;
if (d.widgetKey && !widgetVisible(d.widgetKey)) return false;
return true;
});
}, [localOrder, widgetDefs, hasPermission, preferences]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
// Find which group both items belong to
for (const group of Object.keys(localOrder) as GroupName[]) {
const order = localOrder[group];
const oldIndex = order.indexOf(active.id as string);
const newIndex = order.indexOf(over.id as string);
if (oldIndex !== -1 && newIndex !== -1) {
const newOrder = arrayMove(order, oldIndex, newIndex);
setLocalOrder((prev) => ({ ...prev, [group]: newOrder }));
// Persist
const updatedOrder = { ...localOrder, [group]: newOrder };
preferencesApi.update({ widgetOrder: updatedOrder }).then(() => {
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
});
break;
}
}
}, [localOrder, queryClient]);
useEffect(() => {
const timer = setTimeout(() => {
setDataLoading(false);
}, 800);
return () => clearTimeout(timer);
}, []);
const baseDelay = 300;
let delayCounter = 0;
const nextDelay = () => `${baseDelay + (delayCounter++) * 40}ms`;
// Render a group's widgets
const renderGroup = (group: GroupName) => {
const visible = getVisibleWidgets(group);
const keys = visible.map((d) => d.key);
// Special handling for information group (links are dynamic)
if (group === 'information') {
const linksVisible = visible.some((d) => d.key === 'links');
const bannerVisible = visible.some((d) => d.key === 'bannerWidget');
const hasContent = (linksVisible && linkCollections.length > 0) || bannerVisible;
if (!hasContent) return null;
return (
<WidgetGroup title="Information" gridColumn="1 / -1" key="information">
{linksVisible && linkCollections.map((collection) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
<Box>
<LinksWidget collection={collection} />
</Box>
</Fade>
))}
{bannerVisible && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
<Box>
<BannerWidget />
</Box>
</Fade>
)}
</WidgetGroup>
);
}
if (keys.length === 0) return null;
return (
<WidgetGroup
title={GROUP_ORDER.find((g) => g.name === group)!.title}
gridColumn="1 / -1"
key={group}
>
<SortableContext items={keys} strategy={rectSortingStrategy}>
{visible.map((def) => {
const delay = nextDelay();
return (
<SortableWidget key={def.key} id={def.key} editMode={editMode}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: delay }}>
<Box>{def.component}</Box>
</Fade>
</SortableWidget>
);
})}
</SortableContext>
</WidgetGroup>
);
};
return (
<DashboardLayout>
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
<VikunjaOverdueNotifier />
{/* Atemschutz — Expiry Notifier (invisible, polling component — outside grid) */}
<AtemschutzExpiryNotifier />
{/* Edit mode toggle */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1, px: 1 }}>
<Tooltip title={editMode ? 'Bearbeitung beenden' : 'Widgets anordnen'}>
<IconButton
size="small"
onClick={() => setEditMode((prev) => !prev)}
color={editMode ? 'primary' : 'default'}
>
{editMode ? <CheckIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Container maxWidth={false} disableGutters>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
alignItems: 'start',
}}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{/* Announcement Banner — spans full width, renders null when no banners */}
<AnnouncementBanner gridColumn="1 / -1" />
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
alignItems: 'start',
}}
>
<AnnouncementBanner gridColumn="1 / -1" />
{/* User Profile Card — full width, contains welcome greeting */}
{user && (
<Box sx={{ gridColumn: '1 / -1' }}>
{dataLoading ? (
<SkeletonCard variant="detailed" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
<Box>
<UserProfile user={user} />
</Box>
</Fade>
)}
</Box>
)}
{/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1">
{hasPermission('fahrzeuge:widget') && widgetVisible('vehicles') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box>
<VehicleDashboardCard />
</Box>
</Fade>
{user && (
<Box sx={{ gridColumn: '1 / -1' }}>
{dataLoading ? (
<SkeletonCard variant="detailed" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
<Box>
<UserProfile user={user} />
</Box>
</Fade>
)}
</Box>
)}
{hasPermission('ausruestung:widget') && widgetVisible('equipment') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box>
<EquipmentDashboardCard />
</Box>
</Fade>
)}
{hasPermission('atemschutz:widget') && widgetVisible('atemschutz') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box>
<AtemschutzDashboardCard />
</Box>
</Fade>
)}
{hasPermission('admin:view') && widgetVisible('adminStatus') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box>
<AdminStatusWidget />
</Box>
</Fade>
)}
{hasPermission('bestellungen:widget') && widgetVisible('bestellungen') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '460ms' }}>
<Box>
<BestellungenWidget />
</Box>
</Fade>
)}
{hasPermission('ausruestungsanfrage:widget') && widgetVisible('ausruestungsanfragen') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '470ms' }}>
<Box>
<AusruestungsanfrageWidget />
</Box>
</Fade>
)}
{hasPermission('issues:view_all') && widgetVisible('issueOverview') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<IssueOverviewWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Kalender Group */}
<WidgetGroup title="Kalender" gridColumn="1 / -1">
{hasPermission('kalender:view') && widgetVisible('events') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<UpcomingEventsWidget />
</Box>
</Fade>
)}
{hasPermission('fahrzeugbuchungen:view') && widgetVisible('vehicleBookingList') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<VehicleBookingListWidget />
</Box>
</Fade>
)}
{hasPermission('fahrzeugbuchungen:manage') && widgetVisible('vehicleBooking') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<VehicleBookingQuickAddWidget />
</Box>
</Fade>
)}
{hasPermission('kalender:create') && widgetVisible('eventQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<EventQuickAddWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Dienste Group */}
<WidgetGroup title="Dienste" gridColumn="1 / -1">
{hasPermission('wissen:view') && widgetVisible('bookstackRecent') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<BookStackRecentWidget />
</Box>
</Fade>
)}
{hasPermission('wissen:view') && widgetVisible('bookstackSearch') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box>
<BookStackSearchWidget />
</Box>
</Fade>
)}
{hasPermission('vikunja:widget_tasks') && widgetVisible('vikunjaTasks') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box>
<VikunjaMyTasksWidget />
</Box>
</Fade>
)}
{hasPermission('vikunja:widget_quick_add') && widgetVisible('vikunjaQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box>
<VikunjaQuickAddWidget />
</Box>
</Fade>
)}
{hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
<Box>
<IssueQuickAddWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Information Group */}
<WidgetGroup title="Information" gridColumn="1 / -1">
{hasPermission('dashboard:widget_links') && widgetVisible('links') && linkCollections.map((collection, idx) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + idx * 40}ms` }}>
<Box>
<LinksWidget collection={collection} />
</Box>
</Fade>
))}
{hasPermission('dashboard:widget_banner') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: `${760 + linkCollections.length * 40}ms` }}>
<Box>
<BannerWidget />
</Box>
</Fade>
)}
</WidgetGroup>
</Box>
{GROUP_ORDER.map((g) => renderGroup(g.name))}
</Box>
</DndContext>
</Container>
</DashboardLayout>
);

View File

@@ -77,7 +77,9 @@ import {
import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab';
// ── Tab Panel ─────────────────────────────────────────────────────────────────
@@ -880,6 +882,7 @@ function FahrzeugDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions();
const { hasPermission } = usePermissionContext();
const notification = useNotification();
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
@@ -1035,6 +1038,7 @@ function FahrzeugDetail() {
/>
<Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
{hasPermission('checklisten:view') && <Tab label="Checklisten" />}
</Tabs>
</Box>
@@ -1071,6 +1075,12 @@ function FahrzeugDetail() {
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
</TabPanel>
{hasPermission('checklisten:view') && (
<TabPanel value={activeTab} index={4}>
<FahrzeugChecklistTab fahrzeugId={vehicle.id} />
</TabPanel>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Fahrzeug löschen</DialogTitle>

View File

@@ -3,11 +3,14 @@ import {
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
} from '@mui/material';
import {
ArrowBack, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Refresh as RefreshIcon, History,
AttachFile as AttachFileIcon, InsertDriveFile as FileIcon,
Upload as UploadIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -16,13 +19,29 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie } from '../types/issue.types';
import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie, IssueDatei } from '../types/issue.types';
// ── Helpers (copied from Issues.tsx) ──
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
const formatDateOnly = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '-';
const toInputDate = (iso?: string | null) => {
if (!iso) return '';
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatIssueId = (issue: Issue) =>
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
@@ -123,6 +142,12 @@ export default function IssueDetail() {
enabled: !isNaN(issueId),
});
const { data: files = [] } = useQuery<IssueDatei[]>({
queryKey: ['issues', issueId, 'files'],
queryFn: () => issuesApi.getFiles(issueId),
enabled: !isNaN(issueId),
});
// ── Permissions ──
const isOwner = issue?.erstellt_von === userId;
const isAssignee = issue?.zugewiesen_an === userId;
@@ -175,6 +200,30 @@ export default function IssueDetail() {
onError: () => showError('Kommentar konnte nicht erstellt werden'),
});
const uploadFileMut = useMutation({
mutationFn: (file: File) => issuesApi.uploadFile(issueId, file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'files'] });
showSuccess('Datei hochgeladen');
},
onError: () => showError('Datei konnte nicht hochgeladen werden'),
});
const deleteFileMut = useMutation({
mutationFn: (fileId: string) => issuesApi.deleteFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'files'] });
showSuccess('Datei gelöscht');
},
onError: () => showError('Datei konnte nicht gelöscht werden'),
});
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFileMut.mutate(file);
e.target.value = '';
};
const handleReopen = () => {
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
onSuccess: () => {
@@ -275,6 +324,20 @@ export default function IssueDetail() {
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined" sx={issue.faellig_am && new Date(issue.faellig_am) < new Date() ? { borderColor: 'error.main' } : undefined}>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Fällig am</Typography>
<Typography
variant="body2"
sx={{ mt: 0.5 }}
color={issue.faellig_am && new Date(issue.faellig_am) < new Date() ? 'error' : 'text.primary'}
>
{formatDateOnly(issue.faellig_am)}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Description */}
@@ -285,6 +348,57 @@ export default function IssueDetail() {
</Paper>
)}
{/* Attachments */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AttachFileIcon fontSize="small" />
<Typography variant="subtitle2">Anhänge ({files.length})</Typography>
</Box>
{canComment && (
<Button
size="small"
startIcon={<UploadIcon />}
component="label"
disabled={uploadFileMut.isPending}
>
{uploadFileMut.isPending ? 'Hochladen...' : 'Datei hochladen'}
<input type="file" hidden onChange={handleFileUpload} />
</Button>
)}
</Box>
{files.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Anhänge</Typography>
) : (
<List dense disablePadding>
{files.map((f) => (
<ListItem key={f.id} disableGutters>
<ListItemIcon sx={{ minWidth: 36 }}>
<FileIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={f.dateiname}
secondary={`${formatFileSize(f.dateigroesse)}${formatDate(f.hochgeladen_am)}`}
/>
<ListItemSecondaryAction>
{(hasEdit || isOwner) && (
<IconButton
size="small"
edge="end"
color="error"
onClick={() => deleteFileMut.mutate(f.id)}
disabled={deleteFileMut.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</Paper>
{/* Controls row */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Status control */}
@@ -341,6 +455,19 @@ export default function IssueDetail() {
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
)}
{/* Due date */}
{hasEdit && (
<TextField
size="small"
label="Fällig am"
type="date"
value={toInputDate(issue.faellig_am)}
onChange={(e) => updateMut.mutate({ faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
)}
</Box>
{/* Delete button */}

View File

@@ -121,6 +121,16 @@ export default function IssueNeu() {
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am"
type="date"
fullWidth
value={form.faellig_am || ''}
onChange={(e) => setForm({ ...form, faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>

View File

@@ -4,13 +4,14 @@ import {
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, CircularProgress, FormControlLabel, Switch,
Autocomplete,
Autocomplete, ToggleButtonGroup, ToggleButton,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline,
Circle as CircleIcon, Edit as EditIcon,
DragIndicator, Check as CheckIcon, Close as CloseIcon,
ViewList as ViewListIcon, ViewKanban as ViewKanbanIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useNavigate } from 'react-router-dom';
@@ -21,6 +22,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
import KanbanBoard from '../components/issues/KanbanBoard';
// ── Helpers ──
@@ -555,8 +557,10 @@ function IssueSettings() {
export default function Issues() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const { showSuccess, showError } = useNotification();
const canViewAll = hasPermission('issues:view_all');
const hasEdit = hasPermission('issues:edit');
@@ -581,6 +585,27 @@ export default function Issues() {
const [showDoneMine, setShowDoneMine] = useState(false);
const [showDoneAssigned, setShowDoneAssigned] = useState(false);
const [filters, setFilters] = useState<IssueFilters>({});
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() => {
try { return (localStorage.getItem('issues-view-mode') as 'list' | 'kanban') || 'list'; }
catch { return 'list'; }
});
const handleViewModeChange = (_: unknown, val: 'list' | 'kanban' | null) => {
if (!val) return;
setViewMode(val);
localStorage.setItem('issues-view-mode', val);
};
// Mutation for kanban drag-and-drop status change
const updateStatusMut = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) =>
issuesApi.updateIssue(id, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Status aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
// Fetch all issues for mine/assigned tabs
const { data: issues = [], isLoading } = useQuery({
@@ -634,7 +659,22 @@ export default function Issues() {
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>Issues</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h5">Issues</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={handleViewModeChange}
size="small"
>
<ToggleButton value="list" aria-label="Listenansicht">
<ViewListIcon fontSize="small" />
</ToggleButton>
<ToggleButton value="kanban" aria-label="Kanban-Ansicht">
<ViewKanbanIcon fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
{tabs.map((t, i) => <Tab key={i} label={t.label} />)}
@@ -642,13 +682,24 @@ export default function Issues() {
{/* Tab 0: Meine Issues */}
<TabPanel value={tab} index={0}>
<FormControlLabel
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
{viewMode === 'list' && (
<FormControlLabel
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
/>
)}
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : viewMode === 'kanban' ? (
<KanbanBoard
issues={myIssuesFiltered}
statuses={issueStatuses}
priorities={issuePriorities}
onNavigate={(id) => navigate(`/issues/${id}`)}
onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })}
/>
) : (
<IssueTable issues={myIssuesFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)}
@@ -656,13 +707,24 @@ export default function Issues() {
{/* Tab 1: Zugewiesene Issues */}
<TabPanel value={tab} index={1}>
<FormControlLabel
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
{viewMode === 'list' && (
<FormControlLabel
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
/>
)}
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : viewMode === 'kanban' ? (
<KanbanBoard
issues={assignedFiltered}
statuses={issueStatuses}
priorities={issuePriorities}
onNavigate={(id) => navigate(`/issues/${id}`)}
onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })}
/>
) : (
<IssueTable issues={assignedFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)}
@@ -674,6 +736,14 @@ export default function Issues() {
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} statuses={issueStatuses} priorities={issuePriorities} />
{isFilteredLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : viewMode === 'kanban' ? (
<KanbanBoard
issues={filteredIssues}
statuses={issueStatuses}
priorities={issuePriorities}
onNavigate={(id) => navigate(`/issues/${id}`)}
onStatusChange={(id, status) => updateStatusMut.mutate({ id, status })}
/>
) : (
<IssueTable issues={filteredIssues} statuses={issueStatuses} priorities={issuePriorities} />
)}

View File

@@ -0,0 +1,131 @@
import { api } from './api';
import type {
ChecklistVorlage,
ChecklistVorlageItem,
FahrzeugChecklistItem,
ChecklistAusfuehrung,
ChecklistFaelligkeit,
ChecklistVorlageFilter,
ChecklistAusfuehrungFilter,
CreateVorlagePayload,
UpdateVorlagePayload,
CreateVorlageItemPayload,
UpdateVorlageItemPayload,
CreateFahrzeugItemPayload,
UpdateFahrzeugItemPayload,
SubmitAusfuehrungPayload,
} from '../types/checklist.types';
export const checklistenApi = {
// ── Vorlagen (Templates) ──
getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => {
const params = new URLSearchParams();
if (filter?.fahrzeug_typ_id != null) params.set('fahrzeug_typ_id', String(filter.fahrzeug_typ_id));
if (filter?.aktiv != null) params.set('aktiv', String(filter.aktiv));
const qs = params.toString();
const r = await api.get(`/api/checklisten/vorlagen${qs ? `?${qs}` : ''}`);
return r.data.data;
},
getVorlage: async (id: number): Promise<ChecklistVorlage> => {
const r = await api.get(`/api/checklisten/vorlagen/${id}`);
return r.data.data;
},
createVorlage: async (data: CreateVorlagePayload): Promise<ChecklistVorlage> => {
const r = await api.post('/api/checklisten/vorlagen', data);
return r.data.data;
},
updateVorlage: async (id: number, data: UpdateVorlagePayload): Promise<ChecklistVorlage> => {
const r = await api.put(`/api/checklisten/vorlagen/${id}`, data);
return r.data.data;
},
deleteVorlage: async (id: number): Promise<void> => {
await api.delete(`/api/checklisten/vorlagen/${id}`);
},
// ── Vorlage Items ──
getVorlageItems: async (vorlageId: number): Promise<ChecklistVorlageItem[]> => {
const r = await api.get(`/api/checklisten/vorlagen/${vorlageId}/items`);
return r.data.data;
},
addVorlageItem: async (vorlageId: number, data: CreateVorlageItemPayload): Promise<ChecklistVorlageItem> => {
const r = await api.post(`/api/checklisten/vorlagen/${vorlageId}/items`, data);
return r.data.data;
},
updateVorlageItem: async (id: number, data: UpdateVorlageItemPayload): Promise<ChecklistVorlageItem> => {
const r = await api.put(`/api/checklisten/items/${id}`, data);
return r.data.data;
},
deleteVorlageItem: async (id: number): Promise<void> => {
await api.delete(`/api/checklisten/items/${id}`);
},
// ── Vehicle-specific Items ──
getVehicleItems: async (fahrzeugId: string): Promise<FahrzeugChecklistItem[]> => {
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/items`);
return r.data.data;
},
addVehicleItem: async (fahrzeugId: string, data: CreateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
const r = await api.post(`/api/checklisten/fahrzeug/${fahrzeugId}/items`, data);
return r.data.data;
},
updateVehicleItem: async (id: number, data: UpdateFahrzeugItemPayload): Promise<FahrzeugChecklistItem> => {
const r = await api.put(`/api/checklisten/fahrzeug-items/${id}`, data);
return r.data.data;
},
deleteVehicleItem: async (id: number): Promise<void> => {
await api.delete(`/api/checklisten/fahrzeug-items/${id}`);
},
// ── Checklists for a Vehicle ──
getChecklistenForVehicle: async (fahrzeugId: string): Promise<ChecklistVorlage[]> => {
const r = await api.get(`/api/checklisten/fahrzeug/${fahrzeugId}/checklisten`);
return r.data.data;
},
// ── Executions ──
startExecution: async (fahrzeugId: string, vorlageId: number): Promise<ChecklistAusfuehrung> => {
const r = await api.post('/api/checklisten/ausfuehrungen', { fahrzeug_id: fahrzeugId, vorlage_id: vorlageId });
return r.data.data;
},
submitExecution: async (id: string, data: SubmitAusfuehrungPayload): Promise<ChecklistAusfuehrung> => {
const r = await api.put(`/api/checklisten/ausfuehrungen/${id}`, data);
return r.data.data;
},
approveExecution: async (id: string): Promise<ChecklistAusfuehrung> => {
const r = await api.post(`/api/checklisten/ausfuehrungen/${id}/freigabe`);
return r.data.data;
},
getExecution: async (id: string): Promise<ChecklistAusfuehrung> => {
const r = await api.get(`/api/checklisten/ausfuehrungen/${id}`);
return r.data.data;
},
getExecutions: async (filter?: ChecklistAusfuehrungFilter): Promise<ChecklistAusfuehrung[]> => {
const params = new URLSearchParams();
if (filter?.fahrzeug_id) params.set('fahrzeug_id', filter.fahrzeug_id);
if (filter?.vorlage_id != null) params.set('vorlage_id', String(filter.vorlage_id));
if (filter?.status?.length) params.set('status', filter.status.join(','));
const qs = params.toString();
const r = await api.get(`/api/checklisten/ausfuehrungen${qs ? `?${qs}` : ''}`);
return r.data.data;
},
// ── Overdue / Due ──
getOverdue: async (): Promise<ChecklistFaelligkeit[]> => {
const r = await api.get('/api/checklisten/faellig');
return r.data.data;
},
};

View File

@@ -0,0 +1,28 @@
import { api } from './api';
import type { FahrzeugTyp } from '../types/checklist.types';
export const fahrzeugTypenApi = {
getAll: async (): Promise<FahrzeugTyp[]> => {
const r = await api.get('/api/fahrzeug-typen');
return r.data.data;
},
getById: async (id: number): Promise<FahrzeugTyp> => {
const r = await api.get(`/api/fahrzeug-typen/${id}`);
return r.data.data;
},
create: async (data: Partial<FahrzeugTyp>): Promise<FahrzeugTyp> => {
const r = await api.post('/api/fahrzeug-typen', data);
return r.data.data;
},
update: async (id: number, data: Partial<FahrzeugTyp>): Promise<FahrzeugTyp> => {
const r = await api.put(`/api/fahrzeug-typen/${id}`, data);
return r.data.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/api/fahrzeug-typen/${id}`);
},
};

View File

@@ -1,5 +1,5 @@
import { api } from './api';
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie } from '../types/issue.types';
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary, IssueHistorie, IssueDatei } from '../types/issue.types';
export const issuesApi = {
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
@@ -98,4 +98,20 @@ export const issuesApi = {
deletePriority: async (id: number): Promise<void> => {
await api.delete(`/api/issues/priorities/${id}`);
},
// Files
uploadFile: async (issueId: number, file: File): Promise<IssueDatei> => {
const formData = new FormData();
formData.append('file', file);
const r = await api.post(`/api/issues/${issueId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return r.data.data;
},
getFiles: async (issueId: number): Promise<IssueDatei[]> => {
const r = await api.get(`/api/issues/${issueId}/files`);
return r.data.data;
},
deleteFile: async (fileId: string): Promise<void> => {
await api.delete(`/api/issues/files/${fileId}`);
},
};

View File

@@ -0,0 +1,162 @@
export interface FahrzeugTyp {
id: number;
name: string;
beschreibung?: string;
icon?: string;
created_at: string;
}
export interface ChecklistVorlageItem {
id: number;
vorlage_id: number;
bezeichnung: string;
beschreibung?: string;
pflicht: boolean;
sort_order: number;
}
export interface ChecklistVorlage {
id: number;
name: string;
fahrzeug_typ_id?: number;
fahrzeug_typ?: FahrzeugTyp;
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
intervall_tage?: number;
beschreibung?: string;
aktiv: boolean;
items?: ChecklistVorlageItem[];
created_at: string;
updated_at: string;
}
export interface FahrzeugChecklistItem {
id: number;
fahrzeug_id: string;
bezeichnung: string;
beschreibung?: string;
pflicht: boolean;
sort_order: number;
aktiv: boolean;
}
export interface ChecklistAusfuehrungItem {
id: number;
ausfuehrung_id: string;
vorlage_item_id?: number;
fahrzeug_item_id?: number;
bezeichnung: string;
ergebnis?: 'ok' | 'nok' | 'na';
kommentar?: string;
created_at: string;
}
export interface ChecklistAusfuehrung {
id: string;
fahrzeug_id: string;
fahrzeug_name?: string;
vorlage_id?: number;
vorlage_name?: string;
status: 'offen' | 'abgeschlossen' | 'unvollstaendig' | 'freigegeben';
ausgefuehrt_von?: string;
ausgefuehrt_von_name?: string;
ausgefuehrt_am?: string;
freigegeben_von?: string;
freigegeben_von_name?: string;
freigegeben_am?: string;
notizen?: string;
items?: ChecklistAusfuehrungItem[];
created_at: string;
}
export interface ChecklistFaelligkeit {
fahrzeug_id: string;
fahrzeug_name: string;
vorlage_id: number;
vorlage_name: string;
naechste_faellig_am: string;
letzte_ausfuehrung_id?: string;
}
export type ChecklistAusfuehrungStatus = ChecklistAusfuehrung['status'];
export const CHECKLIST_STATUS_LABELS: Record<ChecklistAusfuehrungStatus, string> = {
offen: 'Offen',
abgeschlossen: 'Abgeschlossen',
unvollstaendig: 'Unvollst\u00e4ndig',
freigegeben: 'Freigegeben',
};
export const CHECKLIST_STATUS_COLORS: Record<ChecklistAusfuehrungStatus, 'default' | 'warning' | 'success' | 'info'> = {
offen: 'default',
abgeschlossen: 'info',
unvollstaendig: 'warning',
freigegeben: 'success',
};
export interface ChecklistVorlageFilter {
fahrzeug_typ_id?: number;
aktiv?: boolean;
}
export interface ChecklistAusfuehrungFilter {
fahrzeug_id?: string;
vorlage_id?: number;
status?: ChecklistAusfuehrungStatus[];
}
export interface CreateVorlagePayload {
name: string;
fahrzeug_typ_id?: number;
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom';
intervall_tage?: number;
beschreibung?: string;
aktiv?: boolean;
}
export interface UpdateVorlagePayload {
name?: string;
fahrzeug_typ_id?: number | null;
intervall?: 'weekly' | 'monthly' | 'yearly' | 'custom' | null;
intervall_tage?: number | null;
beschreibung?: string | null;
aktiv?: boolean;
}
export interface CreateVorlageItemPayload {
bezeichnung: string;
beschreibung?: string;
pflicht?: boolean;
sort_order?: number;
}
export interface UpdateVorlageItemPayload {
bezeichnung?: string;
beschreibung?: string | null;
pflicht?: boolean;
sort_order?: number;
}
export interface CreateFahrzeugItemPayload {
bezeichnung: string;
beschreibung?: string;
pflicht?: boolean;
sort_order?: number;
}
export interface UpdateFahrzeugItemPayload {
bezeichnung?: string;
beschreibung?: string | null;
pflicht?: boolean;
sort_order?: number;
aktiv?: boolean;
}
export interface SubmitAusfuehrungPayload {
items: { itemId: number; ergebnis: 'ok' | 'nok' | 'na'; kommentar?: string }[];
notizen?: string;
}
export interface ChecklistWidgetSummary {
overdue: ChecklistFaelligkeit[];
dueSoon: ChecklistFaelligkeit[];
}

View File

@@ -25,6 +25,7 @@ export interface Issue {
erstellt_von_name?: string;
zugewiesen_an: string | null;
zugewiesen_an_name?: string | null;
faellig_am: string | null;
created_at: string;
updated_at: string;
}
@@ -53,6 +54,7 @@ export interface CreateIssuePayload {
beschreibung?: string;
typ_id?: number;
prioritaet?: string;
faellig_am?: string | null;
}
export interface UpdateIssuePayload {
@@ -63,6 +65,7 @@ export interface UpdateIssuePayload {
status?: string;
zugewiesen_an?: string | null;
kommentar?: string;
faellig_am?: string | null;
}
export interface IssueFilters {
@@ -108,3 +111,14 @@ export interface IssueStatusCount {
}
export type IssueWidgetSummary = IssueStatusCount[];
export interface IssueDatei {
id: string;
issue_id: number;
dateiname: string;
dateipfad: string;
dateityp: string | null;
dateigroesse: number | null;
hochgeladen_von: string | null;
hochgeladen_am: string;
}