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

@@ -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 */}