rework issue system

This commit is contained in:
Matthias Hochmeister
2026-03-24 14:21:17 +01:00
parent abb337c683
commit 6c7531438e
9 changed files with 1260 additions and 189 deletions

View File

@@ -1,14 +1,16 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
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,
Autocomplete,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon,
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
DragIndicator,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
@@ -18,7 +20,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember } from '../types/issue.types';
// ── Helpers ──
@@ -39,18 +41,6 @@ const STATUS_LABELS: Record<Issue['status'], string> = {
abgelehnt: 'Abgelehnt',
};
const TYP_ICONS: Record<Issue['typ'], JSX.Element> = {
bug: <BugReport fontSize="small" color="error" />,
feature: <FiberNew fontSize="small" color="info" />,
sonstiges: <HelpOutline fontSize="small" color="action" />,
};
const TYP_LABELS: Record<Issue['typ'], string> = {
bug: 'Bug',
feature: 'Feature',
sonstiges: 'Sonstiges',
};
const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
hoch: '#d32f2f',
mittel: '#ed6c02',
@@ -63,6 +53,18 @@ const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
niedrig: 'Niedrig',
};
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>;
}
// ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
@@ -73,7 +75,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
// ── Comment Section ──
function CommentSection({ issueId }: { issueId: number }) {
function CommentSection({ issueId, canComment }: { issueId: number; canComment: boolean }) {
const queryClient = useQueryClient();
const { showError } = useNotification();
const [text, setText] = useState('');
@@ -109,30 +111,32 @@ function CommentSection({ issueId }: { issueId: number }) {
</Box>
))
)}
<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>
{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>
);
}
@@ -141,19 +145,48 @@ function CommentSection({ issueId }: { issueId: number }) {
function IssueRow({
issue,
canManage,
isOwner,
userId,
hasEdit,
hasChangeStatus,
hasDelete,
members,
onDelete,
}: {
issue: Issue;
canManage: boolean;
isOwner: boolean;
userId: string;
hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
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
const allowedStatuses = useMemo(() => {
if (hasEdit) return ['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt'] as Issue['status'][];
if (hasChangeStatus || isAssignee) {
const statuses: Issue['status'][] = ['offen', 'in_bearbeitung', 'erledigt'];
if (issue.typ_erlaubt_abgelehnt) statuses.push('abgelehnt');
return statuses;
}
if (isOwner) return [issue.status, 'erledigt'] as Issue['status'][];
return [issue.status] as Issue['status'][];
}, [hasEdit, hasChangeStatus, isAssignee, isOwner, issue.status, issue.typ_erlaubt_abgelehnt]);
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
onSuccess: () => {
@@ -163,6 +196,20 @@ function IssueRow({
onError: () => showError('Fehler beim Aktualisieren'),
});
const handleReopen = () => {
updateMut.mutate({ status: 'offen', kommentar: reopenComment.trim() }, {
onSuccess: () => {
setReopenOpen(false);
setReopenComment('');
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue wiedereröffnet');
},
});
};
// Owner on erledigt issue: show reopen button instead of status select
const showReopenButton = ownerOnlyErledigt && issue.status === 'erledigt';
return (
<>
<TableRow
@@ -173,12 +220,12 @@ function IssueRow({
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{TYP_ICONS[issue.typ]}
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" />
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -194,6 +241,7 @@ function IssueRow({
/>
</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); }}>
@@ -202,7 +250,7 @@ function IssueRow({
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={8} sx={{ py: 0 }}>
<TableCell colSpan={9} sx={{ py: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{issue.beschreibung && (
@@ -210,14 +258,19 @@ function IssueRow({
{issue.beschreibung}
</Typography>
)}
{issue.zugewiesen_an_name && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Zugewiesen an: {issue.zugewiesen_an_name}
</Typography>
)}
{(canManage || isOwner) && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<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
@@ -226,31 +279,47 @@ function IssueRow({
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="offen">Offen</MenuItem>
<MenuItem value="in_bearbeitung">In Bearbeitung</MenuItem>
<MenuItem value="erledigt">Erledigt</MenuItem>
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
{allowedStatuses.map(s => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</FormControl>
{canManage && (
<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 as Issue['prioritaet'] })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</MenuItem>
</Select>
</FormControl>
)}
</Box>
)}
) : null}
{(canManage || isOwner) && (
{/* 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 as Issue['prioritaet'] })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</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"
@@ -263,18 +332,59 @@ function IssueRow({
)}
<Divider sx={{ my: 1 }} />
<CommentSection issueId={issue.id} />
<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, mt: 1 }}>
<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, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) {
function IssueTable({
issues,
userId,
hasEdit,
hasChangeStatus,
hasDelete,
members,
}: {
issues: Issue[];
userId: string;
hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
}) {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
@@ -306,6 +416,7 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
<TableCell>Priorität</TableCell>
<TableCell>Status</TableCell>
<TableCell>Erstellt von</TableCell>
<TableCell>Zugewiesen an</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell />
</TableRow>
@@ -315,8 +426,11 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
<IssueRow
key={issue.id}
issue={issue}
canManage={canManage}
isOwner={issue.erstellt_von === userId}
userId={userId}
hasEdit={hasEdit}
hasChangeStatus={hasChangeStatus}
hasDelete={hasDelete}
members={members}
onDelete={(id) => deleteMut.mutate(id)}
/>
))}
@@ -326,6 +440,295 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
);
}
// ── Filter Bar (for "Alle Issues" tab) ──
function FilterBar({
filters,
onChange,
types,
members,
}: {
filters: IssueFilters;
onChange: (f: IssueFilters) => void;
types: IssueTyp[];
members: AssignableMember[];
}) {
return (
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Type filter */}
<Autocomplete
multiple
size="small"
sx={{ minWidth: 200 }}
options={types}
getOptionLabel={(t) => t.name}
value={types.filter(t => filters.typ_id?.includes(t.id))}
onChange={(_e, val) => onChange({ ...filters, typ_id: val.map(v => v.id) })}
renderInput={(params) => <TextField {...params} label="Typ" size="small" />}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
{/* Priority filter */}
<Autocomplete
multiple
size="small"
sx={{ minWidth: 180 }}
options={['niedrig', 'mittel', 'hoch']}
getOptionLabel={(p) => PRIO_LABELS[p as Issue['prioritaet']] || p}
value={filters.prioritaet || []}
onChange={(_e, val) => onChange({ ...filters, prioritaet: val })}
renderInput={(params) => <TextField {...params} label="Priorität" size="small" />}
/>
{/* Status filter */}
<Autocomplete
multiple
size="small"
sx={{ minWidth: 200 }}
options={['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt']}
getOptionLabel={(s) => STATUS_LABELS[s as Issue['status']] || s}
value={filters.status || []}
onChange={(_e, val) => onChange({ ...filters, status: val })}
renderInput={(params) => <TextField {...params} label="Status" size="small" />}
/>
{/* Erstellt von */}
<Autocomplete
size="small"
sx={{ minWidth: 180 }}
options={members}
getOptionLabel={(m) => m.name}
value={members.find(m => m.id === filters.erstellt_von) || null}
onChange={(_e, val) => onChange({ ...filters, erstellt_von: val?.id })}
renderInput={(params) => <TextField {...params} label="Erstellt von" size="small" />}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
{/* Clear */}
{(filters.typ_id?.length || filters.prioritaet?.length || filters.status?.length || filters.erstellt_von) && (
<Button size="small" onClick={() => onChange({})}>Filter zurücksetzen</Button>
)}
</Box>
);
}
// ── Issue Type Admin ──
function IssueTypeAdmin() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const [editId, setEditId] = useState<number | null>(null);
const [editData, setEditData] = useState<Partial<IssueTyp>>({});
const [createOpen, setCreateOpen] = useState(false);
const [createData, setCreateData] = useState<Partial<IssueTyp>>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
const { data: types = [], isLoading } = useQuery({
queryKey: ['issue-types'],
queryFn: issuesApi.getTypes,
});
const createMut = useMutation({
mutationFn: (data: Partial<IssueTyp>) => issuesApi.createType(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issue-types'] });
showSuccess('Kategorie erstellt');
setCreateOpen(false);
setCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
},
onError: () => showError('Fehler beim Erstellen'),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssueTyp> }) => issuesApi.updateType(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issue-types'] });
showSuccess('Kategorie aktualisiert');
setEditId(null);
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteType(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issue-types'] });
showSuccess('Kategorie gelöscht');
},
onError: () => showError('Fehler beim Löschen'),
});
const startEdit = (t: IssueTyp) => {
setEditId(t.id);
setEditData({ name: t.name, icon: t.icon, farbe: t.farbe, erlaubt_abgelehnt: t.erlaubt_abgelehnt, sort_order: t.sort_order, aktiv: t.aktiv, parent_id: t.parent_id });
};
// Flatten types with children indented
const flatTypes = useMemo(() => {
const roots = types.filter(t => !t.parent_id);
const result: { type: IssueTyp; indent: boolean }[] = [];
for (const root of roots) {
result.push({ type: root, indent: false });
const children = types.filter(t => t.parent_id === root.id);
for (const child of children) {
result.push({ type: child, indent: true });
}
}
// Add orphans (parent_id set but parent missing)
const listed = new Set(result.map(r => r.type.id));
for (const t of types) {
if (!listed.has(t.id)) result.push({ type: t, indent: false });
}
return result;
}, [types]);
if (isLoading) return <CircularProgress />;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Issue-Kategorien</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setCreateOpen(true)}>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Icon</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Abgelehnt erlaubt</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{flatTypes.map(({ type: t, indent }) => (
<TableRow key={t.id} sx={indent ? { bgcolor: 'action.hover' } : undefined}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{indent && <DragIndicator fontSize="small" sx={{ opacity: 0.3, ml: 2 }} />}
{editId === t.id ? (
<TextField size="small" value={editData.name || ''} onChange={(e) => setEditData({ ...editData, name: e.target.value })} />
) : (
<Typography variant="body2">{t.name}</Typography>
)}
</Box>
</TableCell>
<TableCell>
{editId === t.id ? (
<Select size="small" value={editData.icon || 'HelpOutline'} onChange={(e) => setEditData({ ...editData, icon: e.target.value })}>
<MenuItem value="BugReport">BugReport</MenuItem>
<MenuItem value="FiberNew">FiberNew</MenuItem>
<MenuItem value="HelpOutline">HelpOutline</MenuItem>
</Select>
) : (
<Box sx={{ display: 'inline-flex' }}>{getTypIcon(t.icon, t.farbe)}</Box>
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Select size="small" value={editData.farbe || 'action'} onChange={(e) => setEditData({ ...editData, farbe: e.target.value })}>
<MenuItem value="error">error</MenuItem>
<MenuItem value="info">info</MenuItem>
<MenuItem value="action">action</MenuItem>
</Select>
) : (
<Chip label={t.farbe || 'action'} size="small" variant="outlined" />
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Switch checked={editData.erlaubt_abgelehnt ?? true} onChange={(e) => setEditData({ ...editData, erlaubt_abgelehnt: e.target.checked })} size="small" />
) : (
<Typography variant="body2">{t.erlaubt_abgelehnt ? 'Ja' : 'Nein'}</Typography>
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<TextField size="small" type="number" sx={{ width: 70 }} value={editData.sort_order ?? 0} onChange={(e) => setEditData({ ...editData, sort_order: parseInt(e.target.value) || 0 })} />
) : (
t.sort_order
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Switch checked={editData.aktiv ?? true} onChange={(e) => setEditData({ ...editData, aktiv: e.target.checked })} size="small" />
) : (
<Chip label={t.aktiv ? 'Aktiv' : 'Inaktiv'} size="small" color={t.aktiv ? 'success' : 'default'} />
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button size="small" variant="contained" onClick={() => updateMut.mutate({ id: t.id, data: editData })} disabled={updateMut.isPending}>
Speichern
</Button>
<Button size="small" onClick={() => setEditId(null)}>Abbrechen</Button>
</Box>
) : (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => startEdit(t)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Create Type Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neue Kategorie erstellen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField label="Name" required fullWidth value={createData.name || ''} onChange={(e) => setCreateData({ ...createData, name: e.target.value })} autoFocus />
<FormControl fullWidth>
<InputLabel>Übergeordnete Kategorie</InputLabel>
<Select value={createData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setCreateData({ ...createData, parent_id: e.target.value ? Number(e.target.value) : null })}>
<MenuItem value="">Keine</MenuItem>
{types.filter(t => !t.parent_id).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Icon</InputLabel>
<Select value={createData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setCreateData({ ...createData, icon: e.target.value })}>
<MenuItem value="BugReport">BugReport</MenuItem>
<MenuItem value="FiberNew">FiberNew</MenuItem>
<MenuItem value="HelpOutline">HelpOutline</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Farbe</InputLabel>
<Select value={createData.farbe || 'action'} label="Farbe" onChange={(e) => setCreateData({ ...createData, farbe: e.target.value })}>
<MenuItem value="error">error</MenuItem>
<MenuItem value="info">info</MenuItem>
<MenuItem value="action">action</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={<Switch checked={createData.erlaubt_abgelehnt ?? true} onChange={(e) => setCreateData({ ...createData, erlaubt_abgelehnt: e.target.checked })} />}
label="Abgelehnt erlaubt"
/>
<TextField label="Sortierung" type="number" value={createData.sort_order ?? 0} onChange={(e) => setCreateData({ ...createData, sort_order: parseInt(e.target.value) || 0 })} />
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
<Button variant="contained" disabled={!createData.name?.trim() || createMut.isPending} onClick={() => createMut.mutate(createData)}>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ── Main Page ──
export default function Issues() {
@@ -336,30 +739,65 @@ export default function Issues() {
const queryClient = useQueryClient();
const canViewAll = hasPermission('issues:view_all');
const canManage = hasPermission('issues:manage');
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 || '';
// Build tab list dynamically
const tabs = useMemo(() => {
const t: { label: string; key: string }[] = [
{ label: 'Meine Issues', key: 'mine' },
{ label: 'Zugewiesene Issues', key: 'assigned' },
];
if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' });
if (hasEditSettings) t.push({ label: 'Kategorien', key: 'types' });
return t;
}, [canViewAll, hasEditSettings]);
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
const maxTab = canManage ? 2 : (canViewAll ? 1 : 0);
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > maxTab ? 0 : tabParam;
const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam;
const [showDone, setShowDone] = useState(false);
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: '', typ: 'bug', prioritaet: 'mittel' });
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: 'mittel' });
// Fetch all issues for mine/assigned tabs
const { data: issues = [], isLoading } = useQuery({
queryKey: ['issues'],
queryFn: () => issuesApi.getIssues(),
});
// Fetch filtered issues for "Alle Issues" tab
const activeTab = tabs[tab]?.key;
const { data: filteredIssues = [], isLoading: isFilteredLoading } = useQuery({
queryKey: ['issues', 'filtered', filters],
queryFn: () => issuesApi.getIssues(filters),
enabled: activeTab === 'all',
});
const { data: types = [] } = useQuery({
queryKey: ['issue-types'],
queryFn: issuesApi.getTypes,
});
const { data: members = [] } = useQuery({
queryKey: ['issue-members'],
queryFn: issuesApi.getMembers,
enabled: hasEdit,
});
const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt');
setCreateOpen(false);
setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' });
setForm({ titel: '', prioritaet: 'mittel' });
},
onError: () => showError('Fehler beim Erstellen'),
});
@@ -368,9 +806,15 @@ export default function Issues() {
setSearchParams({ tab: String(newValue) });
};
// Filter logic for client-side tabs
const isDone = (i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt';
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
const myIssuesFiltered = myIssues.filter((i: Issue) => showDone || (i.status !== 'erledigt' && i.status !== 'abgelehnt'));
const doneIssues = issues.filter((i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt');
const myIssuesFiltered = showDoneMine ? myIssues : myIssues.filter((i: Issue) => !isDone(i));
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>
@@ -378,47 +822,53 @@ export default function Issues() {
<Typography variant="h5" gutterBottom>Issues</Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
<Tab label="Meine Issues" />
{canViewAll && <Tab label="Alle Issues" />}
{canManage && <Tab label="Erledigte Issues" />}
{tabs.map((t, i) => <Tab key={i} label={t.label} />)}
</Tabs>
{/* Tab 0: Meine Issues */}
<TabPanel value={tab} index={0}>
<FormControlLabel
control={<Switch checked={showDone} onChange={(e) => setShowDone(e.target.checked)} size="small" />}
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
sx={{ mb: 1 }}
/>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={myIssuesFiltered} canManage={canManage} userId={userId} />
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)}
</TabPanel>
{/* Tab 1: Zugewiesene Issues */}
<TabPanel value={tab} index={1}>
<FormControlLabel
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
sx={{ mb: 1 }}
/>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)}
</TabPanel>
{/* Tab 2: Alle Issues (conditional) */}
{canViewAll && (
<TabPanel value={tab} index={1}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'all')}>
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} />
{isFilteredLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={issues} canManage={canManage} userId={userId} />
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)}
</TabPanel>
)}
{canManage && (
<TabPanel value={tab} index={canViewAll ? 2 : 1}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<IssueTable issues={doneIssues} canManage={canManage} userId={userId} />
)}
{/* Tab 3: Kategorien (conditional) */}
{hasEditSettings && (
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'types')}>
<IssueTypeAdmin />
</TabPanel>
)}
</Box>
@@ -446,13 +896,13 @@ export default function Issues() {
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ || 'bug'}
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ: e.target.value as Issue['typ'] })}
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
<MenuItem value="bug">Bug</MenuItem>
<MenuItem value="feature">Feature</MenuItem>
<MenuItem value="sonstiges">Sonstiges</MenuItem>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
@@ -473,7 +923,7 @@ export default function Issues() {
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)}
onClick={() => createMut.mutate({ ...form, typ_id: form.typ_id ?? defaultTypId })}
>
Erstellen
</Button>