feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system
This commit is contained in:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user