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 = { offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error', }; const STATUS_LABELS: Record = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt', }; const PRIO_COLORS: Record = { hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e', }; const PRIO_LABELS: Record = { 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 = { BugReport: , FiberNew: , HelpOutline: , }; function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element { const icon = ICON_MAP[iconName || ''] || ; const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action'; return {icon}; } // ── Tab Panel ── interface TabPanelProps { children: React.ReactNode; index: number; value: number } function TabPanel({ children, value, index }: TabPanelProps) { if (value !== index) return null; return {children}; } // ── Issue Table ── function IssueTable({ issues, statuses, priorities, }: { issues: Issue[]; statuses: IssueStatusDef[]; priorities: IssuePriorityDef[]; }) { const navigate = useNavigate(); if (issues.length === 0) { return ( Keine Issues vorhanden ); } return ( ID Titel Typ Priorität Status Erstellt von Zugewiesen an Erstellt am {issues.map((issue) => ( navigate(`/issues/${issue.id}`)} > {formatIssueId(issue)} {getTypIcon(issue.typ_icon, issue.typ_farbe)} {issue.titel} {getPrioLabel(priorities, issue.prioritaet)} {issue.erstellt_von_name || '-'} {issue.zugewiesen_an_name || '-'} {formatDate(issue.created_at)} ))}
); } // ── 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 ( {/* Type filter */} 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) => } isOptionEqualToValue={(o, v) => o.id === v.id} /> {/* Priority filter */} p.aktiv).map(p => p.schluessel)} getOptionLabel={(key) => getPrioLabel(priorities, key)} value={filters.prioritaet || []} onChange={(_e, val) => onChange({ ...filters, prioritaet: val })} renderInput={(params) => } /> {/* Status filter */} s.aktiv).map(s => s.schluessel)} getOptionLabel={(key) => getStatusLabel(statuses, key)} value={filters.status || []} onChange={(_e, val) => onChange({ ...filters, status: val })} renderInput={(params) => } /> {/* Erstellt von */} m.name} value={members.find(m => m.id === filters.erstellt_von) || null} onChange={(_e, val) => onChange({ ...filters, erstellt_von: val?.id })} renderInput={(params) => } isOptionEqualToValue={(o, v) => o.id === v.id} /> {/* Clear */} {(filters.typ_id?.length || filters.prioritaet?.length || filters.status?.length || filters.erstellt_von) && ( )} ); } // ── Shared color picker helpers ── const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'] as const; const MUI_THEME_COLORS: Record = { 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 ( {colors.map((c) => ( 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 }, }} /> ))} ); } function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { return ( ) => onChange(e.target.value)} sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }} /> {value} ); } // ── 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>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); const [statusEditId, setStatusEditId] = useState(null); const [statusEditData, setStatusEditData] = useState>({}); // ── Priority state ── const [prioCreateOpen, setPrioCreateOpen] = useState(false); const [prioCreateData, setPrioCreateData] = useState>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); const [prioEditId, setPrioEditId] = useState(null); const [prioEditData, setPrioEditData] = useState>({}); // ── Kategorien state ── const [typeCreateOpen, setTypeCreateOpen] = useState(false); const [typeCreateData, setTypeCreateData] = useState>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); const [typeEditId, setTypeEditId] = useState(null); const [typeEditData, setTypeEditData] = useState>({}); // ── 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) => 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 }) => 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) => 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 }) => 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) => 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 }) => 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 ( {/* ──── Section 1: Status ──── */} Status {statusLoading ? : ( Bezeichnung Schlüssel Farbe Abschluss Initial Sort Aktiv Aktionen {issueStatuses.length === 0 ? ( Keine Status vorhanden ) : issueStatuses.map((s) => ( {statusEditId === s.id ? (<> setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /> {s.schluessel} setStatusEditData({ ...statusEditData, farbe: v })} /> setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /> setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /> setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /> setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /> updateStatusMut.mutate({ id: s.id, data: statusEditData })}> setStatusEditId(null)}> ) : (<> {s.schluessel} {s.ist_abschluss ? '✓' : '-'} {s.ist_initial ? '✓' : '-'} {s.sort_order} updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /> { 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 }); }}> deleteStatusMut.mutate(s.id)}> )} ))}
)}
{/* ──── Section 2: Prioritäten ──── */} Prioritäten {prioLoading ? : ( Bezeichnung Schlüssel Farbe Sort Aktiv Aktionen {issuePriorities.length === 0 ? ( Keine Prioritäten vorhanden ) : issuePriorities.map((p) => ( {prioEditId === p.id ? (<> setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /> {p.schluessel} setPrioEditData({ ...prioEditData, farbe: v })} /> setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /> setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /> updatePrioMut.mutate({ id: p.id, data: prioEditData })}> setPrioEditId(null)}> ) : (<> {p.bezeichnung} {p.schluessel} {p.sort_order} updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /> { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}> deletePrioMut.mutate(p.id)}> )} ))}
)}
{/* ──── Section 3: Kategorien ──── */} Kategorien {typesLoading ? : ( Name Icon Farbe Abgelehnt Sort Aktiv Aktionen {flatTypes.length === 0 ? ( Keine Kategorien vorhanden ) : flatTypes.map(({ type: t, indent }) => ( {indent && }{typeEditId === t.id ? setTypeEditData({ ...typeEditData, name: e.target.value })} /> : {t.name}} {typeEditId === t.id ? () : {getTypIcon(t.icon, t.farbe)}} {typeEditId === t.id ? setTypeEditData({ ...typeEditData, farbe: v })} /> : } {typeEditId === t.id ? setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '✓' : '-')} {typeEditId === t.id ? setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order} { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /> {typeEditId === t.id ? ( updateTypeMut.mutate({ id: t.id, data: typeEditData })}> setTypeEditId(null)}>) : ( { 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 }); }}> deleteTypeMut.mutate(t.id)}>)} ))}
)}
{/* ──── Create Status Dialog ──── */} setStatusCreateOpen(false)} maxWidth="sm" fullWidth> Neuer Status setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus /> setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} /> Farbe setStatusCreateData({ ...statusCreateData, farbe: v })} /> setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} /> setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" /> setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" /> setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" /> {/* ──── Create Priority Dialog ──── */} setPrioCreateOpen(false)} maxWidth="sm" fullWidth> Neue Priorität setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus /> setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} /> Farbe setPrioCreateData({ ...prioCreateData, farbe: v })} /> setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} /> {/* ──── Create Kategorie Dialog ──── */} setTypeCreateOpen(false)} maxWidth="sm" fullWidth> Neue Kategorie setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus /> Übergeordnete Kategorie Icon Farbe setTypeCreateData({ ...typeCreateData, farbe: v })} /> setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" /> setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
); } // ── 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({}); // 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 ( Issues {tabs.map((t, i) => )} {/* Tab 0: Meine Issues */} setShowDoneMine(e.target.checked)} size="small" />} label="Erledigte anzeigen" sx={{ mb: 1 }} /> {isLoading ? ( ) : ( )} {/* Tab 1: Zugewiesene Issues */} setShowDoneAssigned(e.target.checked)} size="small" />} label="Erledigte anzeigen" sx={{ mb: 1 }} /> {isLoading ? ( ) : ( )} {/* Tab 2: Alle Issues (conditional) */} {canViewAll && ( t.key === 'all')}> {isFilteredLoading ? ( ) : ( )} )} {/* Tab: Einstellungen (conditional) */} {hasEditSettings && ( t.key === 'settings')}> )} {/* FAB */} {canCreate && ( navigate('/issues/neu')} > )} ); }