rework from modal to page

This commit is contained in:
Matthias Hochmeister
2026-03-25 09:37:16 +01:00
parent 4ed76fe20d
commit 4ad260ce66
9 changed files with 1714 additions and 1389 deletions

View File

@@ -3,24 +3,24 @@ import {
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch,
InputLabel, CircularProgress, FormControlLabel, Switch,
Autocomplete,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
Add as AddIcon, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline,
Circle as CircleIcon, Edit as EditIcon,
DragIndicator, Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
import type { Issue, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
// ── Helpers ──
@@ -76,338 +76,18 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ── Comment Section ──
function CommentSection({ issueId, canComment }: { issueId: number; canComment: boolean }) {
const queryClient = useQueryClient();
const { showError } = useNotification();
const [text, setText] = useState('');
const { data: comments = [], isLoading } = useQuery({
queryKey: ['issues', issueId, 'comments'],
queryFn: () => issuesApi.getComments(issueId),
});
const addMut = useMutation({
mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] });
setText('');
},
onError: () => showError('Kommentar konnte nicht erstellt werden'),
});
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Kommentare</Typography>
{isLoading ? (
<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={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && text.trim()) {
e.preventDefault();
addMut.mutate(text.trim());
}
}}
multiline
maxRows={4}
/>
<IconButton
color="primary"
disabled={!text.trim() || addMut.isPending}
onClick={() => addMut.mutate(text.trim())}
>
<SendIcon />
</IconButton>
</Box>
)}
</Box>
);
}
// ── Issue Row ──
function IssueRow({
issue,
userId,
hasEdit,
hasChangeStatus,
hasDelete,
members,
statuses,
priorities,
onDelete,
}: {
issue: Issue;
userId: string;
hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
onDelete: (id: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
const [reopenComment, setReopenComment] = useState('');
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const isOwner = issue.erstellt_von === userId;
const isAssignee = issue.zugewiesen_an === userId;
const canDelete = hasDelete || isOwner;
const canComment = isOwner || isAssignee || hasChangeStatus || hasEdit;
// Determine status change capability
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
const ownerOnlyErledigt = isOwner && !canChangeStatus;
// Build allowed statuses from dynamic list
const allowedStatuses = useMemo(() => {
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.status, issue.typ_erlaubt_abgelehnt]);
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
// Owner on erledigt issue: show reopen button instead of status select
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';
const handleReopen = () => {
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
onSuccess: () => {
setReopenOpen(false);
setReopenComment('');
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue wiedereröffnet');
},
});
};
return (
<>
<TableRow
hover
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
onClick={() => setExpanded(!expanded)}
>
<TableCell sx={{ width: 80 }}>{formatIssueId(issue)}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={getStatusLabel(statuses, issue.status)}
size="small"
color={getStatusColor(statuses, issue.status)}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
<TableCell>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
{expanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={9} sx={{ py: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{issue.beschreibung && (
<Typography variant="body2" sx={{ mb: 2, whiteSpace: 'pre-wrap' }}>
{issue.beschreibung}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Status control */}
{showReopenButton ? (
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={(e) => { e.stopPropagation(); 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 })}
onClick={(e) => e.stopPropagation()}
>
{allowedStatuses.map(s => (
<MenuItem key={s.schluessel} value={s.schluessel}>{s.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
) : null}
{/* Priority control — only with issues:edit */}
{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 })}
onClick={(e) => e.stopPropagation()}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
)}
{/* Assignment — only with issues:edit */}
{hasEdit && (
<Autocomplete
size="small"
sx={{ minWidth: 200 }}
options={members}
getOptionLabel={(o) => o.name}
value={members.find(m => 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" />}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
)}
</Box>
{canDelete && (
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={(e) => { e.stopPropagation(); onDelete(issue.id); }}
sx={{ mb: 1 }}
>
Löschen
</Button>
)}
<Divider sx={{ my: 1 }} />
<CommentSection issueId={issue.id} canComment={canComment} />
</Box>
</Collapse>
</TableCell>
</TableRow>
{/* 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>
</>
);
}
// ── Issue Table ──
function IssueTable({
issues,
userId,
hasEdit,
hasChangeStatus,
hasDelete,
members,
statuses,
priorities,
}: {
issues: Issue[];
userId: string;
hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
}) {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const deleteMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteIssue(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue gelöscht');
},
onError: () => showError('Fehler beim Löschen'),
});
const navigate = useNavigate();
if (issues.length === 0) {
return (
@@ -430,23 +110,43 @@ function IssueTable({
<TableCell>Erstellt von</TableCell>
<TableCell>Zugewiesen an</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{issues.map((issue) => (
<IssueRow
<TableRow
key={issue.id}
issue={issue}
userId={userId}
hasEdit={hasEdit}
hasChangeStatus={hasChangeStatus}
hasDelete={hasDelete}
members={members}
statuses={statuses}
priorities={priorities}
onDelete={(id) => deleteMut.mutate(id)}
/>
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/issues/${issue.id}`)}
>
<TableCell sx={{ width: 80 }}>{formatIssueId(issue)}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={getStatusLabel(statuses, issue.status)}
size="small"
color={getStatusColor(statuses, issue.status)}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
@@ -854,15 +554,12 @@ function IssueSettings() {
export default function Issues() {
const [searchParams, setSearchParams] = useSearchParams();
const { showSuccess, showError } = useNotification();
const navigate = useNavigate();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const canViewAll = hasPermission('issues:view_all');
const hasEdit = hasPermission('issues:edit');
const hasChangeStatus = hasPermission('issues:change_status');
const hasDeletePerm = hasPermission('issues:delete');
const hasEditSettings = hasPermission('issues:edit_settings');
const canCreate = hasPermission('issues:create');
const userId = user?.id || '';
@@ -884,8 +581,6 @@ export default function Issues() {
const [showDoneMine, setShowDoneMine] = useState(false);
const [showDoneAssigned, setShowDoneAssigned] = useState(false);
const [filters, setFilters] = useState<IssueFilters>({});
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: 'mittel' });
// Fetch all issues for mine/assigned tabs
const { data: issues = [], isLoading } = useQuery({
@@ -922,20 +617,6 @@ export default function Issues() {
enabled: hasEdit,
});
// Default priority: first active, sorted by sort_order
const defaultPriority = issuePriorities.find(p => p.aktiv)?.schluessel ?? 'mittel';
const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt');
setCreateOpen(false);
setForm({ titel: '', prioritaet: defaultPriority });
},
onError: () => showError('Fehler beim Erstellen'),
});
const handleTabChange = (_: unknown, newValue: number) => {
setSearchParams({ tab: String(newValue) });
};
@@ -950,9 +631,6 @@ export default function Issues() {
const assignedIssues = issues.filter((i: Issue) => i.zugewiesen_an === userId);
const assignedFiltered = showDoneAssigned ? assignedIssues : assignedIssues.filter((i: Issue) => !isDone(i));
// Default typ_id to first active type
const defaultTypId = types.find(t => t.aktiv)?.id;
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
@@ -972,7 +650,7 @@ export default function Issues() {
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
<IssueTable issues={myIssuesFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
@@ -986,7 +664,7 @@ export default function Issues() {
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
<IssueTable issues={assignedFiltered} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
@@ -997,7 +675,7 @@ export default function Issues() {
{isFilteredLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
<IssueTable issues={filteredIssues} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
)}
@@ -1010,69 +688,12 @@ export default function Issues() {
)}
</Box>
{/* Create Issue Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neues Issue erstellen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Titel"
required
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
/>
<TextField
label="Beschreibung"
multiline
rows={4}
fullWidth
value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priorität</InputLabel>
<Select
value={form.prioritaet || defaultPriority}
label="Priorität"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
{issuePriorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate({ ...form, typ_id: form.typ_id ?? defaultTypId })}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
{/* FAB */}
{canCreate && activeTab === 'mine' && (
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Neues Issue"
onClick={() => setCreateOpen(true)}
onClick={() => navigate('/issues/neu')}
>
<AddIcon />
</ChatAwareFab>