609 lines
24 KiB
TypeScript
609 lines
24 KiB
TypeScript
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,
|
|
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';
|
|
|
|
// ── 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 }}>
|
|
{/* 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 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'} · {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 */}
|
|
<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>
|
|
);
|
|
}
|