fix permissions

This commit is contained in:
Matthias Hochmeister
2026-03-25 07:48:00 +01:00
parent 5a64987236
commit 59140939df
9 changed files with 750 additions and 100 deletions

View File

@@ -20,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, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung } from '../types/issue.types';
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
// ── Helpers ──
@@ -30,31 +30,31 @@ const formatDate = (iso?: string) =>
const formatIssueId = (issue: Issue) =>
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
const STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
offen: 'info',
in_bearbeitung: 'warning',
erledigt: 'success',
abgelehnt: 'error',
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',
};
const STATUS_LABELS: Record<Issue['status'], string> = {
offen: 'Offen',
in_bearbeitung: 'In Bearbeitung',
erledigt: 'Erledigt',
abgelehnt: 'Abgelehnt',
};
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',
};
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" />,
@@ -153,6 +153,8 @@ function IssueRow({
hasChangeStatus,
hasDelete,
members,
statuses,
priorities,
onDelete,
}: {
issue: Issue;
@@ -161,6 +163,8 @@ function IssueRow({
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
onDelete: (id: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
@@ -178,17 +182,14 @@ function IssueRow({
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
const ownerOnlyErledigt = isOwner && !canChangeStatus;
// Build allowed statuses
// Build allowed statuses from dynamic list
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 active = statuses.filter(s => s.aktiv);
if (hasEdit) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (hasChangeStatus || isAssignee) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
if (isOwner) return active.filter(s => s.schluessel === issue.status || s.ist_abschluss);
return active.filter(s => s.schluessel === issue.status);
}, [statuses, hasEdit, hasChangeStatus, isAssignee, isOwner, issue.status, issue.typ_erlaubt_abgelehnt]);
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
@@ -199,8 +200,15 @@ function IssueRow({
onError: () => showError('Fehler beim Aktualisieren'),
});
// Owner on erledigt issue: show reopen button instead of status select
const currentStatusDef = statuses.find(s => s.schluessel === issue.status);
const isTerminal = currentStatusDef?.ist_abschluss ?? (issue.status === 'erledigt');
const showReopenButton = ownerOnlyErledigt && isTerminal;
const initialStatusKey = statuses.find(s => s.ist_initial)?.schluessel ?? 'offen';
const handleReopen = () => {
updateMut.mutate({ status: 'offen', kommentar: reopenComment.trim() }, {
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
onSuccess: () => {
setReopenOpen(false);
setReopenComment('');
@@ -210,9 +218,6 @@ function IssueRow({
});
};
// Owner on erledigt issue: show reopen button instead of status select
const showReopenButton = ownerOnlyErledigt && issue.status === 'erledigt';
return (
<>
<TableRow
@@ -232,15 +237,15 @@ function IssueRow({
</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>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={STATUS_LABELS[issue.status]}
label={getStatusLabel(statuses, issue.status)}
size="small"
color={STATUS_COLORS[issue.status]}
color={getStatusColor(statuses, issue.status)}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
@@ -279,11 +284,11 @@ function IssueRow({
<Select
value={issue.status}
label="Status"
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
onChange={(e) => updateMut.mutate({ status: e.target.value })}
onClick={(e) => e.stopPropagation()}
>
{allowedStatuses.map(s => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
<MenuItem key={s.schluessel} value={s.schluessel}>{s.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
@@ -296,12 +301,12 @@ function IssueRow({
<Select
value={issue.prioritaet}
label="Priorität"
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value as Issue['prioritaet'] })}
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</MenuItem>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
)}
@@ -380,6 +385,8 @@ function IssueTable({
hasChangeStatus,
hasDelete,
members,
statuses,
priorities,
}: {
issues: Issue[];
userId: string;
@@ -387,6 +394,8 @@ function IssueTable({
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
statuses: IssueStatusDef[];
priorities: IssuePriorityDef[];
}) {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
@@ -434,6 +443,8 @@ function IssueTable({
hasChangeStatus={hasChangeStatus}
hasDelete={hasDelete}
members={members}
statuses={statuses}
priorities={priorities}
onDelete={(id) => deleteMut.mutate(id)}
/>
))}
@@ -450,11 +461,15 @@ function FilterBar({
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' }}>
@@ -476,8 +491,8 @@ function FilterBar({
multiple
size="small"
sx={{ minWidth: 180 }}
options={['niedrig', 'mittel', 'hoch']}
getOptionLabel={(p) => PRIO_LABELS[p as Issue['prioritaet']] || p}
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" />}
@@ -488,8 +503,8 @@ function FilterBar({
multiple
size="small"
sx={{ minWidth: 200 }}
options={['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt']}
getOptionLabel={(s) => STATUS_LABELS[s as Issue['status']] || s}
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" />}
@@ -732,21 +747,48 @@ function IssueTypeAdmin() {
);
}
// ── Issue Settings (Statusmeldungen + Kategorien) ──
// ── Issue Settings (Status + Prioritäten + Statusmeldungen + Kategorien) ──
const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'];
function IssueSettings() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
// ── Statusmeldungen state ──
const [createOpen, setCreateOpen] = useState(false);
const [createData, setCreateData] = useState<{ titel: string; inhalt: string; schwere: 'info' | 'warnung' | 'fehler' }>({ titel: '', inhalt: '', schwere: 'info' });
const [editId, setEditId] = useState<number | null>(null);
const [editData, setEditData] = useState<Partial<IssueStatusmeldung>>({});
// ── 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>>({});
const { data: statusmeldungen = [], isLoading: smLoading } = useQuery({
queryKey: ['issue-statusmeldungen'],
queryFn: issuesApi.getStatusmeldungen,
});
const { data: issueStatuses = [], isLoading: statusLoading } = useQuery({
queryKey: ['issue-statuses'],
queryFn: issuesApi.getStatuses,
});
const { data: issuePriorities = [], isLoading: prioLoading } = useQuery({
queryKey: ['issue-priorities'],
queryFn: issuesApi.getPriorities,
});
// Statusmeldungen mutations
const createSmMut = useMutation({
mutationFn: (data: { titel: string; inhalt?: string; schwere?: string }) => issuesApi.createStatusmeldung(data),
onSuccess: () => {
@@ -780,9 +822,173 @@ function IssueSettings() {
const schwereColors: Record<string, 'info' | 'warning' | 'error'> = { info: 'info', warnung: 'warning', fehler: 'error' };
const schwereLabels: Record<string, string> = { info: 'Info', warnung: 'Warnung', fehler: 'Fehler' };
// 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'),
});
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* Section 1: Statusmeldungen */}
{/* 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>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issueStatuses.length === 0 ? (
<TableRow><TableCell colSpan={8} sx={{ textAlign: 'center', color: 'text.secondary' }}>Keine Status</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>
<Select size="small" value={statusEditData.farbe ?? s.farbe} onChange={(e) => setStatusEditData({ ...statusEditData, farbe: e.target.value })}>
{MUI_CHIP_COLORS.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
</Select>
</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>
<IconButton size="small" onClick={() => updateStatusMut.mutate({ id: s.id, data: statusEditData })}><CheckIcon /></IconButton>
<IconButton size="small" onClick={() => setStatusEditId(null)}><CloseIcon /></IconButton>
</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><Typography variant="body2">{s.farbe}</Typography></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>
<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 /></IconButton>
<IconButton size="small" onClick={() => deleteStatusMut.mutate(s.id)}><DeleteIcon /></IconButton>
</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>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issuePriorities.length === 0 ? (
<TableRow><TableCell colSpan={6} sx={{ textAlign: 'center', color: 'text.secondary' }}>Keine Prioritäten</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><TextField size="small" value={prioEditData.farbe ?? p.farbe} onChange={(e) => setPrioEditData({ ...prioEditData, farbe: e.target.value })} placeholder="#hex" sx={{ width: 90 }} /></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>
<IconButton size="small" onClick={() => updatePrioMut.mutate({ id: p.id, data: prioEditData })}><CheckIcon /></IconButton>
<IconButton size="small" onClick={() => setPrioEditId(null)}><CloseIcon /></IconButton>
</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><Typography variant="body2">{p.farbe}</Typography></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>
<IconButton size="small" onClick={() => { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}><EditIcon /></IconButton>
<IconButton size="small" onClick={() => deletePrioMut.mutate(p.id)}><DeleteIcon /></IconButton>
</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* Section 3: Statusmeldungen */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Statusmeldungen</Typography>
@@ -851,11 +1057,49 @@ function IssueSettings() {
)}
</Box>
{/* Section 2: Kategorien */}
{/* Section 4: Kategorien */}
<Box>
<IssueTypeAdmin />
</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 })} />
<FormControl fullWidth size="small">
<InputLabel>Farbe</InputLabel>
<Select label="Farbe" value={statusCreateData.farbe || 'default'} onChange={(e) => setStatusCreateData({ ...statusCreateData, farbe: e.target.value })}>
{MUI_CHIP_COLORS.map(c => <MenuItem key={c} value={c}><Chip label={c} color={c as any} size="small" /></MenuItem>)}
</Select>
</FormControl>
<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 })} />
<TextField label="Farbe (Hex)" fullWidth value={prioCreateData.farbe || '#9e9e9e'} onChange={(e) => setPrioCreateData({ ...prioCreateData, farbe: e.target.value })} placeholder="#d32f2f" />
<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 Statusmeldung Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neue Statusmeldung</DialogTitle>
@@ -938,19 +1182,32 @@ export default function Issues() {
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,
});
// Default priority: first active, sorted by sort_order
const defaultPriority = issuePriorities.find(p => p.aktiv)?.schluessel ?? 'mittel';
const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt');
setCreateOpen(false);
setForm({ titel: '', prioritaet: 'mittel' });
setForm({ titel: '', prioritaet: defaultPriority });
},
onError: () => showError('Fehler beim Erstellen'),
});
@@ -960,7 +1217,10 @@ export default function Issues() {
};
// Filter logic for client-side tabs
const isDone = (i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt';
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);
@@ -988,7 +1248,7 @@ export default function Issues() {
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
@@ -1002,18 +1262,18 @@ export default function Issues() {
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} 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} />
<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} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
)}
</TabPanel>
)}
@@ -1061,13 +1321,13 @@ export default function Issues() {
<FormControl fullWidth>
<InputLabel>Priorität</InputLabel>
<Select
value={form.prioritaet || 'mittel'}
value={form.prioritaet || defaultPriority}
label="Priorität"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value as Issue['prioritaet'] })}
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</MenuItem>
{issuePriorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>