rework from modal to page
This commit is contained in:
445
frontend/src/pages/IssueDetail.tsx
Normal file
445
frontend/src/pages/IssueDetail.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack, Delete as DeleteIcon,
|
||||
BugReport, FiberNew, HelpOutline, Send as SendIcon,
|
||||
Circle as CircleIcon, Refresh as RefreshIcon,
|
||||
} 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 } 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 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,
|
||||
});
|
||||
|
||||
// ── 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'] });
|
||||
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 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 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/issues')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h5">
|
||||
{formatIssueId(issue)} — {issue.titel}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={getStatusLabel(statuses, issue.status)}
|
||||
color={getStatusColor(statuses, issue.status)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Reopen Dialog */}
|
||||
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Issue wiedereröffnen</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||
<TextField
|
||||
label="Kommentar (Pflicht)"
|
||||
required
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
value={reopenComment}
|
||||
onChange={(e) => setReopenComment(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!reopenComment.trim() || updateMut.isPending}
|
||||
onClick={handleReopen}
|
||||
>
|
||||
Wiedereröffnen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Issue löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Soll dieses Issue wirklich gelöscht werden?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => deleteMut.mutate()}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user