rework issue system

This commit is contained in:
Matthias Hochmeister
2026-03-24 14:21:17 +01:00
parent abb337c683
commit 6c7531438e
9 changed files with 1260 additions and 189 deletions

View File

@@ -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<string, any>;
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -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';

View File

@@ -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,

View File

@@ -1,21 +1,76 @@
import pool from '../config/database';
import logger from '../utils/logger';
async function getIssues(userId: string, canViewAll: boolean) {
try {
const query = `
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
${canViewAll ? '' : 'WHERE i.erstellt_von = $1'}
ORDER BY i.created_at DESC
`;
const result = canViewAll
? await pool.query(query)
: await pool.query(query, [userId]);
`;
async function getIssues({ userId, canViewAll, filters }: GetIssuesParams) {
try {
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,
};

View File

@@ -104,6 +104,11 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'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'],

View File

@@ -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

View File

@@ -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<Issue['status'], string> = {
abgelehnt: 'Abgelehnt',
};
const TYP_ICONS: Record<Issue['typ'], JSX.Element> = {
bug: <BugReport fontSize="small" color="error" />,
feature: <FiberNew fontSize="small" color="info" />,
sonstiges: <HelpOutline fontSize="small" color="action" />,
};
const TYP_LABELS: Record<Issue['typ'], string> = {
bug: 'Bug',
feature: 'Feature',
sonstiges: 'Sonstiges',
};
const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
hoch: '#d32f2f',
mittel: '#ed6c02',
@@ -63,6 +53,18 @@ const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
niedrig: 'Niedrig',
};
const ICON_MAP: Record<string, JSX.Element> = {
BugReport: <BugReport fontSize="small" />,
FiberNew: <FiberNew fontSize="small" />,
HelpOutline: <HelpOutline fontSize="small" />,
};
function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element {
const icon = ICON_MAP[iconName || ''] || <HelpOutline fontSize="small" />;
const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action';
return <Box component="span" sx={{ display: 'inline-flex', color: `${colorProp}.main` }}>{icon}</Box>;
}
// ── 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,6 +111,7 @@ function CommentSection({ issueId }: { issueId: number }) {
</Box>
))
)}
{canComment && (
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
@@ -133,6 +136,7 @@ function CommentSection({ issueId }: { issueId: number }) {
<SendIcon />
</IconButton>
</Box>
)}
</Box>
);
}
@@ -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 (
<>
<TableRow
@@ -173,12 +220,12 @@ function IssueRow({
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{TYP_ICONS[issue.typ]}
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" />
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -194,6 +241,7 @@ function IssueRow({
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
<TableCell>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
@@ -202,7 +250,7 @@ function IssueRow({
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={8} sx={{ py: 0 }}>
<TableCell colSpan={9} sx={{ py: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{issue.beschreibung && (
@@ -210,14 +258,19 @@ function IssueRow({
{issue.beschreibung}
</Typography>
)}
{issue.zugewiesen_an_name && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Zugewiesen an: {issue.zugewiesen_an_name}
</Typography>
)}
{(canManage || isOwner) && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Status control */}
{showReopenButton ? (
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={(e) => { e.stopPropagation(); setReopenOpen(true); }}
>
Wiedereröffnen
</Button>
) : canChangeStatus || isOwner ? (
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
@@ -226,13 +279,15 @@ function IssueRow({
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="offen">Offen</MenuItem>
<MenuItem value="in_bearbeitung">In Bearbeitung</MenuItem>
<MenuItem value="erledigt">Erledigt</MenuItem>
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
{allowedStatuses.map(s => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</FormControl>
{canManage && (
) : null}
{/* Priority control — only with issues:edit */}
{hasEdit && (
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel>
<Select
@@ -247,10 +302,24 @@ function IssueRow({
</Select>
</FormControl>
)}
</Box>
)}
{(canManage || isOwner) && (
{/* Assignment — only with issues:edit */}
{hasEdit && (
<Autocomplete
size="small"
sx={{ minWidth: 200 }}
options={members}
getOptionLabel={(o) => o.name}
value={members.find(m => m.id === issue.zugewiesen_an) || null}
onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })}
renderInput={(params) => <TextField {...params} label="Zugewiesen an" size="small" />}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
)}
</Box>
{canDelete && (
<Button
size="small"
color="error"
@@ -263,18 +332,59 @@ function IssueRow({
)}
<Divider sx={{ my: 1 }} />
<CommentSection issueId={issue.id} />
<CommentSection issueId={issue.id} canComment={canComment} />
</Box>
</Collapse>
</TableCell>
</TableRow>
{/* Reopen Dialog */}
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Issue wiedereröffnen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Kommentar (Pflicht)"
required
multiline
rows={3}
fullWidth
value={reopenComment}
onChange={(e) => setReopenComment(e.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!reopenComment.trim() || updateMut.isPending}
onClick={handleReopen}
>
Wiedereröffnen
</Button>
</DialogActions>
</Dialog>
</>
);
}
// ── 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:
<TableCell>Priorität</TableCell>
<TableCell>Status</TableCell>
<TableCell>Erstellt von</TableCell>
<TableCell>Zugewiesen an</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell />
</TableRow>
@@ -315,8 +426,11 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
<IssueRow
key={issue.id}
issue={issue}
canManage={canManage}
isOwner={issue.erstellt_von === userId}
userId={userId}
hasEdit={hasEdit}
hasChangeStatus={hasChangeStatus}
hasDelete={hasDelete}
members={members}
onDelete={(id) => 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 (
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Type filter */}
<Autocomplete
multiple
size="small"
sx={{ minWidth: 200 }}
options={types}
getOptionLabel={(t) => 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) => <TextField {...params} label="Typ" size="small" />}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
{/* Priority filter */}
<Autocomplete
multiple
size="small"
sx={{ minWidth: 180 }}
options={['niedrig', 'mittel', 'hoch']}
getOptionLabel={(p) => PRIO_LABELS[p as Issue['prioritaet']] || p}
value={filters.prioritaet || []}
onChange={(_e, val) => onChange({ ...filters, prioritaet: val })}
renderInput={(params) => <TextField {...params} label="Priorität" size="small" />}
/>
{/* Status filter */}
<Autocomplete
multiple
size="small"
sx={{ minWidth: 200 }}
options={['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt']}
getOptionLabel={(s) => STATUS_LABELS[s as Issue['status']] || s}
value={filters.status || []}
onChange={(_e, val) => onChange({ ...filters, status: val })}
renderInput={(params) => <TextField {...params} label="Status" size="small" />}
/>
{/* Erstellt von */}
<Autocomplete
size="small"
sx={{ minWidth: 180 }}
options={members}
getOptionLabel={(m) => m.name}
value={members.find(m => m.id === filters.erstellt_von) || null}
onChange={(_e, val) => onChange({ ...filters, erstellt_von: val?.id })}
renderInput={(params) => <TextField {...params} label="Erstellt von" size="small" />}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
{/* Clear */}
{(filters.typ_id?.length || filters.prioritaet?.length || filters.status?.length || filters.erstellt_von) && (
<Button size="small" onClick={() => onChange({})}>Filter zurücksetzen</Button>
)}
</Box>
);
}
// ── Issue Type Admin ──
function IssueTypeAdmin() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const [editId, setEditId] = useState<number | null>(null);
const [editData, setEditData] = useState<Partial<IssueTyp>>({});
const [createOpen, setCreateOpen] = useState(false);
const [createData, setCreateData] = useState<Partial<IssueTyp>>({ 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<IssueTyp>) => 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<IssueTyp> }) => 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 <CircularProgress />;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Issue-Kategorien</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setCreateOpen(true)}>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Icon</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Abgelehnt erlaubt</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{flatTypes.map(({ type: t, indent }) => (
<TableRow key={t.id} sx={indent ? { bgcolor: 'action.hover' } : undefined}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{indent && <DragIndicator fontSize="small" sx={{ opacity: 0.3, ml: 2 }} />}
{editId === t.id ? (
<TextField size="small" value={editData.name || ''} onChange={(e) => setEditData({ ...editData, name: e.target.value })} />
) : (
<Typography variant="body2">{t.name}</Typography>
)}
</Box>
</TableCell>
<TableCell>
{editId === t.id ? (
<Select size="small" value={editData.icon || 'HelpOutline'} onChange={(e) => setEditData({ ...editData, icon: e.target.value })}>
<MenuItem value="BugReport">BugReport</MenuItem>
<MenuItem value="FiberNew">FiberNew</MenuItem>
<MenuItem value="HelpOutline">HelpOutline</MenuItem>
</Select>
) : (
<Box sx={{ display: 'inline-flex' }}>{getTypIcon(t.icon, t.farbe)}</Box>
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Select size="small" value={editData.farbe || 'action'} onChange={(e) => setEditData({ ...editData, farbe: e.target.value })}>
<MenuItem value="error">error</MenuItem>
<MenuItem value="info">info</MenuItem>
<MenuItem value="action">action</MenuItem>
</Select>
) : (
<Chip label={t.farbe || 'action'} size="small" variant="outlined" />
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Switch checked={editData.erlaubt_abgelehnt ?? true} onChange={(e) => setEditData({ ...editData, erlaubt_abgelehnt: e.target.checked })} size="small" />
) : (
<Typography variant="body2">{t.erlaubt_abgelehnt ? 'Ja' : 'Nein'}</Typography>
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<TextField size="small" type="number" sx={{ width: 70 }} value={editData.sort_order ?? 0} onChange={(e) => setEditData({ ...editData, sort_order: parseInt(e.target.value) || 0 })} />
) : (
t.sort_order
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Switch checked={editData.aktiv ?? true} onChange={(e) => setEditData({ ...editData, aktiv: e.target.checked })} size="small" />
) : (
<Chip label={t.aktiv ? 'Aktiv' : 'Inaktiv'} size="small" color={t.aktiv ? 'success' : 'default'} />
)}
</TableCell>
<TableCell>
{editId === t.id ? (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button size="small" variant="contained" onClick={() => updateMut.mutate({ id: t.id, data: editData })} disabled={updateMut.isPending}>
Speichern
</Button>
<Button size="small" onClick={() => setEditId(null)}>Abbrechen</Button>
</Box>
) : (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => startEdit(t)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Create Type Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neue Kategorie erstellen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField label="Name" required fullWidth value={createData.name || ''} onChange={(e) => setCreateData({ ...createData, name: e.target.value })} autoFocus />
<FormControl fullWidth>
<InputLabel>Übergeordnete Kategorie</InputLabel>
<Select value={createData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setCreateData({ ...createData, parent_id: e.target.value ? Number(e.target.value) : null })}>
<MenuItem value="">Keine</MenuItem>
{types.filter(t => !t.parent_id).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Icon</InputLabel>
<Select value={createData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setCreateData({ ...createData, icon: e.target.value })}>
<MenuItem value="BugReport">BugReport</MenuItem>
<MenuItem value="FiberNew">FiberNew</MenuItem>
<MenuItem value="HelpOutline">HelpOutline</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Farbe</InputLabel>
<Select value={createData.farbe || 'action'} label="Farbe" onChange={(e) => setCreateData({ ...createData, farbe: e.target.value })}>
<MenuItem value="error">error</MenuItem>
<MenuItem value="info">info</MenuItem>
<MenuItem value="action">action</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={<Switch checked={createData.erlaubt_abgelehnt ?? true} onChange={(e) => setCreateData({ ...createData, erlaubt_abgelehnt: e.target.checked })} />}
label="Abgelehnt erlaubt"
/>
<TextField label="Sortierung" type="number" value={createData.sort_order ?? 0} onChange={(e) => setCreateData({ ...createData, sort_order: parseInt(e.target.value) || 0 })} />
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
<Button variant="contained" disabled={!createData.name?.trim() || createMut.isPending} onClick={() => createMut.mutate(createData)}>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ── 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<IssueFilters>({});
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
const [form, setForm] = useState<CreateIssuePayload>({ 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 (
<DashboardLayout>
@@ -378,47 +822,53 @@ export default function Issues() {
<Typography variant="h5" gutterBottom>Issues</Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
<Tab label="Meine Issues" />
{canViewAll && <Tab label="Alle Issues" />}
{canManage && <Tab label="Erledigte Issues" />}
{tabs.map((t, i) => <Tab key={i} label={t.label} />)}
</Tabs>
{/* Tab 0: Meine Issues */}
<TabPanel value={tab} index={0}>
<FormControlLabel
control={<Switch checked={showDone} onChange={(e) => setShowDone(e.target.checked)} size="small" />}
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
sx={{ mb: 1 }}
/>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={myIssuesFiltered} canManage={canManage} userId={userId} />
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)}
</TabPanel>
{canViewAll && (
{/* Tab 1: Zugewiesene Issues */}
<TabPanel value={tab} index={1}>
<FormControlLabel
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
label="Erledigte anzeigen"
sx={{ mb: 1 }}
/>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={issues} canManage={canManage} userId={userId} />
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)}
</TabPanel>
{/* Tab 2: Alle Issues (conditional) */}
{canViewAll && (
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'all')}>
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} />
{isFilteredLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : (
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)}
</TabPanel>
)}
{canManage && (
<TabPanel value={tab} index={canViewAll ? 2 : 1}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<IssueTable issues={doneIssues} canManage={canManage} userId={userId} />
)}
{/* Tab 3: Kategorien (conditional) */}
{hasEditSettings && (
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'types')}>
<IssueTypeAdmin />
</TabPanel>
)}
</Box>
@@ -446,13 +896,13 @@ export default function Issues() {
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ || 'bug'}
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ: e.target.value as Issue['typ'] })}
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
<MenuItem value="bug">Bug</MenuItem>
<MenuItem value="feature">Feature</MenuItem>
<MenuItem value="sonstiges">Sonstiges</MenuItem>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
@@ -473,7 +923,7 @@ export default function Issues() {
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)}
onClick={() => createMut.mutate({ ...form, typ_id: form.typ_id ?? defaultTypId })}
>
Erstellen
</Button>

View File

@@ -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<Issue[]> => {
const r = await api.get('/api/issues');
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
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<Issue> => {
@@ -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<IssueTyp[]> => {
const r = await api.get('/api/issues/typen');
return r.data.data;
},
createType: async (data: Partial<IssueTyp>): Promise<IssueTyp> => {
const r = await api.post('/api/issues/typen', data);
return r.data.data;
},
updateType: async (id: number, data: Partial<IssueTyp>): Promise<IssueTyp> => {
const r = await api.patch(`/api/issues/typen/${id}`, data);
return r.data.data;
},
deleteType: async (id: number): Promise<void> => {
await api.delete(`/api/issues/typen/${id}`);
},
// Members for assignment
getMembers: async (): Promise<AssignableMember[]> => {
const r = await api.get('/api/issues/members');
return r.data.data;
},
};

View File

@@ -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;
}