704 lines
40 KiB
TypeScript
704 lines
40 KiB
TypeScript
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, CircularProgress, FormControlLabel, Switch,
|
|
Autocomplete,
|
|
} from '@mui/material';
|
|
import {
|
|
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, 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, IssueTyp, IssueFilters, AssignableMember, IssueStatusDef, IssuePriorityDef } 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 formatIssueId = (issue: Issue) =>
|
|
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error',
|
|
};
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt',
|
|
};
|
|
const PRIO_COLORS: Record<string, string> = {
|
|
hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e',
|
|
};
|
|
const PRIO_LABELS: Record<string, string> = {
|
|
hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig',
|
|
};
|
|
|
|
function getStatusLabel(statuses: IssueStatusDef[], key: string) {
|
|
return statuses.find(s => s.schluessel === key)?.bezeichnung ?? STATUS_LABELS[key] ?? key;
|
|
}
|
|
function getStatusColor(statuses: IssueStatusDef[], key: string): any {
|
|
return statuses.find(s => s.schluessel === key)?.farbe ?? STATUS_COLORS[key] ?? 'default';
|
|
}
|
|
function getPrioColor(priorities: IssuePriorityDef[], key: string) {
|
|
return priorities.find(p => p.schluessel === key)?.farbe ?? PRIO_COLORS[key] ?? '#9e9e9e';
|
|
}
|
|
function getPrioLabel(priorities: IssuePriorityDef[], key: string) {
|
|
return priorities.find(p => p.schluessel === key)?.bezeichnung ?? PRIO_LABELS[key] ?? key;
|
|
}
|
|
|
|
const ICON_MAP: Record<string, JSX.Element> = {
|
|
BugReport: <BugReport fontSize="small" />,
|
|
FiberNew: <FiberNew fontSize="small" />,
|
|
HelpOutline: <HelpOutline fontSize="small" />,
|
|
};
|
|
|
|
function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element {
|
|
const icon = ICON_MAP[iconName || ''] || <HelpOutline fontSize="small" />;
|
|
const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action';
|
|
return <Box component="span" sx={{ display: 'inline-flex', color: `${colorProp}.main` }}>{icon}</Box>;
|
|
}
|
|
|
|
// ── 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>;
|
|
}
|
|
|
|
// ── Issue Table ──
|
|
|
|
function IssueTable({
|
|
issues,
|
|
statuses,
|
|
priorities,
|
|
}: {
|
|
issues: Issue[];
|
|
statuses: IssueStatusDef[];
|
|
priorities: IssuePriorityDef[];
|
|
}) {
|
|
const navigate = useNavigate();
|
|
|
|
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>Zugewiesen an</TableCell>
|
|
<TableCell>Erstellt am</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{issues.map((issue) => (
|
|
<TableRow
|
|
key={issue.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>
|
|
</TableContainer>
|
|
);
|
|
}
|
|
|
|
// ── Filter Bar (for "Alle Issues" tab) ──
|
|
|
|
function FilterBar({
|
|
filters,
|
|
onChange,
|
|
types,
|
|
members,
|
|
statuses,
|
|
priorities,
|
|
}: {
|
|
filters: IssueFilters;
|
|
onChange: (f: IssueFilters) => void;
|
|
types: IssueTyp[];
|
|
members: AssignableMember[];
|
|
statuses: IssueStatusDef[];
|
|
priorities: IssuePriorityDef[];
|
|
}) {
|
|
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={priorities.filter(p => p.aktiv).map(p => p.schluessel)}
|
|
getOptionLabel={(key) => getPrioLabel(priorities, key)}
|
|
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={statuses.filter(s => s.aktiv).map(s => s.schluessel)}
|
|
getOptionLabel={(key) => getStatusLabel(statuses, key)}
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── Shared color picker helpers ──
|
|
|
|
const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'] as const;
|
|
const MUI_THEME_COLORS: Record<string, string> = { default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0', error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02', action: '#757575' };
|
|
const ICON_COLORS = ['action', 'error', 'info', 'success', 'warning', 'primary', 'secondary'] as const;
|
|
|
|
function ColorSwatch({ colors, value, onChange }: { colors: readonly string[]; value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
{colors.map((c) => (
|
|
<Box
|
|
key={c}
|
|
onClick={() => onChange(c)}
|
|
sx={{
|
|
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer',
|
|
bgcolor: MUI_THEME_COLORS[c] ?? c,
|
|
border: value === c ? '2.5px solid' : '2px solid transparent',
|
|
borderColor: value === c ? 'text.primary' : 'transparent',
|
|
'&:hover': { opacity: 0.8 },
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Box
|
|
component="input"
|
|
type="color"
|
|
value={value}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary">{value}</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ── Issue Settings (consolidated: Status + Prioritäten + Kategorien) ──
|
|
|
|
function IssueSettings() {
|
|
const queryClient = useQueryClient();
|
|
const { showSuccess, showError } = useNotification();
|
|
|
|
// ── Status state ──
|
|
const [statusCreateOpen, setStatusCreateOpen] = useState(false);
|
|
const [statusCreateData, setStatusCreateData] = useState<Partial<IssueStatusDef>>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 });
|
|
const [statusEditId, setStatusEditId] = useState<number | null>(null);
|
|
const [statusEditData, setStatusEditData] = useState<Partial<IssueStatusDef>>({});
|
|
|
|
// ── Priority state ──
|
|
const [prioCreateOpen, setPrioCreateOpen] = useState(false);
|
|
const [prioCreateData, setPrioCreateData] = useState<Partial<IssuePriorityDef>>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 });
|
|
const [prioEditId, setPrioEditId] = useState<number | null>(null);
|
|
const [prioEditData, setPrioEditData] = useState<Partial<IssuePriorityDef>>({});
|
|
|
|
// ── Kategorien state ──
|
|
const [typeCreateOpen, setTypeCreateOpen] = useState(false);
|
|
const [typeCreateData, setTypeCreateData] = useState<Partial<IssueTyp>>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
|
|
const [typeEditId, setTypeEditId] = useState<number | null>(null);
|
|
const [typeEditData, setTypeEditData] = useState<Partial<IssueTyp>>({});
|
|
|
|
// ── Queries ──
|
|
const { data: issueStatuses = [], isLoading: statusLoading } = useQuery({ queryKey: ['issue-statuses'], queryFn: issuesApi.getStatuses });
|
|
const { data: issuePriorities = [], isLoading: prioLoading } = useQuery({ queryKey: ['issue-priorities'], queryFn: issuesApi.getPriorities });
|
|
const { data: types = [], isLoading: typesLoading } = useQuery({ queryKey: ['issue-types'], queryFn: issuesApi.getTypes });
|
|
|
|
// ── Status mutations ──
|
|
const createStatusMut = useMutation({
|
|
mutationFn: (data: Partial<IssueStatusDef>) => issuesApi.createStatus(data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status erstellt'); setStatusCreateOpen(false); setStatusCreateData({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); },
|
|
onError: () => showError('Fehler beim Erstellen'),
|
|
});
|
|
const updateStatusMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: Partial<IssueStatusDef> }) => issuesApi.updateStatus(id, data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status aktualisiert'); setStatusEditId(null); },
|
|
onError: () => showError('Fehler beim Aktualisieren'),
|
|
});
|
|
const deleteStatusMut = useMutation({
|
|
mutationFn: (id: number) => issuesApi.deleteStatus(id),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
|
|
onError: () => showError('Fehler beim Deaktivieren'),
|
|
});
|
|
|
|
// ── Priority mutations ──
|
|
const createPrioMut = useMutation({
|
|
mutationFn: (data: Partial<IssuePriorityDef>) => issuesApi.createPriority(data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität erstellt'); setPrioCreateOpen(false); setPrioCreateData({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); },
|
|
onError: () => showError('Fehler beim Erstellen'),
|
|
});
|
|
const updatePrioMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: Partial<IssuePriorityDef> }) => issuesApi.updatePriority(id, data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität aktualisiert'); setPrioEditId(null); },
|
|
onError: () => showError('Fehler beim Aktualisieren'),
|
|
});
|
|
const deletePrioMut = useMutation({
|
|
mutationFn: (id: number) => issuesApi.deletePriority(id),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
|
|
onError: () => showError('Fehler beim Deaktivieren'),
|
|
});
|
|
|
|
// ── Type mutations ──
|
|
const createTypeMut = useMutation({
|
|
mutationFn: (data: Partial<IssueTyp>) => issuesApi.createType(data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie erstellt'); setTypeCreateOpen(false); setTypeCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); },
|
|
onError: () => showError('Fehler beim Erstellen'),
|
|
});
|
|
const updateTypeMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: Partial<IssueTyp> }) => issuesApi.updateType(id, data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie aktualisiert'); setTypeEditId(null); },
|
|
onError: () => showError('Fehler beim Aktualisieren'),
|
|
});
|
|
const deleteTypeMut = useMutation({
|
|
mutationFn: (id: number) => issuesApi.deleteType(id),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); },
|
|
onError: () => showError('Fehler beim Deaktivieren'),
|
|
});
|
|
|
|
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 });
|
|
for (const child of types.filter(t => t.parent_id === root.id)) result.push({ type: child, indent: true });
|
|
}
|
|
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]);
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
|
|
{/* ──── Section 1: Status ──── */}
|
|
<Box>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
<Typography variant="h6">Status</Typography>
|
|
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setStatusCreateOpen(true)}>Neuer Status</Button>
|
|
</Box>
|
|
{statusLoading ? <CircularProgress /> : (
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Bezeichnung</TableCell>
|
|
<TableCell>Schlüssel</TableCell>
|
|
<TableCell>Farbe</TableCell>
|
|
<TableCell>Abschluss</TableCell>
|
|
<TableCell>Initial</TableCell>
|
|
<TableCell>Sort</TableCell>
|
|
<TableCell>Aktiv</TableCell>
|
|
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{issueStatuses.length === 0 ? (
|
|
<TableRow><TableCell colSpan={8} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Status vorhanden</TableCell></TableRow>
|
|
) : issueStatuses.map((s) => (
|
|
<TableRow key={s.id}>
|
|
{statusEditId === s.id ? (<>
|
|
<TableCell><TextField size="small" value={statusEditData.bezeichnung ?? s.bezeichnung} onChange={(e) => setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /></TableCell>
|
|
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
|
|
<TableCell><ColorSwatch colors={MUI_CHIP_COLORS} value={statusEditData.farbe ?? s.farbe} onChange={(v) => setStatusEditData({ ...statusEditData, farbe: v })} /></TableCell>
|
|
<TableCell><Switch checked={statusEditData.ist_abschluss ?? s.ist_abschluss} onChange={(e) => setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /></TableCell>
|
|
<TableCell><Switch checked={statusEditData.ist_initial ?? s.ist_initial} onChange={(e) => setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /></TableCell>
|
|
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={statusEditData.sort_order ?? s.sort_order} onChange={(e) => setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
|
|
<TableCell><Switch checked={statusEditData.aktiv ?? s.aktiv} onChange={(e) => setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /></TableCell>
|
|
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateStatusMut.mutate({ id: s.id, data: statusEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setStatusEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
|
|
</>) : (<>
|
|
<TableCell><Chip label={s.bezeichnung} color={s.farbe as any} size="small" /></TableCell>
|
|
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
|
|
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[s.farbe] ?? s.farbe }} /></TableCell>
|
|
<TableCell>{s.ist_abschluss ? '✓' : '-'}</TableCell>
|
|
<TableCell>{s.ist_initial ? '✓' : '-'}</TableCell>
|
|
<TableCell>{s.sort_order}</TableCell>
|
|
<TableCell><Switch checked={s.aktiv} onChange={(e) => updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
|
|
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setStatusEditId(s.id); setStatusEditData({ bezeichnung: s.bezeichnung, farbe: s.farbe, ist_abschluss: s.ist_abschluss, ist_initial: s.ist_initial, benoetigt_typ_freigabe: s.benoetigt_typ_freigabe, sort_order: s.sort_order, aktiv: s.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteStatusMut.mutate(s.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
|
|
</>)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</Box>
|
|
|
|
{/* ──── Section 2: Prioritäten ──── */}
|
|
<Box>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
<Typography variant="h6">Prioritäten</Typography>
|
|
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setPrioCreateOpen(true)}>Neue Priorität</Button>
|
|
</Box>
|
|
{prioLoading ? <CircularProgress /> : (
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Bezeichnung</TableCell>
|
|
<TableCell>Schlüssel</TableCell>
|
|
<TableCell>Farbe</TableCell>
|
|
<TableCell>Sort</TableCell>
|
|
<TableCell>Aktiv</TableCell>
|
|
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{issuePriorities.length === 0 ? (
|
|
<TableRow><TableCell colSpan={6} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Prioritäten vorhanden</TableCell></TableRow>
|
|
) : issuePriorities.map((p) => (
|
|
<TableRow key={p.id}>
|
|
{prioEditId === p.id ? (<>
|
|
<TableCell><TextField size="small" value={prioEditData.bezeichnung ?? p.bezeichnung} onChange={(e) => setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /></TableCell>
|
|
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
|
|
<TableCell><HexColorInput value={prioEditData.farbe ?? p.farbe} onChange={(v) => setPrioEditData({ ...prioEditData, farbe: v })} /></TableCell>
|
|
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={prioEditData.sort_order ?? p.sort_order} onChange={(e) => setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
|
|
<TableCell><Switch checked={prioEditData.aktiv ?? p.aktiv} onChange={(e) => setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /></TableCell>
|
|
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updatePrioMut.mutate({ id: p.id, data: prioEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setPrioEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
|
|
</>) : (<>
|
|
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><CircleIcon sx={{ fontSize: 12, color: p.farbe }} /><Typography variant="body2">{p.bezeichnung}</Typography></Box></TableCell>
|
|
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
|
|
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: p.farbe, border: '1px solid', borderColor: 'divider' }} /></TableCell>
|
|
<TableCell>{p.sort_order}</TableCell>
|
|
<TableCell><Switch checked={p.aktiv} onChange={(e) => updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
|
|
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deletePrioMut.mutate(p.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
|
|
</>)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</Box>
|
|
|
|
{/* ──── Section 3: Kategorien ──── */}
|
|
<Box>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
<Typography variant="h6">Kategorien</Typography>
|
|
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setTypeCreateOpen(true)}>Neue Kategorie</Button>
|
|
</Box>
|
|
{typesLoading ? <CircularProgress /> : (
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Name</TableCell>
|
|
<TableCell>Icon</TableCell>
|
|
<TableCell>Farbe</TableCell>
|
|
<TableCell>Abgelehnt</TableCell>
|
|
<TableCell>Sort</TableCell>
|
|
<TableCell>Aktiv</TableCell>
|
|
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{flatTypes.length === 0 ? (
|
|
<TableRow><TableCell colSpan={7} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Kategorien vorhanden</TableCell></TableRow>
|
|
) : 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 }} />}{typeEditId === t.id ? <TextField size="small" value={typeEditData.name || ''} onChange={(e) => setTypeEditData({ ...typeEditData, name: e.target.value })} /> : <Typography variant="body2">{t.name}</Typography>}</Box></TableCell>
|
|
<TableCell>{typeEditId === t.id ? (<Select size="small" value={typeEditData.icon || 'HelpOutline'} onChange={(e) => setTypeEditData({ ...typeEditData, 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>{typeEditId === t.id ? <ColorSwatch colors={ICON_COLORS} value={typeEditData.farbe || 'action'} onChange={(v) => setTypeEditData({ ...typeEditData, farbe: v })} /> : <Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[t.farbe || 'action'] ?? '#757575' }} />}</TableCell>
|
|
<TableCell>{typeEditId === t.id ? <Switch checked={typeEditData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '✓' : '-')}</TableCell>
|
|
<TableCell>{typeEditId === t.id ? <TextField size="small" type="number" sx={{ width: 60 }} value={typeEditData.sort_order ?? 0} onChange={(e) => setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order}</TableCell>
|
|
<TableCell><Switch checked={typeEditId === t.id ? (typeEditData.aktiv ?? t.aktiv) : t.aktiv} onChange={(e) => { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /></TableCell>
|
|
<TableCell>{typeEditId === t.id ? (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateTypeMut.mutate({ id: t.id, data: typeEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setTypeEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box>) : (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setTypeEditId(t.id); setTypeEditData({ 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 }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteTypeMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton></Box>)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</Box>
|
|
|
|
{/* ──── Create Status Dialog ──── */}
|
|
<Dialog open={statusCreateOpen} onClose={() => setStatusCreateOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Neuer Status</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
|
<TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus />
|
|
<TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} />
|
|
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={MUI_CHIP_COLORS} value={statusCreateData.farbe || 'default'} onChange={(v) => setStatusCreateData({ ...statusCreateData, farbe: v })} /></Box>
|
|
<TextField label="Sortierung" type="number" value={statusCreateData.sort_order ?? 0} onChange={(e) => setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
|
<FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" />
|
|
<FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" />
|
|
<FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" />
|
|
</DialogContent>
|
|
<DialogActions><Button onClick={() => setStatusCreateOpen(false)}>Abbrechen</Button><Button variant="contained" onClick={() => createStatusMut.mutate(statusCreateData)} disabled={!statusCreateData.schluessel?.trim() || !statusCreateData.bezeichnung?.trim() || createStatusMut.isPending}>Erstellen</Button></DialogActions>
|
|
</Dialog>
|
|
|
|
{/* ──── Create Priority Dialog ──── */}
|
|
<Dialog open={prioCreateOpen} onClose={() => setPrioCreateOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Neue Priorität</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
|
<TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus />
|
|
<TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} />
|
|
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><HexColorInput value={prioCreateData.farbe || '#9e9e9e'} onChange={(v) => setPrioCreateData({ ...prioCreateData, farbe: v })} /></Box>
|
|
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
|
</DialogContent>
|
|
<DialogActions><Button onClick={() => setPrioCreateOpen(false)}>Abbrechen</Button><Button variant="contained" onClick={() => createPrioMut.mutate(prioCreateData)} disabled={!prioCreateData.schluessel?.trim() || !prioCreateData.bezeichnung?.trim() || createPrioMut.isPending}>Erstellen</Button></DialogActions>
|
|
</Dialog>
|
|
|
|
{/* ──── Create Kategorie Dialog ──── */}
|
|
<Dialog open={typeCreateOpen} onClose={() => setTypeCreateOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Neue Kategorie</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
|
<TextField label="Name" required fullWidth value={typeCreateData.name || ''} onChange={(e) => setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus />
|
|
<FormControl fullWidth><InputLabel>Übergeordnete Kategorie</InputLabel><Select value={typeCreateData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setTypeCreateData({ ...typeCreateData, 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={typeCreateData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setTypeCreateData({ ...typeCreateData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select></FormControl>
|
|
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={ICON_COLORS} value={typeCreateData.farbe || 'action'} onChange={(v) => setTypeCreateData({ ...typeCreateData, farbe: v })} /></Box>
|
|
<FormControlLabel control={<Switch checked={typeCreateData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" />
|
|
<TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
|
</DialogContent>
|
|
<DialogActions><Button onClick={() => setTypeCreateOpen(false)}>Abbrechen</Button><Button variant="contained" disabled={!typeCreateData.name?.trim() || createTypeMut.isPending} onClick={() => createTypeMut.mutate(typeCreateData)}>Erstellen</Button></DialogActions>
|
|
</Dialog>
|
|
|
|
</Box>
|
|
);
|
|
}
|
|
// ── Main Page ──
|
|
|
|
export default function Issues() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const { hasPermission } = usePermissionContext();
|
|
const { user } = useAuth();
|
|
|
|
const canViewAll = hasPermission('issues:view_all');
|
|
const hasEdit = hasPermission('issues:edit');
|
|
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: 'Einstellungen', key: 'settings' });
|
|
return t;
|
|
}, [canViewAll, hasEditSettings]);
|
|
|
|
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
|
|
const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam;
|
|
|
|
const [showDoneMine, setShowDoneMine] = useState(false);
|
|
const [showDoneAssigned, setShowDoneAssigned] = useState(false);
|
|
const [filters, setFilters] = useState<IssueFilters>({});
|
|
|
|
// 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: issueStatuses = [] } = useQuery({
|
|
queryKey: ['issue-statuses'],
|
|
queryFn: issuesApi.getStatuses,
|
|
});
|
|
|
|
const { data: issuePriorities = [] } = useQuery({
|
|
queryKey: ['issue-priorities'],
|
|
queryFn: issuesApi.getPriorities,
|
|
});
|
|
|
|
const { data: members = [] } = useQuery({
|
|
queryKey: ['issue-members'],
|
|
queryFn: issuesApi.getMembers,
|
|
enabled: hasEdit,
|
|
});
|
|
|
|
const handleTabChange = (_: unknown, newValue: number) => {
|
|
setSearchParams({ tab: String(newValue) });
|
|
};
|
|
|
|
// Filter logic for client-side tabs
|
|
const isDone = (i: Issue) => {
|
|
const def = issueStatuses.find(s => s.schluessel === i.status);
|
|
return def?.ist_abschluss ?? (i.status === 'erledigt' || i.status === 'abgelehnt');
|
|
};
|
|
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
|
|
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));
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Box sx={{ p: 3 }}>
|
|
<Typography variant="h5" gutterBottom>Issues</Typography>
|
|
|
|
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
|
|
{tabs.map((t, i) => <Tab key={i} label={t.label} />)}
|
|
</Tabs>
|
|
|
|
{/* Tab 0: Meine Issues */}
|
|
<TabPanel value={tab} index={0}>
|
|
<FormControlLabel
|
|
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>
|
|
) : (
|
|
<IssueTable issues={myIssuesFiltered} statuses={issueStatuses} priorities={issuePriorities} />
|
|
)}
|
|
</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} statuses={issueStatuses} priorities={issuePriorities} />
|
|
)}
|
|
</TabPanel>
|
|
|
|
{/* Tab 2: Alle Issues (conditional) */}
|
|
{canViewAll && (
|
|
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'all')}>
|
|
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} statuses={issueStatuses} priorities={issuePriorities} />
|
|
{isFilteredLoading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
|
) : (
|
|
<IssueTable issues={filteredIssues} statuses={issueStatuses} priorities={issuePriorities} />
|
|
)}
|
|
</TabPanel>
|
|
)}
|
|
|
|
{/* Tab: Einstellungen (conditional) */}
|
|
{hasEditSettings && (
|
|
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'settings')}>
|
|
<IssueSettings />
|
|
</TabPanel>
|
|
)}
|
|
</Box>
|
|
|
|
{/* FAB */}
|
|
{canCreate && (
|
|
<ChatAwareFab
|
|
color="primary"
|
|
aria-label="Neues Issue"
|
|
onClick={() => navigate('/issues/neu')}
|
|
>
|
|
<AddIcon />
|
|
</ChatAwareFab>
|
|
)}
|
|
</DashboardLayout>
|
|
);
|
|
}
|