rework issue system
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
128
backend/src/database/migrations/053_issues_rework.sql
Normal file
128
backend/src/database/migrations/053_issues_rework.sql
Normal 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';
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user