diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index 302267f..2f53e67 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -11,7 +11,34 @@ class IssueController { const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); - const issues = await issueService.getIssues(userId, canViewAll); + + // Parse filter query params + const filters: { + typ_id?: number[]; + prioritaet?: string[]; + status?: string[]; + erstellt_von?: string; + zugewiesen_an?: string; + } = {}; + + if (req.query.typ_id) { + filters.typ_id = String(req.query.typ_id).split(',').map(Number).filter((n) => !isNaN(n)); + } + if (req.query.prioritaet) { + filters.prioritaet = String(req.query.prioritaet).split(','); + } + if (req.query.status) { + filters.status = String(req.query.status).split(','); + } + if (req.query.erstellt_von) { + filters.erstellt_von = req.query.erstellt_von as string; + } + if (req.query.zugewiesen_an) { + filters.zugewiesen_an = + req.query.zugewiesen_an === 'me' ? userId : (req.query.zugewiesen_an as string); + } + + const issues = await issueService.getIssues({ userId, canViewAll, filters }); res.status(200).json({ success: true, data: issues }); } catch (error) { logger.error('IssueController.getIssues error', { error }); @@ -34,7 +61,7 @@ class IssueController { const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); - if (!canViewAll && issue.erstellt_von !== userId) { + if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) { res.status(403).json({ success: false, message: 'Kein Zugriff' }); return; } @@ -69,7 +96,8 @@ class IssueController { try { const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; - const canManage = permissionService.hasPermission(groups, 'issues:manage'); + const canEdit = permissionService.hasPermission(groups, 'issues:edit'); + const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status'); const existing = await issueService.getIssueById(id); if (!existing) { @@ -78,19 +106,80 @@ class IssueController { } const isOwner = existing.erstellt_von === userId; - if (!canManage && !isOwner) { + const isAssignee = existing.zugewiesen_an === userId; + + // Determine what update data is allowed + let updateData: Record; + + if (canEdit) { + // Full edit access + updateData = { ...req.body }; + // Explicit null for unassign is handled by 'zugewiesen_an' in data check in service + } else if (canChangeStatus || isAssignee) { + // Can only change status (+ kommentar is handled separately) + updateData = {}; + if (req.body.status !== undefined) updateData.status = req.body.status; + } else if (isOwner) { + // Owner without change_status: can only close own issue or reopen from erledigt + updateData = {}; + if (req.body.status !== undefined) { + const newStatus = req.body.status; + if (newStatus === 'erledigt') { + updateData.status = 'erledigt'; + } else if (newStatus === 'offen' && existing.status === 'erledigt') { + // Reopen: require kommentar + if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) { + res.status(400).json({ + success: false, + message: 'Beim Wiedereröffnen ist ein Kommentar erforderlich', + }); + return; + } + updateData.status = 'offen'; + } else { + res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' }); + return; + } + } else { + // Owner trying to change non-status fields without edit permission + res.status(403).json({ success: false, message: 'Keine Berechtigung' }); + return; + } + } else { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } - // Owners without manage permission can only change status - const updateData = canManage ? req.body : { status: req.body.status }; + // Validate: if setting status to 'abgelehnt', check if type allows it + if (updateData.status === 'abgelehnt' && existing.typ_id) { + if (!existing.typ_erlaubt_abgelehnt) { + res.status(400).json({ + success: false, + message: 'Dieser Issue-Typ erlaubt den Status "Abgelehnt" nicht', + }); + return; + } + } + const issue = await issueService.updateIssue(id, updateData); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } - res.status(200).json({ success: true, data: issue }); + + // Handle reopen comment (owner reopen flow) + if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) { + await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`); + } + + // If kommentar was provided alongside a status change (not the reopen flow above) + if (req.body.kommentar && updateData.status && !(isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt')) { + await issueService.addComment(id, userId, req.body.kommentar.trim()); + } + + // Re-fetch to include any new comment info + const updated = await issueService.getIssueById(id); + res.status(200).json({ success: true, data: updated }); } catch (error) { logger.error('IssueController.updateIssue error', { error }); res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' }); @@ -111,8 +200,8 @@ class IssueController { } const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; - const canManage = permissionService.hasPermission(groups, 'issues:manage'); - if (!canManage && issue.erstellt_von !== userId) { + const canDelete = permissionService.hasPermission(groups, 'issues:delete'); + if (!canDelete && issue.erstellt_von !== userId) { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } @@ -139,7 +228,7 @@ class IssueController { const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); - if (!canViewAll && issue.erstellt_von !== userId) { + if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) { res.status(403).json({ success: false, message: 'Kein Zugriff' }); return; } @@ -170,12 +259,17 @@ class IssueController { } const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; - const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); - const canManage = permissionService.hasPermission(groups, 'issues:manage'); - if (!canViewAll && !canManage && issue.erstellt_von !== userId) { - res.status(403).json({ success: false, message: 'Kein Zugriff' }); + const isOwner = issue.erstellt_von === userId; + const isAssignee = issue.zugewiesen_an === userId; + const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status'); + const canEdit = permissionService.hasPermission(groups, 'issues:edit'); + + // Authorization: owner, assignee, change_status, or edit can comment + if (!isOwner && !isAssignee && !canChangeStatus && !canEdit) { + res.status(403).json({ success: false, message: 'Keine Berechtigung zum Kommentieren' }); return; } + const comment = await issueService.addComment(issueId, userId, inhalt.trim()); res.status(201).json({ success: true, data: comment }); } catch (error) { @@ -183,6 +277,81 @@ class IssueController { res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' }); } } + + // --- Type management --- + + async getTypes(_req: Request, res: Response): Promise { + try { + const types = await issueService.getTypes(); + res.status(200).json({ success: true, data: types }); + } catch (error) { + logger.error('IssueController.getTypes error', { error }); + res.status(500).json({ success: false, message: 'Issue-Typen konnten nicht geladen werden' }); + } + } + + async createType(req: Request, res: Response): Promise { + const { name } = req.body; + if (!name || typeof name !== 'string' || name.trim().length === 0) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + try { + const type = await issueService.createType(req.body); + res.status(201).json({ success: true, data: type }); + } catch (error) { + logger.error('IssueController.createType error', { error }); + res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht erstellt werden' }); + } + } + + async updateType(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 type = await issueService.updateType(id, req.body); + if (!type) { + res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: type }); + } catch (error) { + logger.error('IssueController.updateType error', { error }); + res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht aktualisiert werden' }); + } + } + + async deleteType(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 type = await issueService.deactivateType(id); + if (!type) { + res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: type }); + } catch (error) { + logger.error('IssueController.deleteType error', { error }); + res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht deaktiviert werden' }); + } + } + + async getMembers(_req: Request, res: Response): Promise { + try { + const members = await issueService.getAssignableMembers(); + res.status(200).json({ success: true, data: members }); + } catch (error) { + logger.error('IssueController.getMembers error', { error }); + res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' }); + } + } } export default new IssueController(); diff --git a/backend/src/database/migrations/053_issues_rework.sql b/backend/src/database/migrations/053_issues_rework.sql new file mode 100644 index 0000000..8560dbb --- /dev/null +++ b/backend/src/database/migrations/053_issues_rework.sql @@ -0,0 +1,128 @@ +-- Migration 053: Issues rework +-- 1. Fix update trigger bug (uses wrong column name) +-- 2. Dynamic issue types table (issue_typen) +-- 3. Migrate issues.typ → issues.typ_id +-- 4. Permission rework: replace issues:manage with granular permissions + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Fix update trigger +-- The old trigger calls update_aktualisiert_am() which sets NEW.aktualisiert_am, +-- but issues table uses updated_at → crashes every UPDATE. +-- ═══════════════════════════════════════════════════════════════════════════ + +DROP TRIGGER IF EXISTS trg_issues_updated ON issues; + +CREATE OR REPLACE FUNCTION update_issues_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues + FOR EACH ROW EXECUTE FUNCTION update_issues_updated_at(); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Dynamic types table +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS issue_typen ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + parent_id INT REFERENCES issue_typen(id) ON DELETE CASCADE, + icon VARCHAR(50) DEFAULT NULL, + farbe VARCHAR(20) DEFAULT NULL, + erlaubt_abgelehnt BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + aktiv BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issue_typen_parent ON issue_typen(parent_id); + +-- Seed default types +INSERT INTO issue_typen (id, name, parent_id, icon, farbe, erlaubt_abgelehnt, sort_order) VALUES + (1, 'Bug', NULL, 'BugReport', 'error', false, 1), + (2, 'Funktionsanfrage', NULL, 'FiberNew', 'info', true, 2), + (3, 'Sonstiges', NULL, 'HelpOutline', 'action', true, 3) +ON CONFLICT (id) DO NOTHING; + +-- Ensure sequence is past seeded IDs +SELECT setval('issue_typen_id_seq', GREATEST((SELECT MAX(id) FROM issue_typen), 3)); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Migrate issues.typ column → typ_id FK +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE issues ADD COLUMN IF NOT EXISTS typ_id INT REFERENCES issue_typen(id) ON DELETE SET NULL; + +-- Migrate existing data +UPDATE issues SET typ_id = 1 WHERE typ = 'bug' AND typ_id IS NULL; +UPDATE issues SET typ_id = 2 WHERE typ = 'feature' AND typ_id IS NULL; +UPDATE issues SET typ_id = 3 WHERE typ = 'sonstiges' AND typ_id IS NULL; +-- Fallback: anything unmapped → Sonstiges +UPDATE issues SET typ_id = 3 WHERE typ_id IS NULL; + +-- Drop old constraint and column +ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_typ_check; +ALTER TABLE issues DROP COLUMN IF EXISTS typ; + +CREATE INDEX IF NOT EXISTS idx_issues_typ_id ON issues(typ_id); +CREATE INDEX IF NOT EXISTS idx_issues_zugewiesen_an ON issues(zugewiesen_an); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Permission rework +-- Replace issues:manage with granular permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +-- 4a. Find all groups that had issues:manage and give them the new permissions +-- We use a temp table to store the groups that had manage +CREATE TEMP TABLE IF NOT EXISTS _issues_manage_groups AS + SELECT authentik_group FROM group_permissions WHERE permission_id = 'issues:manage'; + +-- 4b. Insert new permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('issues:change_status', 'issues', 'Status ändern', 'Status ändern und kommentieren', 4), + ('issues:edit', 'issues', 'Bearbeiten', 'Issues bearbeiten (Titel, Beschreibung, Typ, Priorität, Zuweisung)', 5), + ('issues:edit_settings', 'issues', 'Einstellungen', 'Issue-Kategorien verwalten', 6), + ('issues:delete', 'issues', 'Löschen', 'Issues löschen', 7) +ON CONFLICT (id) DO NOTHING; + +-- 4c. Grant all new permissions to groups that had manage +INSERT INTO group_permissions (authentik_group, permission_id) + SELECT g.authentik_group, p.id + FROM _issues_manage_groups g + CROSS JOIN (VALUES + ('issues:view_all'), + ('issues:change_status'), + ('issues:edit'), + ('issues:edit_settings'), + ('issues:delete') + ) AS p(id) +ON CONFLICT DO NOTHING; + +-- 4d. Remove old manage permission +DELETE FROM group_permissions WHERE permission_id = 'issues:manage'; +DELETE FROM permissions WHERE id = 'issues:manage'; + +DROP TABLE IF EXISTS _issues_manage_groups; + +-- 4e. Update permission_deps in app_settings JSON +-- Remove old issues entries and add new ones +UPDATE app_settings +SET value = ( + SELECT jsonb_strip_nulls( + (value::jsonb - 'issues:view_own' - 'issues:view_all' - 'issues:manage') + || '{ + "issues:create": ["issues:view_own"], + "issues:view_all": ["issues:view_own"], + "issues:change_status": ["issues:view_all"], + "issues:edit": ["issues:view_all"], + "issues:delete": ["issues:view_all"], + "issues:edit_settings": ["issues:view_all"] + }'::jsonb + )::text + FROM app_settings WHERE key = 'permission_deps' +) +WHERE key = 'permission_deps'; diff --git a/backend/src/routes/issue.routes.ts b/backend/src/routes/issue.routes.ts index 1d142f3..68ce20f 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(); +// --- Type management routes (BEFORE /:id to avoid conflict) --- +router.get( + '/typen', + authenticate, + issueController.getTypes.bind(issueController) +); + +router.post( + '/typen', + authenticate, + requirePermission('issues:edit_settings'), + issueController.createType.bind(issueController) +); + +router.patch( + '/typen/:id', + authenticate, + requirePermission('issues:edit_settings'), + issueController.updateType.bind(issueController) +); + +router.delete( + '/typen/:id', + authenticate, + requirePermission('issues:edit_settings'), + issueController.deleteType.bind(issueController) +); + +// --- Members route --- +router.get( + '/members', + authenticate, + issueController.getMembers.bind(issueController) +); + +// --- Issue CRUD --- router.get( '/', authenticate, @@ -30,6 +66,12 @@ router.post( issueController.addComment.bind(issueController) ); +router.get( + '/:id', + authenticate, + issueController.getIssue.bind(issueController) +); + router.patch( '/:id', authenticate, diff --git a/backend/src/services/issue.service.ts b/backend/src/services/issue.service.ts index e830c87..62d8cea 100644 --- a/backend/src/services/issue.service.ts +++ b/backend/src/services/issue.service.ts @@ -1,21 +1,76 @@ import pool from '../config/database'; import logger from '../utils/logger'; -async function getIssues(userId: string, canViewAll: boolean) { +interface IssueFilters { + typ_id?: number[]; + prioritaet?: string[]; + status?: string[]; + erstellt_von?: string; + zugewiesen_an?: string; +} + +interface GetIssuesParams { + userId: string; + canViewAll: boolean; + filters?: IssueFilters; +} + +const BASE_SELECT = ` + SELECT i.*, + it.name AS typ_name, it.icon AS typ_icon, it.farbe AS typ_farbe, it.erlaubt_abgelehnt AS typ_erlaubt_abgelehnt, + u1.name AS erstellt_von_name, + u2.name AS zugewiesen_an_name + FROM issues i + LEFT JOIN issue_typen it ON it.id = i.typ_id + LEFT JOIN users u1 ON u1.id = i.erstellt_von + LEFT JOIN users u2 ON u2.id = i.zugewiesen_an +`; + +async function getIssues({ userId, canViewAll, filters }: GetIssuesParams) { try { - const query = ` - SELECT i.*, - u1.name AS erstellt_von_name, - u2.name AS zugewiesen_an_name - FROM issues i - LEFT JOIN users u1 ON u1.id = i.erstellt_von - LEFT JOIN users u2 ON u2.id = i.zugewiesen_an - ${canViewAll ? '' : 'WHERE i.erstellt_von = $1'} - ORDER BY i.created_at DESC - `; - const result = canViewAll - ? await pool.query(query) - : await pool.query(query, [userId]); + const conditions: string[] = []; + const values: any[] = []; + let idx = 1; + + if (!canViewAll) { + conditions.push(`(i.erstellt_von = $${idx} OR i.zugewiesen_an = $${idx})`); + values.push(userId); + idx++; + } + + if (filters?.typ_id && filters.typ_id.length > 0) { + conditions.push(`i.typ_id = ANY($${idx}::int[])`); + values.push(filters.typ_id); + idx++; + } + + if (filters?.prioritaet && filters.prioritaet.length > 0) { + conditions.push(`i.prioritaet = ANY($${idx}::text[])`); + values.push(filters.prioritaet); + idx++; + } + + if (filters?.status && filters.status.length > 0) { + conditions.push(`i.status = ANY($${idx}::text[])`); + values.push(filters.status); + idx++; + } + + if (filters?.erstellt_von) { + conditions.push(`i.erstellt_von = $${idx}`); + values.push(filters.erstellt_von); + idx++; + } + + if (filters?.zugewiesen_an) { + conditions.push(`i.zugewiesen_an = $${idx}`); + values.push(filters.zugewiesen_an); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const query = `${BASE_SELECT} ${where} ORDER BY i.created_at DESC`; + const result = await pool.query(query, values); return result.rows; } catch (error) { logger.error('IssueService.getIssues failed', { error }); @@ -26,13 +81,7 @@ async function getIssues(userId: string, canViewAll: boolean) { async function getIssueById(id: number) { try { const result = await pool.query( - `SELECT i.*, - u1.name AS erstellt_von_name, - u2.name AS zugewiesen_an_name - FROM issues i - LEFT JOIN users u1 ON u1.id = i.erstellt_von - LEFT JOIN users u2 ON u2.id = i.zugewiesen_an - WHERE i.id = $1`, + `${BASE_SELECT} WHERE i.id = $1`, [id] ); return result.rows[0] || null; @@ -43,63 +92,96 @@ async function getIssueById(id: number) { } async function createIssue( - data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string }, + data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string }, userId: string ) { try { const result = await pool.query( - `INSERT INTO issues (titel, beschreibung, typ, prioritaet, erstellt_von) + `INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [ data.titel, data.beschreibung || null, - data.typ || 'sonstiges', + data.typ_id || 3, data.prioritaet || 'mittel', userId, ] ); - return result.rows[0]; + return getIssueById(result.rows[0].id); } catch (error) { logger.error('IssueService.createIssue failed', { error }); throw new Error('Issue konnte nicht erstellt werden'); } } +// Sentinel value to explicitly set zugewiesen_an to NULL (not used currently but kept for reference) +const UNASSIGN = '__UNASSIGN__'; + async function updateIssue( id: number, data: { titel?: string; beschreibung?: string; - typ?: string; + typ_id?: number; prioritaet?: string; status?: string; zugewiesen_an?: string | null; } ) { try { - const result = await pool.query( - `UPDATE issues - SET titel = COALESCE($1, titel), - beschreibung = COALESCE($2, beschreibung), - typ = COALESCE($3, typ), - prioritaet = COALESCE($4, prioritaet), - status = COALESCE($5, status), - zugewiesen_an = COALESCE($6, zugewiesen_an), - updated_at = NOW() - WHERE id = $7 - RETURNING *`, - [ - data.titel, - data.beschreibung, - data.typ, - data.prioritaet, - data.status, - data.zugewiesen_an, - id, - ] - ); - return result.rows[0] || null; + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.titel !== undefined) { + setClauses.push(`titel = $${idx}`); + values.push(data.titel); + idx++; + } + if (data.beschreibung !== undefined) { + setClauses.push(`beschreibung = $${idx}`); + values.push(data.beschreibung); + idx++; + } + if (data.typ_id !== undefined) { + setClauses.push(`typ_id = $${idx}`); + values.push(data.typ_id); + idx++; + } + if (data.prioritaet !== undefined) { + setClauses.push(`prioritaet = $${idx}`); + values.push(data.prioritaet); + idx++; + } + if (data.status !== undefined) { + setClauses.push(`status = $${idx}`); + values.push(data.status); + idx++; + } + if ('zugewiesen_an' in data) { + setClauses.push(`zugewiesen_an = $${idx}`); + values.push(data.zugewiesen_an ?? null); + idx++; + } + + if (setClauses.length === 0) { + return getIssueById(id); + } + + setClauses.push(`updated_at = NOW()`); + values.push(id); + + const query = ` + UPDATE issues + SET ${setClauses.join(', ')} + WHERE id = $${idx} + RETURNING id + `; + const result = await pool.query(query, values); + if (result.rows.length === 0) return null; + + return getIssueById(id); } catch (error) { logger.error('IssueService.updateIssue failed', { error, id }); throw new Error('Issue konnte nicht aktualisiert werden'); @@ -151,6 +233,134 @@ async function addComment(issueId: number, autorId: string, inhalt: string) { } } +async function getTypes() { + try { + const result = await pool.query( + `SELECT * FROM issue_typen WHERE aktiv = true ORDER BY sort_order ASC, id ASC` + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getTypes failed', { error }); + throw new Error('Issue-Typen konnten nicht geladen werden'); + } +} + +async function createType(data: { + name: string; + parent_id?: number | null; + icon?: string | null; + farbe?: string | null; + erlaubt_abgelehnt?: boolean; + sort_order?: number; +}) { + try { + const result = await pool.query( + `INSERT INTO issue_typen (name, parent_id, icon, farbe, erlaubt_abgelehnt, sort_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + data.name, + data.parent_id ?? null, + data.icon ?? null, + data.farbe ?? null, + data.erlaubt_abgelehnt ?? true, + data.sort_order ?? 0, + ] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.createType failed', { error }); + throw new Error('Issue-Typ konnte nicht erstellt werden'); + } +} + +async function updateType( + id: number, + data: { + name?: string; + parent_id?: number | null; + icon?: string | null; + farbe?: string | null; + erlaubt_abgelehnt?: boolean; + sort_order?: number; + } +) { + try { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + + if (data.name !== undefined) { + setClauses.push(`name = $${idx}`); + values.push(data.name); + idx++; + } + if ('parent_id' in data) { + setClauses.push(`parent_id = $${idx}`); + values.push(data.parent_id); + idx++; + } + if ('icon' in data) { + setClauses.push(`icon = $${idx}`); + values.push(data.icon); + idx++; + } + if ('farbe' in data) { + setClauses.push(`farbe = $${idx}`); + values.push(data.farbe); + idx++; + } + if (data.erlaubt_abgelehnt !== undefined) { + setClauses.push(`erlaubt_abgelehnt = $${idx}`); + values.push(data.erlaubt_abgelehnt); + idx++; + } + if (data.sort_order !== undefined) { + setClauses.push(`sort_order = $${idx}`); + values.push(data.sort_order); + idx++; + } + + if (setClauses.length === 0) { + const result = await pool.query(`SELECT * FROM issue_typen WHERE id = $1`, [id]); + return result.rows[0] || null; + } + + values.push(id); + const query = `UPDATE issue_typen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`; + const result = await pool.query(query, values); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.updateType failed', { error, id }); + throw new Error('Issue-Typ konnte nicht aktualisiert werden'); + } +} + +async function deactivateType(id: number) { + try { + const result = await pool.query( + `UPDATE issue_typen SET aktiv = false WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.deactivateType failed', { error, id }); + throw new Error('Issue-Typ konnte nicht deaktiviert werden'); + } +} + +async function getAssignableMembers() { + try { + const result = await pool.query( + `SELECT id, name FROM users WHERE id IS NOT NULL ORDER BY name` + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getAssignableMembers failed', { error }); + throw new Error('Mitglieder konnten nicht geladen werden'); + } +} + export default { getIssues, getIssueById, @@ -159,4 +369,10 @@ export default { deleteIssue, getComments, addComment, + getTypes, + createType, + updateType, + deactivateType, + getAssignableMembers, + UNASSIGN, }; diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 988cdb6..b6b7d2d 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -104,6 +104,11 @@ const PERMISSION_SUB_GROUPS: Record> = { 'Anfragen': ['create_request', 'approve', 'link_orders', 'order_for_user', 'edit'], 'Widget': ['widget'], }, + issues: { + 'Ansehen': ['view_own', 'view_all'], + 'Bearbeiten': ['create', 'change_status', 'edit', 'delete'], + 'Admin': ['edit_settings'], + }, admin: { 'Allgemein': ['view', 'write'], 'Services': ['view_services', 'edit_services'], diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 5c09aaa..d9b0177 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -193,12 +193,15 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); // Build Issues sub-items dynamically (tab order must match Issues.tsx) - const issuesSubItems: SubItem[] = [{ text: 'Meine Issues', path: '/issues?tab=0' }]; + const issuesSubItems: SubItem[] = [ + { text: 'Meine Issues', path: '/issues?tab=0' }, + { text: 'Zugewiesene', path: '/issues?tab=1' }, + ]; if (hasPermission('issues:view_all')) { - issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=1' }); + issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=2' }); } - if (hasPermission('issues:manage')) { - issuesSubItems.push({ text: 'Erledigte Issues', path: `/issues?tab=${issuesSubItems.length}` }); + if (hasPermission('issues:edit_settings')) { + issuesSubItems.push({ text: 'Kategorien', path: `/issues?tab=${issuesSubItems.length}` }); } const items = baseNavigationItems diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index ed4edea..bba18b8 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -1,14 +1,16 @@ -import { useState } from 'react'; +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, Collapse, Divider, CircularProgress, FormControlLabel, Switch, + Autocomplete, } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess, BugReport, FiberNew, HelpOutline, Send as SendIcon, - Circle as CircleIcon, + Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon, + DragIndicator, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'react-router-dom'; @@ -18,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, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types'; +import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember } from '../types/issue.types'; // ── Helpers ── @@ -39,18 +41,6 @@ const STATUS_LABELS: Record = { abgelehnt: 'Abgelehnt', }; -const TYP_ICONS: Record = { - bug: , - feature: , - sonstiges: , -}; - -const TYP_LABELS: Record = { - bug: 'Bug', - feature: 'Feature', - sonstiges: 'Sonstiges', -}; - const PRIO_COLORS: Record = { hoch: '#d32f2f', mittel: '#ed6c02', @@ -63,6 +53,18 @@ const PRIO_LABELS: Record = { niedrig: 'Niedrig', }; +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 } @@ -73,7 +75,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { // ── Comment Section ── -function CommentSection({ issueId }: { issueId: number }) { +function CommentSection({ issueId, canComment }: { issueId: number; canComment: boolean }) { const queryClient = useQueryClient(); const { showError } = useNotification(); const [text, setText] = useState(''); @@ -109,30 +111,32 @@ function CommentSection({ issueId }: { issueId: number }) { )) )} - - setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && text.trim()) { - e.preventDefault(); - addMut.mutate(text.trim()); - } - }} - multiline - maxRows={4} - /> - addMut.mutate(text.trim())} - > - - - + {canComment && ( + + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && text.trim()) { + e.preventDefault(); + addMut.mutate(text.trim()); + } + }} + multiline + maxRows={4} + /> + addMut.mutate(text.trim())} + > + + + + )} ); } @@ -141,19 +145,48 @@ function CommentSection({ issueId }: { issueId: number }) { function IssueRow({ issue, - canManage, - isOwner, + userId, + hasEdit, + hasChangeStatus, + hasDelete, + members, onDelete, }: { issue: Issue; - canManage: boolean; - isOwner: boolean; + userId: string; + hasEdit: boolean; + hasChangeStatus: boolean; + hasDelete: boolean; + members: AssignableMember[]; onDelete: (id: number) => void; }) { const [expanded, setExpanded] = useState(false); + const [reopenOpen, setReopenOpen] = useState(false); + const [reopenComment, setReopenComment] = useState(''); const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); + const isOwner = issue.erstellt_von === userId; + const isAssignee = issue.zugewiesen_an === userId; + const canDelete = hasDelete || isOwner; + const canComment = isOwner || isAssignee || hasChangeStatus || hasEdit; + + // Determine status change capability + const canChangeStatus = hasEdit || hasChangeStatus || isAssignee; + const ownerOnlyErledigt = isOwner && !canChangeStatus; + + // Build allowed statuses + 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 updateMut = useMutation({ mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data), onSuccess: () => { @@ -163,6 +196,20 @@ function IssueRow({ onError: () => showError('Fehler beim Aktualisieren'), }); + const handleReopen = () => { + updateMut.mutate({ status: 'offen', kommentar: reopenComment.trim() }, { + onSuccess: () => { + setReopenOpen(false); + setReopenComment(''); + queryClient.invalidateQueries({ queryKey: ['issues'] }); + showSuccess('Issue wiedereröffnet'); + }, + }); + }; + + // Owner on erledigt issue: show reopen button instead of status select + const showReopenButton = ownerOnlyErledigt && issue.status === 'erledigt'; + return ( <> #{issue.id} - {TYP_ICONS[issue.typ]} + {getTypIcon(issue.typ_icon, issue.typ_farbe)} {issue.titel} - + @@ -194,6 +241,7 @@ function IssueRow({ /> {issue.erstellt_von_name || '-'} + {issue.zugewiesen_an_name || '-'} {formatDate(issue.created_at)} { e.stopPropagation(); setExpanded(!expanded); }}> @@ -202,7 +250,7 @@ function IssueRow({ - + {issue.beschreibung && ( @@ -210,14 +258,19 @@ function IssueRow({ {issue.beschreibung} )} - {issue.zugewiesen_an_name && ( - - Zugewiesen an: {issue.zugewiesen_an_name} - - )} - {(canManage || isOwner) && ( - + + {/* Status control */} + {showReopenButton ? ( + + ) : canChangeStatus || isOwner ? ( Status - {canManage && ( - - Priorität - - - )} - - )} + ) : null} - {(canManage || isOwner) && ( + {/* Priority control — only with issues:edit */} + {hasEdit && ( + + Priorität + + + )} + + {/* Assignment — only with issues:edit */} + {hasEdit && ( + o.name} + value={members.find(m => m.id === issue.zugewiesen_an) || null} + onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })} + renderInput={(params) => } + onClick={(e: React.MouseEvent) => e.stopPropagation()} + isOptionEqualToValue={(o, v) => o.id === v.id} + /> + )} + + + {canDelete && ( + + + ); } // ── Issue Table ── -function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) { +function IssueTable({ + issues, + userId, + hasEdit, + hasChangeStatus, + hasDelete, + members, +}: { + issues: Issue[]; + userId: string; + hasEdit: boolean; + hasChangeStatus: boolean; + hasDelete: boolean; + members: AssignableMember[]; +}) { const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); @@ -306,6 +416,7 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: Priorität Status Erstellt von + Zugewiesen an Erstellt am @@ -315,8 +426,11 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: deleteMut.mutate(id)} /> ))} @@ -326,6 +440,295 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: ); } +// ── Filter Bar (for "Alle Issues" tab) ── + +function FilterBar({ + filters, + onChange, + types, + members, +}: { + filters: IssueFilters; + onChange: (f: IssueFilters) => void; + types: IssueTyp[]; + members: AssignableMember[]; +}) { + 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 */} + PRIO_LABELS[p as Issue['prioritaet']] || p} + value={filters.prioritaet || []} + onChange={(_e, val) => onChange({ ...filters, prioritaet: val })} + renderInput={(params) => } + /> + + {/* Status filter */} + STATUS_LABELS[s as Issue['status']] || s} + 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) && ( + + )} + + ); +} + +// ── Issue Type Admin ── + +function IssueTypeAdmin() { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const [editId, setEditId] = useState(null); + const [editData, setEditData] = useState>({}); + const [createOpen, setCreateOpen] = useState(false); + const [createData, setCreateData] = useState>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); + + const { data: types = [], isLoading } = useQuery({ + queryKey: ['issue-types'], + queryFn: issuesApi.getTypes, + }); + + const createMut = useMutation({ + mutationFn: (data: Partial) => issuesApi.createType(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issue-types'] }); + showSuccess('Kategorie erstellt'); + setCreateOpen(false); + setCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); + }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => issuesApi.updateType(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issue-types'] }); + showSuccess('Kategorie aktualisiert'); + setEditId(null); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => issuesApi.deleteType(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issue-types'] }); + showSuccess('Kategorie gelöscht'); + }, + onError: () => showError('Fehler beim Löschen'), + }); + + const startEdit = (t: IssueTyp) => { + setEditId(t.id); + setEditData({ 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 }); + }; + + // Flatten types with children indented + 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 }); + const children = types.filter(t => t.parent_id === root.id); + for (const child of children) { + result.push({ type: child, indent: true }); + } + } + // Add orphans (parent_id set but parent missing) + 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]); + + if (isLoading) return ; + + return ( + + + Issue-Kategorien + + + + + + + + Name + Icon + Farbe + Abgelehnt erlaubt + Sort + Aktiv + Aktionen + + + + {flatTypes.map(({ type: t, indent }) => ( + + + + {indent && } + {editId === t.id ? ( + setEditData({ ...editData, name: e.target.value })} /> + ) : ( + {t.name} + )} + + + + {editId === t.id ? ( + + ) : ( + {getTypIcon(t.icon, t.farbe)} + )} + + + {editId === t.id ? ( + + ) : ( + + )} + + + {editId === t.id ? ( + setEditData({ ...editData, erlaubt_abgelehnt: e.target.checked })} size="small" /> + ) : ( + {t.erlaubt_abgelehnt ? 'Ja' : 'Nein'} + )} + + + {editId === t.id ? ( + setEditData({ ...editData, sort_order: parseInt(e.target.value) || 0 })} /> + ) : ( + t.sort_order + )} + + + {editId === t.id ? ( + setEditData({ ...editData, aktiv: e.target.checked })} size="small" /> + ) : ( + + )} + + + {editId === t.id ? ( + + + + + ) : ( + + startEdit(t)}> + deleteMut.mutate(t.id)}> + + )} + + + ))} + +
+
+ + {/* Create Type Dialog */} + setCreateOpen(false)} maxWidth="sm" fullWidth> + Neue Kategorie erstellen + + setCreateData({ ...createData, name: e.target.value })} autoFocus /> + + Übergeordnete Kategorie + + + + Icon + + + + Farbe + + + setCreateData({ ...createData, erlaubt_abgelehnt: e.target.checked })} />} + label="Abgelehnt erlaubt" + /> + setCreateData({ ...createData, sort_order: parseInt(e.target.value) || 0 })} /> + + + + + + +
+ ); +} + // ── Main Page ── export default function Issues() { @@ -336,30 +739,65 @@ export default function Issues() { const queryClient = useQueryClient(); const canViewAll = hasPermission('issues:view_all'); - const canManage = hasPermission('issues:manage'); + const hasEdit = hasPermission('issues:edit'); + const hasChangeStatus = hasPermission('issues:change_status'); + const hasDeletePerm = hasPermission('issues:delete'); + 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: 'Kategorien', key: 'types' }); + return t; + }, [canViewAll, hasEditSettings]); + const tabParam = parseInt(searchParams.get('tab') || '0', 10); - const maxTab = canManage ? 2 : (canViewAll ? 1 : 0); - const tab = isNaN(tabParam) || tabParam < 0 || tabParam > maxTab ? 0 : tabParam; + const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam; - const [showDone, setShowDone] = useState(false); + const [showDoneMine, setShowDoneMine] = useState(false); + const [showDoneAssigned, setShowDoneAssigned] = useState(false); + const [filters, setFilters] = useState({}); const [createOpen, setCreateOpen] = useState(false); - const [form, setForm] = useState({ titel: '', typ: 'bug', prioritaet: 'mittel' }); + const [form, setForm] = useState({ titel: '', prioritaet: 'mittel' }); + // 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: members = [] } = useQuery({ + queryKey: ['issue-members'], + queryFn: issuesApi.getMembers, + enabled: hasEdit, + }); + const createMut = useMutation({ mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issues'] }); showSuccess('Issue erstellt'); setCreateOpen(false); - setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' }); + setForm({ titel: '', prioritaet: 'mittel' }); }, onError: () => showError('Fehler beim Erstellen'), }); @@ -368,9 +806,15 @@ export default function Issues() { setSearchParams({ tab: String(newValue) }); }; + // Filter logic for client-side tabs + const isDone = (i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt'; const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId); - const myIssuesFiltered = myIssues.filter((i: Issue) => showDone || (i.status !== 'erledigt' && i.status !== 'abgelehnt')); - const doneIssues = issues.filter((i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt'); + 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)); + + // Default typ_id to first active type + const defaultTypId = types.find(t => t.aktiv)?.id; return ( @@ -378,47 +822,53 @@ export default function Issues() { Issues - - {canViewAll && } - {canManage && } + {tabs.map((t, i) => )} + {/* Tab 0: Meine Issues */} setShowDone(e.target.checked)} size="small" />} + control={ 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 && ( - - {isLoading ? ( - - - + t.key === 'all')}> + + {isFilteredLoading ? ( + ) : ( - + )} )} - {canManage && ( - - {isLoading ? ( - - - - ) : ( - - )} + {/* Tab 3: Kategorien (conditional) */} + {hasEditSettings && ( + t.key === 'types')}> + )} @@ -446,13 +896,13 @@ export default function Issues() { Typ @@ -473,7 +923,7 @@ export default function Issues() { diff --git a/frontend/src/services/issues.ts b/frontend/src/services/issues.ts index 175462c..e914006 100644 --- a/frontend/src/services/issues.ts +++ b/frontend/src/services/issues.ts @@ -1,9 +1,16 @@ import { api } from './api'; -import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember } from '../types/issue.types'; export const issuesApi = { - getIssues: async (): Promise => { - const r = await api.get('/api/issues'); + getIssues: async (filters?: IssueFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.typ_id?.length) params.set('typ_id', filters.typ_id.join(',')); + if (filters?.prioritaet?.length) params.set('prioritaet', filters.prioritaet.join(',')); + if (filters?.status?.length) params.set('status', filters.status.join(',')); + if (filters?.erstellt_von) params.set('erstellt_von', filters.erstellt_von); + if (filters?.zugewiesen_an) params.set('zugewiesen_an', filters.zugewiesen_an); + const qs = params.toString(); + const r = await api.get(`/api/issues${qs ? `?${qs}` : ''}`); return r.data.data; }, getIssue: async (id: number): Promise => { @@ -29,4 +36,25 @@ export const issuesApi = { const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt }); return r.data.data; }, + // Types CRUD + getTypes: async (): Promise => { + const r = await api.get('/api/issues/typen'); + return r.data.data; + }, + createType: async (data: Partial): Promise => { + const r = await api.post('/api/issues/typen', data); + return r.data.data; + }, + updateType: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/issues/typen/${id}`, data); + return r.data.data; + }, + deleteType: async (id: number): Promise => { + await api.delete(`/api/issues/typen/${id}`); + }, + // Members for assignment + getMembers: async (): Promise => { + const r = await api.get('/api/issues/members'); + return r.data.data; + }, }; diff --git a/frontend/src/types/issue.types.ts b/frontend/src/types/issue.types.ts index 78f4031..d0c274a 100644 --- a/frontend/src/types/issue.types.ts +++ b/frontend/src/types/issue.types.ts @@ -1,8 +1,24 @@ +export interface IssueTyp { + id: number; + name: string; + parent_id: number | null; + icon: string | null; + farbe: string | null; + erlaubt_abgelehnt: boolean; + sort_order: number; + aktiv: boolean; + children?: IssueTyp[]; +} + export interface Issue { id: number; titel: string; beschreibung: string | null; - typ: 'bug' | 'feature' | 'sonstiges'; + typ_id: number; + typ_name: string; + typ_icon: string | null; + typ_farbe: string | null; + typ_erlaubt_abgelehnt: boolean; prioritaet: 'niedrig' | 'mittel' | 'hoch'; status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; erstellt_von: string; @@ -25,15 +41,29 @@ export interface IssueComment { export interface CreateIssuePayload { titel: string; beschreibung?: string; - typ?: 'bug' | 'feature' | 'sonstiges'; + typ_id?: number; prioritaet?: 'niedrig' | 'mittel' | 'hoch'; } export interface UpdateIssuePayload { titel?: string; beschreibung?: string; - typ?: 'bug' | 'feature' | 'sonstiges'; + typ_id?: number; prioritaet?: 'niedrig' | 'mittel' | 'hoch'; status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; zugewiesen_an?: string | null; + kommentar?: string; +} + +export interface IssueFilters { + typ_id?: number[]; + prioritaet?: string[]; + status?: string[]; + erstellt_von?: string; + zugewiesen_an?: string; +} + +export interface AssignableMember { + id: string; + name: string; }