From 59140939df31ab67787e664203da8f50f85389c4 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Wed, 25 Mar 2026 07:48:00 +0100 Subject: [PATCH] fix permissions --- backend/src/controllers/issue.controller.ts | 102 +++++ .../058_issue_statuses_and_priorities.sql | 37 ++ backend/src/routes/issue.routes.ts | 12 + backend/src/services/issue.service.ts | 206 ++++++++- .../dashboard/IssueOverviewWidget.tsx | 21 +- frontend/src/components/shared/Sidebar.tsx | 2 +- frontend/src/pages/Issues.tsx | 392 +++++++++++++++--- frontend/src/services/issues.ts | 34 +- frontend/src/types/issue.types.ts | 44 +- 9 files changed, 750 insertions(+), 100 deletions(-) create mode 100644 backend/src/database/migrations/058_issue_statuses_and_priorities.sql diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index 4a0270c..82d97f5 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -363,6 +363,108 @@ class IssueController { } } + async getIssueStatuses(_req: Request, res: Response): Promise { + try { + const items = await issueService.getIssueStatuses(); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('IssueController.getIssueStatuses error', { error }); + res.status(500).json({ success: false, message: 'Issue-Status konnten nicht geladen werden' }); + } + } + + async createIssueStatus(req: Request, res: Response): Promise { + const { schluessel, bezeichnung } = req.body; + if (!schluessel?.trim() || !bezeichnung?.trim()) { + res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' }); + return; + } + try { + const item = await issueService.createIssueStatus(req.body); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.createIssueStatus error', { error }); + res.status(500).json({ success: false, message: 'Issue-Status konnte nicht erstellt werden' }); + } + } + + async updateIssueStatus(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const item = await issueService.updateIssueStatus(id, req.body); + if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.updateIssueStatus error', { error }); + res.status(500).json({ success: false, message: 'Issue-Status konnte nicht aktualisiert werden' }); + } + } + + async deleteIssueStatus(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const item = await issueService.deleteIssueStatus(id); + if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.deleteIssueStatus error', { error }); + res.status(500).json({ success: false, message: 'Issue-Status konnte nicht deaktiviert werden' }); + } + } + + async getIssuePriorities(_req: Request, res: Response): Promise { + try { + const items = await issueService.getIssuePriorities(); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('IssueController.getIssuePriorities error', { error }); + res.status(500).json({ success: false, message: 'Prioritäten konnten nicht geladen werden' }); + } + } + + async createIssuePriority(req: Request, res: Response): Promise { + const { schluessel, bezeichnung } = req.body; + if (!schluessel?.trim() || !bezeichnung?.trim()) { + res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' }); + return; + } + try { + const item = await issueService.createIssuePriority(req.body); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.createIssuePriority error', { error }); + res.status(500).json({ success: false, message: 'Priorität konnte nicht erstellt werden' }); + } + } + + async updateIssuePriority(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const item = await issueService.updateIssuePriority(id, req.body); + if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.updateIssuePriority error', { error }); + res.status(500).json({ success: false, message: 'Priorität konnte nicht aktualisiert werden' }); + } + } + + async deleteIssuePriority(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const item = await issueService.deleteIssuePriority(id); + if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.deleteIssuePriority error', { error }); + res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' }); + } + } + async getStatusmeldungen(_req: Request, res: Response): Promise { try { const items = await issueService.getStatusmeldungen(); diff --git a/backend/src/database/migrations/058_issue_statuses_and_priorities.sql b/backend/src/database/migrations/058_issue_statuses_and_priorities.sql new file mode 100644 index 0000000..998b20e --- /dev/null +++ b/backend/src/database/migrations/058_issue_statuses_and_priorities.sql @@ -0,0 +1,37 @@ +-- Migration 058: dynamic issue statuses and priorities + +CREATE TABLE IF NOT EXISTS issue_statuses ( + id SERIAL PRIMARY KEY, + schluessel VARCHAR(50) UNIQUE NOT NULL, + bezeichnung VARCHAR(100) NOT NULL, + farbe VARCHAR(50) NOT NULL DEFAULT 'default', + ist_abschluss BOOLEAN NOT NULL DEFAULT false, + ist_initial BOOLEAN NOT NULL DEFAULT false, + benoetigt_typ_freigabe BOOLEAN NOT NULL DEFAULT false, + sort_order INT NOT NULL DEFAULT 0, + aktiv BOOLEAN NOT NULL DEFAULT true +); + +INSERT INTO issue_statuses (schluessel, bezeichnung, farbe, ist_abschluss, ist_initial, benoetigt_typ_freigabe, sort_order) +VALUES + ('offen', 'Offen', 'info', false, true, false, 0), + ('in_bearbeitung', 'In Bearbeitung', 'warning', false, false, false, 1), + ('erledigt', 'Erledigt', 'success', true, false, false, 2), + ('abgelehnt', 'Abgelehnt', 'error', true, false, true, 3) +ON CONFLICT (schluessel) DO NOTHING; + +CREATE TABLE IF NOT EXISTS issue_prioritaeten ( + id SERIAL PRIMARY KEY, + schluessel VARCHAR(50) UNIQUE NOT NULL, + bezeichnung VARCHAR(100) NOT NULL, + farbe VARCHAR(50) NOT NULL DEFAULT '#9e9e9e', + sort_order INT NOT NULL DEFAULT 0, + aktiv BOOLEAN NOT NULL DEFAULT true +); + +INSERT INTO issue_prioritaeten (schluessel, bezeichnung, farbe, sort_order) +VALUES + ('hoch', 'Hoch', '#d32f2f', 0), + ('mittel', 'Mittel', '#ed6c02', 1), + ('niedrig', 'Niedrig', '#9e9e9e', 2) +ON CONFLICT (schluessel) DO NOTHING; diff --git a/backend/src/routes/issue.routes.ts b/backend/src/routes/issue.routes.ts index 0bd40f6..9186635 100644 --- a/backend/src/routes/issue.routes.ts +++ b/backend/src/routes/issue.routes.ts @@ -41,6 +41,18 @@ router.get( issueController.getWidgetSummary.bind(issueController) ); +// --- Statuses CRUD (BEFORE /:id) --- +router.get('/statuses', authenticate, issueController.getIssueStatuses.bind(issueController)); +router.post('/statuses', authenticate, requirePermission('issues:edit_settings'), issueController.createIssueStatus.bind(issueController)); +router.patch('/statuses/:id', authenticate, requirePermission('issues:edit_settings'), issueController.updateIssueStatus.bind(issueController)); +router.delete('/statuses/:id', authenticate, requirePermission('issues:edit_settings'), issueController.deleteIssueStatus.bind(issueController)); + +// --- Priorities CRUD (BEFORE /:id) --- +router.get('/priorities', authenticate, issueController.getIssuePriorities.bind(issueController)); +router.post('/priorities', authenticate, requirePermission('issues:edit_settings'), issueController.createIssuePriority.bind(issueController)); +router.patch('/priorities/:id', authenticate, requirePermission('issues:edit_settings'), issueController.updateIssuePriority.bind(issueController)); +router.delete('/priorities/:id', authenticate, requirePermission('issues:edit_settings'), issueController.deleteIssuePriority.bind(issueController)); + // --- Type management routes (BEFORE /:id to avoid conflict) --- router.get( '/typen', diff --git a/backend/src/services/issue.service.ts b/backend/src/services/issue.service.ts index 32f68f7..ef75008 100644 --- a/backend/src/services/issue.service.ts +++ b/backend/src/services/issue.service.ts @@ -363,20 +363,202 @@ async function getAssignableMembers() { async function getIssueCounts() { try { - const result = await pool.query( - `SELECT status, COUNT(*)::int AS count FROM issues GROUP BY status` - ); - const counts: Record = { offen: 0, in_bearbeitung: 0, erledigt: 0, abgelehnt: 0 }; - for (const row of result.rows) { - counts[row.status] = row.count; - } - return counts; + const result = await pool.query(` + SELECT + COALESCE(s.schluessel, i.status) AS schluessel, + COALESCE(s.bezeichnung, i.status) AS bezeichnung, + COALESCE(s.farbe, 'default') AS farbe, + COALESCE(s.ist_abschluss, false) AS ist_abschluss, + COALESCE(s.sort_order, 99) AS sort_order, + COUNT(*)::int AS count + FROM issues i + LEFT JOIN issue_statuses s ON s.schluessel = i.status + GROUP BY + COALESCE(s.schluessel, i.status), + COALESCE(s.bezeichnung, i.status), + COALESCE(s.farbe, 'default'), + COALESCE(s.ist_abschluss, false), + COALESCE(s.sort_order, 99) + ORDER BY COALESCE(s.sort_order, 99) + `); + return result.rows; } catch (error) { logger.error('IssueService.getIssueCounts failed', { error }); throw new Error('Issue-Counts konnten nicht geladen werden'); } } +async function getIssueStatuses() { + try { + const result = await pool.query( + `SELECT * FROM issue_statuses ORDER BY sort_order ASC, id ASC` + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getIssueStatuses failed', { error }); + throw new Error('Issue-Status konnten nicht geladen werden'); + } +} + +async function createIssueStatus(data: { + schluessel: string; + bezeichnung: string; + farbe?: string; + ist_abschluss?: boolean; + ist_initial?: boolean; + benoetigt_typ_freigabe?: boolean; + sort_order?: number; +}) { + try { + const result = await pool.query( + `INSERT INTO issue_statuses (schluessel, bezeichnung, farbe, ist_abschluss, ist_initial, benoetigt_typ_freigabe, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + data.schluessel, + data.bezeichnung, + data.farbe ?? 'default', + data.ist_abschluss ?? false, + data.ist_initial ?? false, + data.benoetigt_typ_freigabe ?? false, + data.sort_order ?? 0, + ] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.createIssueStatus failed', { error }); + throw new Error('Issue-Status konnte nicht erstellt werden'); + } +} + +async function updateIssueStatus(id: number, data: { + bezeichnung?: string; + farbe?: string; + ist_abschluss?: boolean; + ist_initial?: boolean; + benoetigt_typ_freigabe?: boolean; + sort_order?: number; + aktiv?: boolean; +}) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; } + if (data.farbe !== undefined) { setClauses.push(`farbe = $${idx}`); values.push(data.farbe); idx++; } + if (data.ist_abschluss !== undefined) { setClauses.push(`ist_abschluss = $${idx}`); values.push(data.ist_abschluss); idx++; } + if (data.ist_initial !== undefined) { setClauses.push(`ist_initial = $${idx}`); values.push(data.ist_initial); idx++; } + if (data.benoetigt_typ_freigabe !== undefined) { setClauses.push(`benoetigt_typ_freigabe = $${idx}`); values.push(data.benoetigt_typ_freigabe); idx++; } + if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; } + if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; } + + if (setClauses.length === 0) { + const r = await pool.query(`SELECT * FROM issue_statuses WHERE id = $1`, [id]); + return r.rows[0] || null; + } + values.push(id); + const result = await pool.query( + `UPDATE issue_statuses SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.updateIssueStatus failed', { error, id }); + throw new Error('Issue-Status konnte nicht aktualisiert werden'); + } +} + +async function deleteIssueStatus(id: number) { + try { + const result = await pool.query( + `UPDATE issue_statuses SET aktiv = false WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.deleteIssueStatus failed', { error, id }); + throw new Error('Issue-Status konnte nicht deaktiviert werden'); + } +} + +async function getIssuePriorities() { + try { + const result = await pool.query( + `SELECT * FROM issue_prioritaeten ORDER BY sort_order ASC, id ASC` + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getIssuePriorities failed', { error }); + throw new Error('Issue-Prioritäten konnten nicht geladen werden'); + } +} + +async function createIssuePriority(data: { + schluessel: string; + bezeichnung: string; + farbe?: string; + sort_order?: number; +}) { + try { + const result = await pool.query( + `INSERT INTO issue_prioritaeten (schluessel, bezeichnung, farbe, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [data.schluessel, data.bezeichnung, data.farbe ?? '#9e9e9e', data.sort_order ?? 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.createIssuePriority failed', { error }); + throw new Error('Priorität konnte nicht erstellt werden'); + } +} + +async function updateIssuePriority(id: number, data: { + bezeichnung?: string; + farbe?: string; + sort_order?: number; + aktiv?: boolean; +}) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; } + if (data.farbe !== undefined) { setClauses.push(`farbe = $${idx}`); values.push(data.farbe); idx++; } + if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; } + if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; } + + if (setClauses.length === 0) { + const r = await pool.query(`SELECT * FROM issue_prioritaeten WHERE id = $1`, [id]); + return r.rows[0] || null; + } + values.push(id); + const result = await pool.query( + `UPDATE issue_prioritaeten SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.updateIssuePriority failed', { error, id }); + throw new Error('Priorität konnte nicht aktualisiert werden'); + } +} + +async function deleteIssuePriority(id: number) { + try { + const result = await pool.query( + `UPDATE issue_prioritaeten SET aktiv = false WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.deleteIssuePriority failed', { error, id }); + throw new Error('Priorität konnte nicht deaktiviert werden'); + } +} + async function getStatusmeldungen() { try { const result = await pool.query( @@ -465,6 +647,14 @@ export default { deactivateType, getAssignableMembers, getIssueCounts, + getIssueStatuses, + createIssueStatus, + updateIssueStatus, + deleteIssueStatus, + getIssuePriorities, + createIssuePriority, + updateIssuePriority, + deleteIssuePriority, getStatusmeldungen, createStatusmeldung, updateStatusmeldung, diff --git a/frontend/src/components/dashboard/IssueOverviewWidget.tsx b/frontend/src/components/dashboard/IssueOverviewWidget.tsx index 430dcc3..8c266da 100644 --- a/frontend/src/components/dashboard/IssueOverviewWidget.tsx +++ b/frontend/src/components/dashboard/IssueOverviewWidget.tsx @@ -4,17 +4,10 @@ import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { issuesApi } from '../../services/issues'; -const STATUS_CHIPS = [ - { key: 'offen' as const, label: 'Offen', color: 'info' as const }, - { key: 'in_bearbeitung' as const, label: 'In Bearbeitung', color: 'warning' as const }, - { key: 'erledigt' as const, label: 'Erledigt', color: 'success' as const }, - { key: 'abgelehnt' as const, label: 'Abgelehnt', color: 'error' as const }, -]; - function IssueOverviewWidget() { const navigate = useNavigate(); - const { data, isLoading, isError } = useQuery({ + const { data = [], isLoading, isError } = useQuery({ queryKey: ['issues-widget-summary'], queryFn: issuesApi.getWidgetSummary, refetchInterval: 5 * 60 * 1000, @@ -45,9 +38,9 @@ function IssueOverviewWidget() { ); } - const visibleChips = STATUS_CHIPS.filter((s) => data && data[s.key] > 0); + const visibleCounts = data.filter((s) => s.count > 0); - if (visibleChips.length === 0) { + if (visibleCounts.length === 0) { return ( navigate('/issues')}> @@ -69,11 +62,11 @@ function IssueOverviewWidget() { - {visibleChips.map((s) => ( + {visibleCounts.map((s) => ( ))} diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index d9b0177..42108a7 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -201,7 +201,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=2' }); } if (hasPermission('issues:edit_settings')) { - issuesSubItems.push({ text: 'Kategorien', path: `/issues?tab=${issuesSubItems.length}` }); + issuesSubItems.push({ text: 'Einstellungen', path: `/issues?tab=${issuesSubItems.length}` }); } const items = baseNavigationItems diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index 2a693b7..5bf11ef 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -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 = { - offen: 'info', - in_bearbeitung: 'warning', - erledigt: 'success', - abgelehnt: 'error', +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', }; -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: , @@ -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 ( <> - - {PRIO_LABELS[issue.prioritaet]} + + {getPrioLabel(priorities, issue.prioritaet)} {issue.erstellt_von_name || '-'} @@ -279,11 +284,11 @@ function IssueRow({ @@ -296,12 +301,12 @@ function IssueRow({ )} @@ -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 ( @@ -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) => } @@ -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) => } @@ -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(null); const [editData, setEditData] = useState>({}); + // ── 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>({}); + 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 = { info: 'info', warnung: 'warning', fehler: 'error' }; const schwereLabels: Record = { info: 'Info', warnung: 'Warnung', fehler: 'Fehler' }; + // 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'), + }); + return ( - {/* Section 1: Statusmeldungen */} + + {/* Section 1: Status */} + + + Status + + + {statusLoading ? : ( + + + + + Bezeichnung + Schlüssel + Farbe + Abschluss + Initial + Sort + Aktiv + Aktionen + + + + {issueStatuses.length === 0 ? ( + Keine Status + ) : issueStatuses.map((s) => ( + + {statusEditId === s.id ? ( + <> + setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /> + {s.schluessel} + + + + 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.farbe} + {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 + ) : issuePriorities.map((p) => ( + + {prioEditId === p.id ? ( + <> + setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /> + {p.schluessel} + setPrioEditData({ ...prioEditData, farbe: e.target.value })} placeholder="#hex" sx={{ width: 90 }} /> + 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.farbe} + {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: Statusmeldungen */} Statusmeldungen @@ -851,11 +1057,49 @@ function IssueSettings() { )} - {/* Section 2: Kategorien */} + {/* Section 4: Kategorien */} + {/* 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, 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 })} /> + setPrioCreateData({ ...prioCreateData, farbe: e.target.value })} placeholder="#d32f2f" /> + setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} /> + + + + + + + {/* Create Statusmeldung Dialog */} setCreateOpen(false)} maxWidth="sm" fullWidth> Neue Statusmeldung @@ -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 ? ( ) : ( - + )} @@ -1002,18 +1262,18 @@ export default function Issues() { {isLoading ? ( ) : ( - + )} {/* Tab 2: Alle Issues (conditional) */} {canViewAll && ( t.key === 'all')}> - + {isFilteredLoading ? ( ) : ( - + )} )} @@ -1061,13 +1321,13 @@ export default function Issues() { Priorität diff --git a/frontend/src/services/issues.ts b/frontend/src/services/issues.ts index 8c40581..2641763 100644 --- a/frontend/src/services/issues.ts +++ b/frontend/src/services/issues.ts @@ -1,5 +1,5 @@ import { api } from './api'; -import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusmeldung, IssueWidgetSummary } from '../types/issue.types'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusmeldung, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary } from '../types/issue.types'; export const issuesApi = { getIssues: async (filters?: IssueFilters): Promise => { @@ -78,4 +78,36 @@ export const issuesApi = { const r = await api.get('/api/issues/widget-summary'); return r.data.data; }, + // Statuses CRUD + getStatuses: async (): Promise => { + const r = await api.get('/api/issues/statuses'); + return r.data.data; + }, + createStatus: async (data: Partial): Promise => { + const r = await api.post('/api/issues/statuses', data); + return r.data.data; + }, + updateStatus: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/issues/statuses/${id}`, data); + return r.data.data; + }, + deleteStatus: async (id: number): Promise => { + await api.delete(`/api/issues/statuses/${id}`); + }, + // Priorities CRUD + getPriorities: async (): Promise => { + const r = await api.get('/api/issues/priorities'); + return r.data.data; + }, + createPriority: async (data: Partial): Promise => { + const r = await api.post('/api/issues/priorities', data); + return r.data.data; + }, + updatePriority: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/issues/priorities/${id}`, data); + return r.data.data; + }, + deletePriority: async (id: number): Promise => { + await api.delete(`/api/issues/priorities/${id}`); + }, }; diff --git a/frontend/src/types/issue.types.ts b/frontend/src/types/issue.types.ts index fb1f3e1..fb1c466 100644 --- a/frontend/src/types/issue.types.ts +++ b/frontend/src/types/issue.types.ts @@ -19,8 +19,8 @@ export interface Issue { typ_icon: string | null; typ_farbe: string | null; typ_erlaubt_abgelehnt: boolean; - prioritaet: 'niedrig' | 'mittel' | 'hoch'; - status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; + prioritaet: string; + status: string; erstellt_von: string; erstellt_von_name?: string; zugewiesen_an: string | null; @@ -42,15 +42,15 @@ export interface CreateIssuePayload { titel: string; beschreibung?: string; typ_id?: number; - prioritaet?: 'niedrig' | 'mittel' | 'hoch'; + prioritaet?: string; } export interface UpdateIssuePayload { titel?: string; beschreibung?: string; typ_id?: number; - prioritaet?: 'niedrig' | 'mittel' | 'hoch'; - status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; + prioritaet?: string; + status?: string; zugewiesen_an?: string | null; kommentar?: string; } @@ -79,9 +79,33 @@ export interface IssueStatusmeldung { updated_at: string; } -export interface IssueWidgetSummary { - offen: number; - in_bearbeitung: number; - erledigt: number; - abgelehnt: number; +export interface IssueStatusDef { + id: number; + schluessel: string; + bezeichnung: string; + farbe: string; + ist_abschluss: boolean; + ist_initial: boolean; + benoetigt_typ_freigabe: boolean; + sort_order: number; + aktiv: boolean; } + +export interface IssuePriorityDef { + id: number; + schluessel: string; + bezeichnung: string; + farbe: string; + sort_order: number; + aktiv: boolean; +} + +export interface IssueStatusCount { + schluessel: string; + bezeichnung: string; + farbe: string; + ist_abschluss: boolean; + count: number; +} + +export type IssueWidgetSummary = IssueStatusCount[];