new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:09:42 +01:00
parent e9a9478aac
commit 8c66492b27
40 changed files with 2016 additions and 117 deletions

View File

@@ -0,0 +1,471 @@
import { useState } 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,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } 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, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
// ── Helpers ──
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 STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
offen: 'info',
in_bearbeitung: 'warning',
erledigt: 'success',
abgelehnt: 'error',
};
const STATUS_LABELS: Record<Issue['status'], string> = {
offen: 'Offen',
in_bearbeitung: 'In Bearbeitung',
erledigt: 'Erledigt',
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',
niedrig: '#9e9e9e',
};
const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
hoch: 'Hoch',
mittel: 'Mittel',
niedrig: 'Niedrig',
};
// ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
function TabPanel({ children, value, index }: TabPanelProps) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ── Comment Section ──
function CommentSection({ issueId }: { issueId: number }) {
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>
))
)}
<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,
canManage,
isOwner,
onDelete,
}: {
issue: Issue;
canManage: boolean;
isOwner: boolean;
onDelete: (id: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
return (
<>
<TableRow
hover
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
onClick={() => setExpanded(!expanded)}
>
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{TYP_ICONS[issue.typ]}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: PRIO_COLORS[issue.prioritaet] }} />
<Typography variant="body2">{PRIO_LABELS[issue.prioritaet]}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={STATUS_LABELS[issue.status]}
size="small"
color={STATUS_COLORS[issue.status]}
/>
</TableCell>
<TableCell>{issue.erstellt_von_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={8} 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>
)}
{issue.zugewiesen_an_name && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Zugewiesen an: {issue.zugewiesen_an_name}
</Typography>
)}
{canManage && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={issue.status}
label="Status"
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>
</Select>
</FormControl>
<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>
)}
{(canManage || isOwner) && (
<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} />
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
}
// ── Issue Table ──
function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) {
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'),
});
if (issues.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Keine Issues vorhanden
</Typography>
);
}
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Titel</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Priorität</TableCell>
<TableCell>Status</TableCell>
<TableCell>Erstellt von</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{issues.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
canManage={canManage}
isOwner={issue.erstellt_von === userId}
onDelete={(id) => deleteMut.mutate(id)}
/>
))}
</TableBody>
</Table>
</TableContainer>
);
}
// ── Main Page ──
export default function Issues() {
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > 1 ? 0 : tabParam;
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const canViewAll = hasPermission('issues:view_all');
const canManage = hasPermission('issues:manage');
const canCreate = hasPermission('issues:create');
const userId = user?.id || '';
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
const { data: issues = [], isLoading } = useQuery({
queryKey: ['issues'],
queryFn: () => issuesApi.getIssues(),
});
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' });
},
onError: () => showError('Fehler beim Erstellen'),
});
const handleTabChange = (_: unknown, newValue: number) => {
setSearchParams({ tab: String(newValue) });
};
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>Issues</Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
<Tab label="Meine Issues" />
{canViewAll && <Tab label="Alle Issues" />}
</Tabs>
<TabPanel value={tab} index={0}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<IssueTable issues={myIssues} canManage={canManage} userId={userId} />
)}
</TabPanel>
{canViewAll && (
<TabPanel value={tab} index={1}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<IssueTable issues={issues} canManage={canManage} userId={userId} />
)}
</TabPanel>
)}
</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, mt: 1 }}>
<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 || 'bug'}
label="Typ"
onChange={(e) => setForm({ ...form, typ: e.target.value as Issue['typ'] })}
>
<MenuItem value="bug">Bug</MenuItem>
<MenuItem value="feature">Feature</MenuItem>
<MenuItem value="sonstiges">Sonstiges</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priorität</InputLabel>
<Select
value={form.prioritaet || 'mittel'}
label="Priorität"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value as Issue['prioritaet'] })}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
{/* FAB */}
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Neues Issue"
onClick={() => setCreateOpen(true)}
>
<AddIcon />
</ChatAwareFab>
)}
</DashboardLayout>
);
}