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 = { offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error', }; const STATUS_LABELS: Record = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt', }; const PRIO_COLORS: Record = { hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e', }; const PRIO_LABELS: Record = { 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 = { BugReport: , FiberNew: , HelpOutline: , }; function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element { const icon = ICON_MAP[iconName || ''] || ; const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action'; return {icon}; } // ══════════════════════════════════════════════════════════════════════════════ // 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({ queryKey: ['issues', issueId, 'history'], queryFn: () => issuesApi.getHistory(issueId), enabled: !isNaN(issueId), }); const { data: files = [] } = useQuery({ 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) => { 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 ( ); } if (isError || !issue) { return ( Issue nicht gefunden. ); } return ( } /> {/* Info cards */} Typ {getTypIcon(issue.typ_icon, issue.typ_farbe)} {issue.typ_name} Priorität {getPrioLabel(priorities, issue.prioritaet)} Erstellt von {issue.erstellt_von_name || '-'} Zugewiesen an {issue.zugewiesen_an_name || '-'} Erstellt am {formatDate(issue.created_at)} Fällig am {formatDateOnly(issue.faellig_am)} {/* Description */} {issue.beschreibung && ( Beschreibung {issue.beschreibung} )} {/* Attachments */} Anhänge ({files.length}) {canComment && ( )} {files.length === 0 ? ( Keine Anhänge ) : ( {files.map((f) => ( {(hasEdit || isOwner) && ( deleteFileMut.mutate(f.id)} disabled={deleteFileMut.isPending} > )} ))} )} {/* Controls row */} {/* Status control */} {showReopenButton ? ( ) : canChangeStatus || isOwner ? ( Status ) : null} {/* Priority control */} {hasEdit && ( Priorität )} {/* Assignment */} {hasEdit && ( 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) => } isOptionEqualToValue={(o, v) => o.id === v.id} /> )} {/* Due date */} {hasEdit && ( updateMut.mutate({ faellig_am: e.target.value || null })} InputLabelProps={{ shrink: true }} sx={{ minWidth: 160 }} /> )} {/* Delete button */} {canDelete && ( )} {/* Comments section */} Kommentare {commentsLoading ? ( ) : comments.length === 0 ? ( Noch keine Kommentare ) : ( comments.map((c: IssueComment) => ( {c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)} {c.inhalt} )) )} {canComment && ( setCommentText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && commentText.trim()) { e.preventDefault(); addCommentMut.mutate(commentText.trim()); } }} multiline maxRows={4} /> addCommentMut.mutate(commentText.trim())} > )} {/* History section */} Historie {historie.length === 0 ? ( Keine Einträge ) : ( {historie.map((h) => ( {h.aktion} {h.erstellt_von_name || 'System'} · {formatDate(h.erstellt_am)} {h.details && ( {Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')} )} ))} )} {/* Reopen Dialog */} setReopenOpen(false)} onSubmit={handleReopen} title="Issue wiedereröffnen" submitLabel="Wiedereröffnen" isSubmitting={updateMut.isPending} maxWidth="sm" > setReopenComment(e.target.value)} autoFocus /> {/* Delete Confirmation Dialog */} 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} /> ); }