From f228dd67ba23960d529bb99418745e7247235d26 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 24 Mar 2026 17:54:36 +0100 Subject: [PATCH] fix permissions --- backend/src/controllers/issue.controller.ts | 73 ++++++++ .../migrations/057_issue_statusmeldungen.sql | 24 +++ backend/src/routes/issue.routes.ts | 36 ++++ backend/src/services/issue.service.ts | 95 ++++++++++ .../dashboard/IssueOverviewWidget.tsx | 86 ++++++++++ frontend/src/components/dashboard/index.ts | 1 + frontend/src/constants/widgets.ts | 1 + frontend/src/pages/Dashboard.tsx | 9 + frontend/src/pages/Issues.tsx | 162 +++++++++++++++++- frontend/src/services/issues.ts | 23 ++- frontend/src/types/issue.types.ts | 18 ++ 11 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 backend/src/database/migrations/057_issue_statusmeldungen.sql create mode 100644 frontend/src/components/dashboard/IssueOverviewWidget.tsx diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index 2f53e67..4a0270c 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -352,6 +352,79 @@ class IssueController { res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' }); } } + + async getWidgetSummary(_req: Request, res: Response): Promise { + try { + const counts = await issueService.getIssueCounts(); + res.status(200).json({ success: true, data: counts }); + } catch (error) { + logger.error('IssueController.getWidgetSummary error', { error }); + res.status(500).json({ success: false, message: 'Issue-Counts konnten nicht geladen werden' }); + } + } + + async getStatusmeldungen(_req: Request, res: Response): Promise { + try { + const items = await issueService.getStatusmeldungen(); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('IssueController.getStatusmeldungen error', { error }); + res.status(500).json({ success: false, message: 'Statusmeldungen konnten nicht geladen werden' }); + } + } + + async createStatusmeldung(req: Request, res: Response): Promise { + const { titel } = req.body; + if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { + res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); + return; + } + try { + const item = await issueService.createStatusmeldung(req.body, req.user!.id); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.createStatusmeldung error', { error }); + res.status(500).json({ success: false, message: 'Statusmeldung konnte nicht erstellt werden' }); + } + } + + async updateStatusmeldung(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.updateStatusmeldung(id, req.body); + if (!item) { + res.status(404).json({ success: false, message: 'Statusmeldung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('IssueController.updateStatusmeldung error', { error }); + res.status(500).json({ success: false, message: 'Statusmeldung konnte nicht aktualisiert werden' }); + } + } + + async deleteStatusmeldung(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 deleted = await issueService.deleteStatusmeldung(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Statusmeldung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Statusmeldung gelöscht' }); + } catch (error) { + logger.error('IssueController.deleteStatusmeldung error', { error }); + res.status(500).json({ success: false, message: 'Statusmeldung konnte nicht gelöscht werden' }); + } + } } export default new IssueController(); diff --git a/backend/src/database/migrations/057_issue_statusmeldungen.sql b/backend/src/database/migrations/057_issue_statusmeldungen.sql new file mode 100644 index 0000000..c66f8df --- /dev/null +++ b/backend/src/database/migrations/057_issue_statusmeldungen.sql @@ -0,0 +1,24 @@ +-- Migration 057: issue_statusmeldungen table + +CREATE TABLE IF NOT EXISTS issue_statusmeldungen ( + id SERIAL PRIMARY KEY, + titel VARCHAR(255) NOT NULL, + inhalt TEXT, + schwere VARCHAR(20) NOT NULL DEFAULT 'info', + aktiv BOOLEAN NOT NULL DEFAULT true, + erstellt_von VARCHAR(255) REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION update_issue_statusmeldungen_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_issue_statusmeldungen_updated_at + BEFORE UPDATE ON issue_statusmeldungen + FOR EACH ROW EXECUTE FUNCTION update_issue_statusmeldungen_updated_at(); diff --git a/backend/src/routes/issue.routes.ts b/backend/src/routes/issue.routes.ts index 68ce20f..0bd40f6 100644 --- a/backend/src/routes/issue.routes.ts +++ b/backend/src/routes/issue.routes.ts @@ -5,6 +5,42 @@ import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); +// --- Statusmeldungen routes (BEFORE /:id) --- +router.get( + '/statusmeldungen', + authenticate, + issueController.getStatusmeldungen.bind(issueController) +); + +router.post( + '/statusmeldungen', + authenticate, + requirePermission('issues:edit_settings'), + issueController.createStatusmeldung.bind(issueController) +); + +router.patch( + '/statusmeldungen/:id', + authenticate, + requirePermission('issues:edit_settings'), + issueController.updateStatusmeldung.bind(issueController) +); + +router.delete( + '/statusmeldungen/:id', + authenticate, + requirePermission('issues:edit_settings'), + issueController.deleteStatusmeldung.bind(issueController) +); + +// --- Widget summary route (BEFORE /:id) --- +router.get( + '/widget-summary', + authenticate, + requirePermission('issues:view_all'), + issueController.getWidgetSummary.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 62d8cea..32f68f7 100644 --- a/backend/src/services/issue.service.ts +++ b/backend/src/services/issue.service.ts @@ -361,6 +361,96 @@ 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; + } catch (error) { + logger.error('IssueService.getIssueCounts failed', { error }); + throw new Error('Issue-Counts konnten nicht geladen werden'); + } +} + +async function getStatusmeldungen() { + try { + const result = await pool.query( + `SELECT * FROM issue_statusmeldungen WHERE aktiv = true ORDER BY created_at DESC` + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getStatusmeldungen failed', { error }); + throw new Error('Statusmeldungen konnten nicht geladen werden'); + } +} + +async function createStatusmeldung( + data: { titel: string; inhalt?: string; schwere?: string }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO issue_statusmeldungen (titel, inhalt, schwere, erstellt_von) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [data.titel, data.inhalt ?? null, data.schwere ?? 'info', userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.createStatusmeldung failed', { error }); + throw new Error('Statusmeldung konnte nicht erstellt werden'); + } +} + +async function updateStatusmeldung( + id: number, + data: { titel?: string; inhalt?: string; schwere?: string; aktiv?: boolean } +) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.titel !== undefined) { setClauses.push(`titel = $${idx}`); values.push(data.titel); idx++; } + if ('inhalt' in data) { setClauses.push(`inhalt = $${idx}`); values.push(data.inhalt ?? null); idx++; } + if (data.schwere !== undefined) { setClauses.push(`schwere = $${idx}`); values.push(data.schwere); 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_statusmeldungen WHERE id = $1`, [id]); + return r.rows[0] || null; + } + + values.push(id); + const result = await pool.query( + `UPDATE issue_statusmeldungen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.updateStatusmeldung failed', { error, id }); + throw new Error('Statusmeldung konnte nicht aktualisiert werden'); + } +} + +async function deleteStatusmeldung(id: number) { + try { + const result = await pool.query( + `DELETE FROM issue_statusmeldungen WHERE id = $1 RETURNING id`, + [id] + ); + return result.rows.length > 0; + } catch (error) { + logger.error('IssueService.deleteStatusmeldung failed', { error, id }); + throw new Error('Statusmeldung konnte nicht gelöscht werden'); + } +} + export default { getIssues, getIssueById, @@ -374,5 +464,10 @@ export default { updateType, deactivateType, getAssignableMembers, + getIssueCounts, + getStatusmeldungen, + createStatusmeldung, + updateStatusmeldung, + deleteStatusmeldung, UNASSIGN, }; diff --git a/frontend/src/components/dashboard/IssueOverviewWidget.tsx b/frontend/src/components/dashboard/IssueOverviewWidget.tsx new file mode 100644 index 0000000..430dcc3 --- /dev/null +++ b/frontend/src/components/dashboard/IssueOverviewWidget.tsx @@ -0,0 +1,86 @@ +import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { BugReport } from '@mui/icons-material'; +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({ + queryKey: ['issues-widget-summary'], + queryFn: issuesApi.getWidgetSummary, + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + if (isLoading) { + return ( + + + Issues + + + + ); + } + + if (isError) { + return ( + + + Issues + + Issues konnten nicht geladen werden. + + + + ); + } + + const visibleChips = STATUS_CHIPS.filter((s) => data && data[s.key] > 0); + + if (visibleChips.length === 0) { + return ( + navigate('/issues')}> + + Issues + + + Keine offenen Issues + + + + ); + } + + return ( + navigate('/issues')}> + + + Issues + + + + {visibleChips.map((s) => ( + + ))} + + + + ); +} + +export default IssueOverviewWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 3ca16c4..b729bba 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -21,3 +21,4 @@ export { default as WidgetGroup } from './WidgetGroup'; export { default as BestellungenWidget } from './BestellungenWidget'; export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget'; export { default as IssueQuickAddWidget } from './IssueQuickAddWidget'; +export { default as IssueOverviewWidget } from './IssueOverviewWidget'; diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index 5dc0e2b..073ca4a 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -16,6 +16,7 @@ export const WIDGETS = [ { key: 'bestellungen', label: 'Bestellungen', defaultVisible: true }, { key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true }, { key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true }, + { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 8497c5e..62d39b8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -31,6 +31,7 @@ import WidgetGroup from '../components/dashboard/WidgetGroup'; import BestellungenWidget from '../components/dashboard/BestellungenWidget'; import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget'; import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget'; +import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget'; import { preferencesApi } from '../services/settings'; import { configApi } from '../services/config'; import { WidgetKey } from '../constants/widgets'; @@ -220,6 +221,14 @@ function Dashboard() { )} + {hasPermission('issues:view_all') && widgetVisible('issueOverview') && ( + + + + + + )} + {hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && ( diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index 87970ab..2a693b7 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -10,7 +10,7 @@ import { Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess, BugReport, FiberNew, HelpOutline, Send as SendIcon, Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon, - DragIndicator, + DragIndicator, Check as CheckIcon, Close as CloseIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'react-router-dom'; @@ -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 } from '../types/issue.types'; +import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung } from '../types/issue.types'; // ── Helpers ── @@ -732,6 +732,156 @@ function IssueTypeAdmin() { ); } +// ── Issue Settings (Statusmeldungen + Kategorien) ── + +function IssueSettings() { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + 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>({}); + + const { data: statusmeldungen = [], isLoading: smLoading } = useQuery({ + queryKey: ['issue-statusmeldungen'], + queryFn: issuesApi.getStatusmeldungen, + }); + + const createSmMut = useMutation({ + mutationFn: (data: { titel: string; inhalt?: string; schwere?: string }) => issuesApi.createStatusmeldung(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issue-statusmeldungen'] }); + showSuccess('Statusmeldung erstellt'); + setCreateOpen(false); + setCreateData({ titel: '', inhalt: '', schwere: 'info' }); + }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateSmMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => issuesApi.updateStatusmeldung(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issue-statusmeldungen'] }); + showSuccess('Statusmeldung aktualisiert'); + setEditId(null); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteSmMut = useMutation({ + mutationFn: (id: number) => issuesApi.deleteStatusmeldung(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issue-statusmeldungen'] }); + showSuccess('Statusmeldung gelöscht'); + }, + onError: () => showError('Fehler beim Löschen'), + }); + + const schwereColors: Record = { info: 'info', warnung: 'warning', fehler: 'error' }; + const schwereLabels: Record = { info: 'Info', warnung: 'Warnung', fehler: 'Fehler' }; + + return ( + + {/* Section 1: Statusmeldungen */} + + + Statusmeldungen + + + {smLoading ? : ( + + + + + Titel + Schwere + Aktiv + Aktionen + + + + {statusmeldungen.length === 0 ? ( + + + Keine Statusmeldungen + + + ) : statusmeldungen.map((sm) => ( + + {editId === sm.id ? ( + <> + + setEditData({ ...editData, titel: e.target.value })} /> + + + + + + setEditData({ ...editData, aktiv: e.target.checked })} size="small" /> + + + updateSmMut.mutate({ id: sm.id, data: editData })}> + setEditId(null)}> + + + ) : ( + <> + {sm.titel} + + + updateSmMut.mutate({ id: sm.id, data: { aktiv: e.target.checked } })} size="small" /> + + + { setEditId(sm.id); setEditData({ titel: sm.titel, schwere: sm.schwere, aktiv: sm.aktiv, inhalt: sm.inhalt ?? '' }); }}> + deleteSmMut.mutate(sm.id)}> + + + )} + + ))} + +
+
+ )} +
+ + {/* Section 2: Kategorien */} + + + + + {/* Create Statusmeldung Dialog */} + setCreateOpen(false)} maxWidth="sm" fullWidth> + Neue Statusmeldung + + setCreateData({ ...createData, titel: e.target.value })} autoFocus /> + setCreateData({ ...createData, inhalt: e.target.value })} /> + + Schwere + + + + + + + + +
+ ); +} + // ── Main Page ── export default function Issues() { @@ -756,7 +906,7 @@ export default function Issues() { { label: 'Zugewiesene Issues', key: 'assigned' }, ]; if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' }); - if (hasEditSettings) t.push({ label: 'Kategorien', key: 'types' }); + if (hasEditSettings) t.push({ label: 'Einstellungen', key: 'settings' }); return t; }, [canViewAll, hasEditSettings]); @@ -868,10 +1018,10 @@ export default function Issues() { )} - {/* Tab 3: Kategorien (conditional) */} + {/* Tab: Einstellungen (conditional) */} {hasEditSettings && ( - t.key === 'types')}> - + t.key === 'settings')}> + )}
diff --git a/frontend/src/services/issues.ts b/frontend/src/services/issues.ts index e914006..8c40581 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 } from '../types/issue.types'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusmeldung, IssueWidgetSummary } from '../types/issue.types'; export const issuesApi = { getIssues: async (filters?: IssueFilters): Promise => { @@ -57,4 +57,25 @@ export const issuesApi = { const r = await api.get('/api/issues/members'); return r.data.data; }, + // Statusmeldungen CRUD + getStatusmeldungen: async (): Promise => { + const r = await api.get('/api/issues/statusmeldungen'); + return r.data.data; + }, + createStatusmeldung: async (data: { titel: string; inhalt?: string; schwere?: string }): Promise => { + const r = await api.post('/api/issues/statusmeldungen', data); + return r.data.data; + }, + updateStatusmeldung: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/issues/statusmeldungen/${id}`, data); + return r.data.data; + }, + deleteStatusmeldung: async (id: number): Promise => { + await api.delete(`/api/issues/statusmeldungen/${id}`); + }, + // Widget summary + getWidgetSummary: async (): Promise => { + const r = await api.get('/api/issues/widget-summary'); + return r.data.data; + }, }; diff --git a/frontend/src/types/issue.types.ts b/frontend/src/types/issue.types.ts index d0c274a..fb1f3e1 100644 --- a/frontend/src/types/issue.types.ts +++ b/frontend/src/types/issue.types.ts @@ -67,3 +67,21 @@ export interface AssignableMember { id: string; name: string; } + +export interface IssueStatusmeldung { + id: number; + titel: string; + inhalt: string | null; + schwere: 'info' | 'warnung' | 'fehler'; + aktiv: boolean; + erstellt_von: string | null; + created_at: string; + updated_at: string; +} + +export interface IssueWidgetSummary { + offen: number; + in_bearbeitung: number; + erledigt: number; + abgelehnt: number; +}