feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system
This commit is contained in:
@@ -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'],
|
||||
|
||||
92
frontend/src/components/dashboard/ChecklistWidget.tsx
Normal file
92
frontend/src/components/dashboard/ChecklistWidget.tsx
Normal 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;
|
||||
55
frontend/src/components/dashboard/SortableWidget.tsx
Normal file
55
frontend/src/components/dashboard/SortableWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
252
frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx
Normal file
252
frontend/src/components/fahrzeuge/FahrzeugChecklistTab.tsx
Normal 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;
|
||||
328
frontend/src/components/issues/KanbanBoard.tsx
Normal file
328
frontend/src/components/issues/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user