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 userId = req.user!.id;
const groups: string[] = (req.user as any).groups || []; const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); 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 }); res.status(200).json({ success: true, data: issues });
} catch (error) { } catch (error) {
logger.error('IssueController.getIssues error', { error }); logger.error('IssueController.getIssues error', { error });
@@ -34,7 +61,7 @@ class IssueController {
const userId = req.user!.id; const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || []; const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); 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' }); res.status(403).json({ success: false, message: 'Kein Zugriff' });
return; return;
} }
@@ -69,7 +96,8 @@ class IssueController {
try { try {
const userId = req.user!.id; const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || []; 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); const existing = await issueService.getIssueById(id);
if (!existing) { if (!existing) {
@@ -78,19 +106,80 @@ class IssueController {
} }
const isOwner = existing.erstellt_von === userId; 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' }); res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return; return;
} }
// Owners without manage permission can only change status // Validate: if setting status to 'abgelehnt', check if type allows it
const updateData = canManage ? req.body : { status: req.body.status }; 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); const issue = await issueService.updateIssue(id, updateData);
if (!issue) { if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return; 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) { } catch (error) {
logger.error('IssueController.updateIssue error', { error }); logger.error('IssueController.updateIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' }); res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' });
@@ -111,8 +200,8 @@ class IssueController {
} }
const userId = req.user!.id; const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || []; const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage'); const canDelete = permissionService.hasPermission(groups, 'issues:delete');
if (!canManage && issue.erstellt_von !== userId) { if (!canDelete && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' }); res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return; return;
} }
@@ -139,7 +228,7 @@ class IssueController {
const userId = req.user!.id; const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || []; const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); 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' }); res.status(403).json({ success: false, message: 'Kein Zugriff' });
return; return;
} }
@@ -170,12 +259,17 @@ class IssueController {
} }
const userId = req.user!.id; const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || []; const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); const isOwner = issue.erstellt_von === userId;
const canManage = permissionService.hasPermission(groups, 'issues:manage'); const isAssignee = issue.zugewiesen_an === userId;
if (!canViewAll && !canManage && issue.erstellt_von !== userId) { const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
res.status(403).json({ success: false, message: 'Kein Zugriff' }); 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; return;
} }
const comment = await issueService.addComment(issueId, userId, inhalt.trim()); const comment = await issueService.addComment(issueId, userId, inhalt.trim());
res.status(201).json({ success: true, data: comment }); res.status(201).json({ success: true, data: comment });
} catch (error) { } catch (error) {
@@ -183,6 +277,81 @@ class IssueController {
res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' }); 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(); 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(); 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( router.get(
'/', '/',
authenticate, authenticate,
@@ -30,6 +66,12 @@ router.post(
issueController.addComment.bind(issueController) issueController.addComment.bind(issueController)
); );
router.get(
'/:id',
authenticate,
issueController.getIssue.bind(issueController)
);
router.patch( router.patch(
'/:id', '/:id',
authenticate, authenticate,

View File

@@ -1,21 +1,76 @@
import pool from '../config/database'; import pool from '../config/database';
import logger from '../utils/logger'; import logger from '../utils/logger';
async function getIssues(userId: string, canViewAll: boolean) { interface IssueFilters {
try { typ_id?: number[];
const query = ` prioritaet?: string[];
status?: string[];
erstellt_von?: string;
zugewiesen_an?: string;
}
interface GetIssuesParams {
userId: string;
canViewAll: boolean;
filters?: IssueFilters;
}
const BASE_SELECT = `
SELECT i.*, 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, u1.name AS erstellt_von_name,
u2.name AS zugewiesen_an_name u2.name AS zugewiesen_an_name
FROM issues i 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 u1 ON u1.id = i.erstellt_von
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an 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) async function getIssues({ userId, canViewAll, filters }: GetIssuesParams) {
: await pool.query(query, [userId]); 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; return result.rows;
} catch (error) { } catch (error) {
logger.error('IssueService.getIssues failed', { error }); logger.error('IssueService.getIssues failed', { error });
@@ -26,13 +81,7 @@ async function getIssues(userId: string, canViewAll: boolean) {
async function getIssueById(id: number) { async function getIssueById(id: number) {
try { try {
const result = await pool.query( const result = await pool.query(
`SELECT i.*, `${BASE_SELECT} WHERE i.id = $1`,
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`,
[id] [id]
); );
return result.rows[0] || null; return result.rows[0] || null;
@@ -43,63 +92,96 @@ async function getIssueById(id: number) {
} }
async function createIssue( async function createIssue(
data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string }, data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string },
userId: string userId: string
) { ) {
try { try {
const result = await pool.query( 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) VALUES ($1, $2, $3, $4, $5)
RETURNING *`, RETURNING *`,
[ [
data.titel, data.titel,
data.beschreibung || null, data.beschreibung || null,
data.typ || 'sonstiges', data.typ_id || 3,
data.prioritaet || 'mittel', data.prioritaet || 'mittel',
userId, userId,
] ]
); );
return result.rows[0]; return getIssueById(result.rows[0].id);
} catch (error) { } catch (error) {
logger.error('IssueService.createIssue failed', { error }); logger.error('IssueService.createIssue failed', { error });
throw new Error('Issue konnte nicht erstellt werden'); 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( async function updateIssue(
id: number, id: number,
data: { data: {
titel?: string; titel?: string;
beschreibung?: string; beschreibung?: string;
typ?: string; typ_id?: number;
prioritaet?: string; prioritaet?: string;
status?: string; status?: string;
zugewiesen_an?: string | null; zugewiesen_an?: string | null;
} }
) { ) {
try { try {
const result = await pool.query( const setClauses: string[] = [];
`UPDATE issues const values: any[] = [];
SET titel = COALESCE($1, titel), let idx = 1;
beschreibung = COALESCE($2, beschreibung),
typ = COALESCE($3, typ), if (data.titel !== undefined) {
prioritaet = COALESCE($4, prioritaet), setClauses.push(`titel = $${idx}`);
status = COALESCE($5, status), values.push(data.titel);
zugewiesen_an = COALESCE($6, zugewiesen_an), idx++;
updated_at = NOW() }
WHERE id = $7 if (data.beschreibung !== undefined) {
RETURNING *`, setClauses.push(`beschreibung = $${idx}`);
[ values.push(data.beschreibung);
data.titel, idx++;
data.beschreibung, }
data.typ, if (data.typ_id !== undefined) {
data.prioritaet, setClauses.push(`typ_id = $${idx}`);
data.status, values.push(data.typ_id);
data.zugewiesen_an, idx++;
id, }
] if (data.prioritaet !== undefined) {
); setClauses.push(`prioritaet = $${idx}`);
return result.rows[0] || null; 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) { } catch (error) {
logger.error('IssueService.updateIssue failed', { error, id }); logger.error('IssueService.updateIssue failed', { error, id });
throw new Error('Issue konnte nicht aktualisiert werden'); 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 { export default {
getIssues, getIssues,
getIssueById, getIssueById,
@@ -159,4 +369,10 @@ export default {
deleteIssue, deleteIssue,
getComments, getComments,
addComment, 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'], 'Anfragen': ['create_request', 'approve', 'link_orders', 'order_for_user', 'edit'],
'Widget': ['widget'], 'Widget': ['widget'],
}, },
issues: {
'Ansehen': ['view_own', 'view_all'],
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
'Admin': ['edit_settings'],
},
admin: { admin: {
'Allgemein': ['view', 'write'], 'Allgemein': ['view', 'write'],
'Services': ['view_services', 'edit_services'], 'Services': ['view_services', 'edit_services'],

View File

@@ -193,12 +193,15 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` });
// Build Issues sub-items dynamically (tab order must match Issues.tsx) // 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')) { 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')) { if (hasPermission('issues:edit_settings')) {
issuesSubItems.push({ text: 'Erledigte Issues', path: `/issues?tab=${issuesSubItems.length}` }); issuesSubItems.push({ text: 'Kategorien', path: `/issues?tab=${issuesSubItems.length}` });
} }
const items = baseNavigationItems const items = baseNavigationItems

View File

@@ -1,14 +1,16 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { import {
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer, Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle, TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch, InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch,
Autocomplete,
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess, Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon, BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
DragIndicator,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@@ -18,7 +20,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues'; 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 ── // ── Helpers ──
@@ -39,18 +41,6 @@ const STATUS_LABELS: Record<Issue['status'], string> = {
abgelehnt: 'Abgelehnt', 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> = { const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
hoch: '#d32f2f', hoch: '#d32f2f',
mittel: '#ed6c02', mittel: '#ed6c02',
@@ -63,6 +53,18 @@ const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
niedrig: 'Niedrig', 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 ── // ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number } interface TabPanelProps { children: React.ReactNode; index: number; value: number }
@@ -73,7 +75,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
// ── Comment Section ── // ── Comment Section ──
function CommentSection({ issueId }: { issueId: number }) { function CommentSection({ issueId, canComment }: { issueId: number; canComment: boolean }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showError } = useNotification(); const { showError } = useNotification();
const [text, setText] = useState(''); const [text, setText] = useState('');
@@ -109,6 +111,7 @@ function CommentSection({ issueId }: { issueId: number }) {
</Box> </Box>
)) ))
)} )}
{canComment && (
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField <TextField
size="small" size="small"
@@ -133,6 +136,7 @@ function CommentSection({ issueId }: { issueId: number }) {
<SendIcon /> <SendIcon />
</IconButton> </IconButton>
</Box> </Box>
)}
</Box> </Box>
); );
} }
@@ -141,19 +145,48 @@ function CommentSection({ issueId }: { issueId: number }) {
function IssueRow({ function IssueRow({
issue, issue,
canManage, userId,
isOwner, hasEdit,
hasChangeStatus,
hasDelete,
members,
onDelete, onDelete,
}: { }: {
issue: Issue; issue: Issue;
canManage: boolean; userId: string;
isOwner: boolean; hasEdit: boolean;
hasChangeStatus: boolean;
hasDelete: boolean;
members: AssignableMember[];
onDelete: (id: number) => void; onDelete: (id: number) => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
const [reopenComment, setReopenComment] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification(); 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({ const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data), mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
onSuccess: () => { onSuccess: () => {
@@ -163,6 +196,20 @@ function IssueRow({
onError: () => showError('Fehler beim Aktualisieren'), 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 ( return (
<> <>
<TableRow <TableRow
@@ -173,12 +220,12 @@ function IssueRow({
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell> <TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
<TableCell> <TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <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> <Typography variant="body2">{issue.titel}</Typography>
</Box> </Box>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" /> <Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -194,6 +241,7 @@ function IssueRow({
/> />
</TableCell> </TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell> <TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell> <TableCell>{formatDate(issue.created_at)}</TableCell>
<TableCell> <TableCell>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}> <IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
@@ -202,7 +250,7 @@ function IssueRow({
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell colSpan={8} sx={{ py: 0 }}> <TableCell colSpan={9} sx={{ py: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit> <Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
{issue.beschreibung && ( {issue.beschreibung && (
@@ -210,14 +258,19 @@ function IssueRow({
{issue.beschreibung} {issue.beschreibung}
</Typography> </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', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}> {/* 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 }}> <FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel> <InputLabel>Status</InputLabel>
<Select <Select
@@ -226,13 +279,15 @@ function IssueRow({
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })} onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MenuItem value="offen">Offen</MenuItem> {allowedStatuses.map(s => (
<MenuItem value="in_bearbeitung">In Bearbeitung</MenuItem> <MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
<MenuItem value="erledigt">Erledigt</MenuItem> ))}
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
{canManage && ( ) : null}
{/* Priority control — only with issues:edit */}
{hasEdit && (
<FormControl size="small" sx={{ minWidth: 140 }}> <FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel> <InputLabel>Priorität</InputLabel>
<Select <Select
@@ -247,10 +302,24 @@ function IssueRow({
</Select> </Select>
</FormControl> </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 <Button
size="small" size="small"
color="error" color="error"
@@ -263,18 +332,59 @@ function IssueRow({
)} )}
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
<CommentSection issueId={issue.id} /> <CommentSection issueId={issue.id} canComment={canComment} />
</Box> </Box>
</Collapse> </Collapse>
</TableCell> </TableCell>
</TableRow> </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 ── // ── 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 queryClient = useQueryClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
@@ -306,6 +416,7 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
<TableCell>Priorität</TableCell> <TableCell>Priorität</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Erstellt von</TableCell> <TableCell>Erstellt von</TableCell>
<TableCell>Zugewiesen an</TableCell>
<TableCell>Erstellt am</TableCell> <TableCell>Erstellt am</TableCell>
<TableCell /> <TableCell />
</TableRow> </TableRow>
@@ -315,8 +426,11 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
<IssueRow <IssueRow
key={issue.id} key={issue.id}
issue={issue} issue={issue}
canManage={canManage} userId={userId}
isOwner={issue.erstellt_von === userId} hasEdit={hasEdit}
hasChangeStatus={hasChangeStatus}
hasDelete={hasDelete}
members={members}
onDelete={(id) => deleteMut.mutate(id)} 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 ── // ── Main Page ──
export default function Issues() { export default function Issues() {
@@ -336,30 +739,65 @@ export default function Issues() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canViewAll = hasPermission('issues:view_all'); 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 canCreate = hasPermission('issues:create');
const userId = user?.id || ''; 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 tabParam = parseInt(searchParams.get('tab') || '0', 10);
const maxTab = canManage ? 2 : (canViewAll ? 1 : 0); const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam;
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > maxTab ? 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 [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({ const { data: issues = [], isLoading } = useQuery({
queryKey: ['issues'], queryKey: ['issues'],
queryFn: () => issuesApi.getIssues(), 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({ const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data), mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] }); queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt'); showSuccess('Issue erstellt');
setCreateOpen(false); setCreateOpen(false);
setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' }); setForm({ titel: '', prioritaet: 'mittel' });
}, },
onError: () => showError('Fehler beim Erstellen'), onError: () => showError('Fehler beim Erstellen'),
}); });
@@ -368,9 +806,15 @@ export default function Issues() {
setSearchParams({ tab: String(newValue) }); 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 myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
const myIssuesFiltered = myIssues.filter((i: Issue) => showDone || (i.status !== 'erledigt' && i.status !== 'abgelehnt')); const myIssuesFiltered = showDoneMine ? myIssues : myIssues.filter((i: Issue) => !isDone(i));
const doneIssues = issues.filter((i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt'); 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 ( return (
<DashboardLayout> <DashboardLayout>
@@ -378,47 +822,53 @@ export default function Issues() {
<Typography variant="h5" gutterBottom>Issues</Typography> <Typography variant="h5" gutterBottom>Issues</Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}> <Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
<Tab label="Meine Issues" /> {tabs.map((t, i) => <Tab key={i} label={t.label} />)}
{canViewAll && <Tab label="Alle Issues" />}
{canManage && <Tab label="Erledigte Issues" />}
</Tabs> </Tabs>
{/* Tab 0: Meine Issues */}
<TabPanel value={tab} index={0}> <TabPanel value={tab} index={0}>
<FormControlLabel <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" label="Erledigte anzeigen"
sx={{ mb: 1 }} sx={{ mb: 1 }}
/> />
{isLoading ? ( {isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
<CircularProgress />
</Box>
) : ( ) : (
<IssueTable issues={myIssuesFiltered} canManage={canManage} userId={userId} /> <IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
)} )}
</TabPanel> </TabPanel>
{canViewAll && ( {/* Tab 1: Zugewiesene Issues */}
<TabPanel value={tab} index={1}> <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 ? ( {isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
<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> </TabPanel>
)} )}
{canManage && ( {/* Tab 3: Kategorien (conditional) */}
<TabPanel value={tab} index={canViewAll ? 2 : 1}> {hasEditSettings && (
{isLoading ? ( <TabPanel value={tab} index={tabs.findIndex(t => t.key === 'types')}>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <IssueTypeAdmin />
<CircularProgress />
</Box>
) : (
<IssueTable issues={doneIssues} canManage={canManage} userId={userId} />
)}
</TabPanel> </TabPanel>
)} )}
</Box> </Box>
@@ -446,13 +896,13 @@ export default function Issues() {
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Typ</InputLabel> <InputLabel>Typ</InputLabel>
<Select <Select
value={form.typ || 'bug'} value={form.typ_id ?? defaultTypId ?? ''}
label="Typ" 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> {types.filter(t => t.aktiv).map(t => (
<MenuItem value="feature">Feature</MenuItem> <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
<MenuItem value="sonstiges">Sonstiges</MenuItem> ))}
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth> <FormControl fullWidth>
@@ -473,7 +923,7 @@ export default function Issues() {
<Button <Button
variant="contained" variant="contained"
disabled={!form.titel.trim() || createMut.isPending} disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)} onClick={() => createMut.mutate({ ...form, typ_id: form.typ_id ?? defaultTypId })}
> >
Erstellen Erstellen
</Button> </Button>

View File

@@ -1,9 +1,16 @@
import { api } from './api'; 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 = { export const issuesApi = {
getIssues: async (): Promise<Issue[]> => { getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
const r = await api.get('/api/issues'); 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; return r.data.data;
}, },
getIssue: async (id: number): Promise<Issue> => { getIssue: async (id: number): Promise<Issue> => {
@@ -29,4 +36,25 @@ export const issuesApi = {
const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt }); const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt });
return r.data.data; 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 { export interface Issue {
id: number; id: number;
titel: string; titel: string;
beschreibung: string | null; 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'; prioritaet: 'niedrig' | 'mittel' | 'hoch';
status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
erstellt_von: string; erstellt_von: string;
@@ -25,15 +41,29 @@ export interface IssueComment {
export interface CreateIssuePayload { export interface CreateIssuePayload {
titel: string; titel: string;
beschreibung?: string; beschreibung?: string;
typ?: 'bug' | 'feature' | 'sonstiges'; typ_id?: number;
prioritaet?: 'niedrig' | 'mittel' | 'hoch'; prioritaet?: 'niedrig' | 'mittel' | 'hoch';
} }
export interface UpdateIssuePayload { export interface UpdateIssuePayload {
titel?: string; titel?: string;
beschreibung?: string; beschreibung?: string;
typ?: 'bug' | 'feature' | 'sonstiges'; typ_id?: number;
prioritaet?: 'niedrig' | 'mittel' | 'hoch'; prioritaet?: 'niedrig' | 'mittel' | 'hoch';
status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
zugewiesen_an?: string | null; 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;
} }