Files
dashboard/frontend/src/pages/IssueDetail.tsx

593 lines
23 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Box, Typography, Paper, Chip, IconButton, Button,
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';
import DashboardLayout from '../components/dashboard/DashboardLayout';
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, IssueDatei } from '../types/issue.types';
import { ConfirmDialog, FormDialog, PageHeader } from '../components/templates';
// ── 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}`;
const STATUS_COLORS: Record<string, string> = {
offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error',
};
const STATUS_LABELS: Record<string, string> = {
offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt',
};
const PRIO_COLORS: Record<string, string> = {
hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e',
};
const PRIO_LABELS: Record<string, string> = {
hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig',
};
function getStatusLabel(statuses: IssueStatusDef[], key: string) {
return statuses.find(s => s.schluessel === key)?.bezeichnung ?? STATUS_LABELS[key] ?? key;
}
function getStatusColor(statuses: IssueStatusDef[], key: string): any {
return statuses.find(s => s.schluessel === key)?.farbe ?? STATUS_COLORS[key] ?? 'default';
}
function getPrioColor(priorities: IssuePriorityDef[], key: string) {
return priorities.find(p => p.schluessel === key)?.farbe ?? PRIO_COLORS[key] ?? '#9e9e9e';
}
function getPrioLabel(priorities: IssuePriorityDef[], key: string) {
return priorities.find(p => p.schluessel === key)?.bezeichnung ?? PRIO_LABELS[key] ?? key;
}
const ICON_MAP: Record<string, JSX.Element> = {
BugReport: <BugReport fontSize="small" />,
FiberNew: <FiberNew fontSize="small" />,
HelpOutline: <HelpOutline fontSize="small" />,
};
function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element {
const icon = ICON_MAP[iconName || ''] || <HelpOutline fontSize="small" />;
const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action';
return <Box component="span" sx={{ display: 'inline-flex', color: `${colorProp}.main` }}>{icon}</Box>;
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function IssueDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const issueId = Number(id);
const userId = user?.id || '';
const hasEdit = hasPermission('issues:edit');
const hasChangeStatus = hasPermission('issues:change_status');
const hasDeletePerm = hasPermission('issues:delete');
// ── State ──
const [reopenOpen, setReopenOpen] = useState(false);
const [reopenComment, setReopenComment] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [commentText, setCommentText] = useState('');
// ── Queries ──
const { data: issue, isLoading, isError } = useQuery({
queryKey: ['issues', issueId],
queryFn: () => issuesApi.getIssue(issueId),
enabled: !isNaN(issueId),
});
const { data: comments = [], isLoading: commentsLoading } = useQuery({
queryKey: ['issues', issueId, 'comments'],
queryFn: () => issuesApi.getComments(issueId),
enabled: !isNaN(issueId),
});
const { data: statuses = [] } = useQuery({
queryKey: ['issue-statuses'],
queryFn: issuesApi.getStatuses,
});
const { data: priorities = [] } = useQuery({
queryKey: ['issue-priorities'],
queryFn: issuesApi.getPriorities,
});
const { data: members = [] } = useQuery({
queryKey: ['issue-members'],
queryFn: issuesApi.getMembers,
enabled: hasEdit,
});
const { data: historie = [] } = useQuery<IssueHistorie[]>({
queryKey: ['issues', issueId, 'history'],
queryFn: () => issuesApi.getHistory(issueId),
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;
const canDelete = hasDeletePerm || isOwner;
const canComment = isOwner || isAssignee || hasChangeStatus || hasEdit;
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
const ownerOnlyErledigt = isOwner && !canChangeStatus;
const allowedStatuses = useMemo(() => {
if (!issue) return [];
const active = statuses.filter(s => s.aktiv);
if (hasEdit) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (hasChangeStatus || isAssignee) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (isOwner) return active.filter(s => s.schluessel === issue.status || s.ist_abschluss);
return active.filter(s => s.schluessel === issue.status);
}, [statuses, hasEdit, hasChangeStatus, isAssignee, isOwner, issue]);
const currentStatusDef = statuses.find(s => s.schluessel === issue?.status);
const isTerminal = currentStatusDef?.ist_abschluss ?? (issue?.status === 'erledigt');
const showReopenButton = ownerOnlyErledigt && isTerminal;
const initialStatusKey = statuses.find(s => s.ist_initial)?.schluessel ?? 'offen';
// ── Mutations ──
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issueId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'history'] });
showSuccess('Issue aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMut = useMutation({
mutationFn: () => issuesApi.deleteIssue(issueId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue gelöscht');
navigate('/issues');
},
onError: () => showError('Fehler beim Löschen'),
});
const addCommentMut = useMutation({
mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] });
setCommentText('');
},
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: () => {
setReopenOpen(false);
setReopenComment('');
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue wiedereröffnet');
},
});
};
// ── Loading / Error / 404 ──
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
</DashboardLayout>
);
}
if (isError || !issue) {
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/issues')} sx={{ mb: 2 }}>
Zurück
</Button>
<Typography color="error">Issue nicht gefunden.</Typography>
</Box>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<PageHeader
title={`${formatIssueId(issue)}${issue.titel}`}
backTo="/issues"
actions={
<Chip
label={getStatusLabel(statuses, issue.status)}
color={getStatusColor(statuses, issue.status)}
/>
}
/>
{/* Info cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Typ</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.typ_name}</Typography>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Priorität</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Erstellt von</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{issue.erstellt_von_name || '-'}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Zugewiesen an</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{issue.zugewiesen_an_name || '-'}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>{formatDate(issue.created_at)}</Typography>
</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 */}
{issue.beschreibung && (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>Beschreibung</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{issue.beschreibung}</Typography>
</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 */}
{showReopenButton ? (
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => setReopenOpen(true)}
>
Wiedereröffnen
</Button>
) : canChangeStatus || isOwner ? (
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={issue.status}
label="Status"
onChange={(e) => updateMut.mutate({ status: e.target.value })}
>
{allowedStatuses.map(s => (
<MenuItem key={s.schluessel} value={s.schluessel}>{s.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
) : null}
{/* Priority control */}
{hasEdit && (
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel>
<Select
value={issue.prioritaet}
label="Priorität"
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value })}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
)}
{/* Assignment */}
{hasEdit && (
<Autocomplete
size="small"
sx={{ minWidth: 200 }}
options={members}
getOptionLabel={(o: AssignableMember) => o.name}
value={members.find((m: AssignableMember) => m.id === issue.zugewiesen_an) || null}
onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })}
renderInput={(params) => <TextField {...params} label="Zugewiesen an" size="small" />}
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 */}
{canDelete && (
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteOpen(true)}
sx={{ mb: 2 }}
>
Löschen
</Button>
)}
<Divider sx={{ my: 2 }} />
{/* Comments section */}
<Typography variant="subtitle2" gutterBottom>Kommentare</Typography>
{commentsLoading ? (
<CircularProgress size={20} />
) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary">Noch keine Kommentare</Typography>
) : (
comments.map((c: IssueComment) => (
<Box key={c.id} sx={{ mb: 1.5, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
{c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-wrap' }}>{c.inhalt}</Typography>
</Box>
))
)}
{canComment && (
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Kommentar schreiben..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && commentText.trim()) {
e.preventDefault();
addCommentMut.mutate(commentText.trim());
}
}}
multiline
maxRows={4}
/>
<IconButton
color="primary"
disabled={!commentText.trim() || addCommentMut.isPending}
onClick={() => addCommentMut.mutate(commentText.trim())}
>
<SendIcon />
</IconButton>
</Box>
)}
{/* History section */}
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<History fontSize="small" />
<Typography variant="subtitle2">Historie</Typography>
</Box>
{historie.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Einträge</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{historie.map((h) => (
<Box key={h.id} sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ width: 6, minHeight: '100%', borderRadius: 3, bgcolor: 'divider', flexShrink: 0 }} />
<Box>
<Typography variant="body2">{h.aktion}</Typography>
<Typography variant="caption" color="text.secondary">
{h.erstellt_von_name || 'System'} &middot; {formatDate(h.erstellt_am)}
</Typography>
{h.details && (
<Typography variant="caption" display="block" color="text.secondary">
{Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')}
</Typography>
)}
</Box>
</Box>
))}
</Box>
)}
</Box>
{/* Reopen Dialog */}
<FormDialog
open={reopenOpen}
onClose={() => setReopenOpen(false)}
onSubmit={handleReopen}
title="Issue wiedereröffnen"
submitLabel="Wiedereröffnen"
isSubmitting={updateMut.isPending}
maxWidth="sm"
>
<TextField
label="Kommentar (Pflicht)"
required
multiline
rows={3}
fullWidth
value={reopenComment}
onChange={(e) => setReopenComment(e.target.value)}
autoFocus
/>
</FormDialog>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMut.mutate()}
title="Issue löschen"
message="Soll dieses Issue wirklich gelöscht werden?"
confirmLabel="Löschen"
confirmColor="error"
isLoading={deleteMut.isPending}
/>
</DashboardLayout>
);
}