rework issue system
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user