rework issue system
This commit is contained in:
@@ -11,7 +11,34 @@ class IssueController {
|
||||
const userId = req.user!.id;
|
||||
const groups: string[] = (req.user as any).groups || [];
|
||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||
const issues = await issueService.getIssues(userId, canViewAll);
|
||||
|
||||
// Parse filter query params
|
||||
const filters: {
|
||||
typ_id?: number[];
|
||||
prioritaet?: string[];
|
||||
status?: string[];
|
||||
erstellt_von?: string;
|
||||
zugewiesen_an?: string;
|
||||
} = {};
|
||||
|
||||
if (req.query.typ_id) {
|
||||
filters.typ_id = String(req.query.typ_id).split(',').map(Number).filter((n) => !isNaN(n));
|
||||
}
|
||||
if (req.query.prioritaet) {
|
||||
filters.prioritaet = String(req.query.prioritaet).split(',');
|
||||
}
|
||||
if (req.query.status) {
|
||||
filters.status = String(req.query.status).split(',');
|
||||
}
|
||||
if (req.query.erstellt_von) {
|
||||
filters.erstellt_von = req.query.erstellt_von as string;
|
||||
}
|
||||
if (req.query.zugewiesen_an) {
|
||||
filters.zugewiesen_an =
|
||||
req.query.zugewiesen_an === 'me' ? userId : (req.query.zugewiesen_an as string);
|
||||
}
|
||||
|
||||
const issues = await issueService.getIssues({ userId, canViewAll, filters });
|
||||
res.status(200).json({ success: true, data: issues });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.getIssues error', { error });
|
||||
@@ -34,7 +61,7 @@ class IssueController {
|
||||
const userId = req.user!.id;
|
||||
const groups: string[] = (req.user as any).groups || [];
|
||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||
if (!canViewAll && issue.erstellt_von !== userId) {
|
||||
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
|
||||
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||
return;
|
||||
}
|
||||
@@ -69,7 +96,8 @@ class IssueController {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const groups: string[] = (req.user as any).groups || [];
|
||||
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||
const canEdit = permissionService.hasPermission(groups, 'issues:edit');
|
||||
const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
|
||||
|
||||
const existing = await issueService.getIssueById(id);
|
||||
if (!existing) {
|
||||
@@ -78,19 +106,80 @@ class IssueController {
|
||||
}
|
||||
|
||||
const isOwner = existing.erstellt_von === userId;
|
||||
if (!canManage && !isOwner) {
|
||||
const isAssignee = existing.zugewiesen_an === userId;
|
||||
|
||||
// Determine what update data is allowed
|
||||
let updateData: Record<string, any>;
|
||||
|
||||
if (canEdit) {
|
||||
// Full edit access
|
||||
updateData = { ...req.body };
|
||||
// Explicit null for unassign is handled by 'zugewiesen_an' in data check in service
|
||||
} else if (canChangeStatus || isAssignee) {
|
||||
// Can only change status (+ kommentar is handled separately)
|
||||
updateData = {};
|
||||
if (req.body.status !== undefined) updateData.status = req.body.status;
|
||||
} else if (isOwner) {
|
||||
// Owner without change_status: can only close own issue or reopen from erledigt
|
||||
updateData = {};
|
||||
if (req.body.status !== undefined) {
|
||||
const newStatus = req.body.status;
|
||||
if (newStatus === 'erledigt') {
|
||||
updateData.status = 'erledigt';
|
||||
} else if (newStatus === 'offen' && existing.status === 'erledigt') {
|
||||
// Reopen: require kommentar
|
||||
if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Beim Wiedereröffnen ist ein Kommentar erforderlich',
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.status = 'offen';
|
||||
} else {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Owner trying to change non-status fields without edit permission
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Owners without manage permission can only change status
|
||||
const updateData = canManage ? req.body : { status: req.body.status };
|
||||
// Validate: if setting status to 'abgelehnt', check if type allows it
|
||||
if (updateData.status === 'abgelehnt' && existing.typ_id) {
|
||||
if (!existing.typ_erlaubt_abgelehnt) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Dieser Issue-Typ erlaubt den Status "Abgelehnt" nicht',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const issue = await issueService.updateIssue(id, updateData);
|
||||
if (!issue) {
|
||||
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: issue });
|
||||
|
||||
// Handle reopen comment (owner reopen flow)
|
||||
if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) {
|
||||
await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`);
|
||||
}
|
||||
|
||||
// If kommentar was provided alongside a status change (not the reopen flow above)
|
||||
if (req.body.kommentar && updateData.status && !(isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt')) {
|
||||
await issueService.addComment(id, userId, req.body.kommentar.trim());
|
||||
}
|
||||
|
||||
// Re-fetch to include any new comment info
|
||||
const updated = await issueService.getIssueById(id);
|
||||
res.status(200).json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.updateIssue error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' });
|
||||
@@ -111,8 +200,8 @@ class IssueController {
|
||||
}
|
||||
const userId = req.user!.id;
|
||||
const groups: string[] = (req.user as any).groups || [];
|
||||
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||
if (!canManage && issue.erstellt_von !== userId) {
|
||||
const canDelete = permissionService.hasPermission(groups, 'issues:delete');
|
||||
if (!canDelete && issue.erstellt_von !== userId) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +228,7 @@ class IssueController {
|
||||
const userId = req.user!.id;
|
||||
const groups: string[] = (req.user as any).groups || [];
|
||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||
if (!canViewAll && issue.erstellt_von !== userId) {
|
||||
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
|
||||
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||
return;
|
||||
}
|
||||
@@ -170,12 +259,17 @@ class IssueController {
|
||||
}
|
||||
const userId = req.user!.id;
|
||||
const groups: string[] = (req.user as any).groups || [];
|
||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
||||
const canManage = permissionService.hasPermission(groups, 'issues:manage');
|
||||
if (!canViewAll && !canManage && issue.erstellt_von !== userId) {
|
||||
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||
const isOwner = issue.erstellt_von === userId;
|
||||
const isAssignee = issue.zugewiesen_an === userId;
|
||||
const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
|
||||
const canEdit = permissionService.hasPermission(groups, 'issues:edit');
|
||||
|
||||
// Authorization: owner, assignee, change_status, or edit can comment
|
||||
if (!isOwner && !isAssignee && !canChangeStatus && !canEdit) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung zum Kommentieren' });
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = await issueService.addComment(issueId, userId, inhalt.trim());
|
||||
res.status(201).json({ success: true, data: comment });
|
||||
} catch (error) {
|
||||
@@ -183,6 +277,81 @@ class IssueController {
|
||||
res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Type management ---
|
||||
|
||||
async getTypes(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const types = await issueService.getTypes();
|
||||
res.status(200).json({ success: true, data: types });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.getTypes error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue-Typen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createType(req: Request, res: Response): Promise<void> {
|
||||
const { name } = req.body;
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const type = await issueService.createType(req.body);
|
||||
res.status(201).json({ success: true, data: type });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.createType error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateType(req: Request, res: Response): Promise<void> {
|
||||
const id = parseInt(param(req, 'id'), 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const type = await issueService.updateType(id, req.body);
|
||||
if (!type) {
|
||||
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: type });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.updateType error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteType(req: Request, res: Response): Promise<void> {
|
||||
const id = parseInt(param(req, 'id'), 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const type = await issueService.deactivateType(id);
|
||||
if (!type) {
|
||||
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: type });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.deleteType error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht deaktiviert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMembers(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const members = await issueService.getAssignableMembers();
|
||||
res.status(200).json({ success: true, data: members });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.getMembers error', { error });
|
||||
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new IssueController();
|
||||
|
||||
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();
|
||||
|
||||
// --- Type management routes (BEFORE /:id to avoid conflict) ---
|
||||
router.get(
|
||||
'/typen',
|
||||
authenticate,
|
||||
issueController.getTypes.bind(issueController)
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/typen',
|
||||
authenticate,
|
||||
requirePermission('issues:edit_settings'),
|
||||
issueController.createType.bind(issueController)
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/typen/:id',
|
||||
authenticate,
|
||||
requirePermission('issues:edit_settings'),
|
||||
issueController.updateType.bind(issueController)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/typen/:id',
|
||||
authenticate,
|
||||
requirePermission('issues:edit_settings'),
|
||||
issueController.deleteType.bind(issueController)
|
||||
);
|
||||
|
||||
// --- Members route ---
|
||||
router.get(
|
||||
'/members',
|
||||
authenticate,
|
||||
issueController.getMembers.bind(issueController)
|
||||
);
|
||||
|
||||
// --- Issue CRUD ---
|
||||
router.get(
|
||||
'/',
|
||||
authenticate,
|
||||
@@ -30,6 +66,12 @@ router.post(
|
||||
issueController.addComment.bind(issueController)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticate,
|
||||
issueController.getIssue.bind(issueController)
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
|
||||
@@ -1,21 +1,76 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
async function getIssues(userId: string, canViewAll: boolean) {
|
||||
interface IssueFilters {
|
||||
typ_id?: number[];
|
||||
prioritaet?: string[];
|
||||
status?: string[];
|
||||
erstellt_von?: string;
|
||||
zugewiesen_an?: string;
|
||||
}
|
||||
|
||||
interface GetIssuesParams {
|
||||
userId: string;
|
||||
canViewAll: boolean;
|
||||
filters?: IssueFilters;
|
||||
}
|
||||
|
||||
const BASE_SELECT = `
|
||||
SELECT i.*,
|
||||
it.name AS typ_name, it.icon AS typ_icon, it.farbe AS typ_farbe, it.erlaubt_abgelehnt AS typ_erlaubt_abgelehnt,
|
||||
u1.name AS erstellt_von_name,
|
||||
u2.name AS zugewiesen_an_name
|
||||
FROM issues i
|
||||
LEFT JOIN issue_typen it ON it.id = i.typ_id
|
||||
LEFT JOIN users u1 ON u1.id = i.erstellt_von
|
||||
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
|
||||
`;
|
||||
|
||||
async function getIssues({ userId, canViewAll, filters }: GetIssuesParams) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT i.*,
|
||||
u1.name AS erstellt_von_name,
|
||||
u2.name AS zugewiesen_an_name
|
||||
FROM issues i
|
||||
LEFT JOIN users u1 ON u1.id = i.erstellt_von
|
||||
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
|
||||
${canViewAll ? '' : 'WHERE i.erstellt_von = $1'}
|
||||
ORDER BY i.created_at DESC
|
||||
`;
|
||||
const result = canViewAll
|
||||
? await pool.query(query)
|
||||
: await pool.query(query, [userId]);
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (!canViewAll) {
|
||||
conditions.push(`(i.erstellt_von = $${idx} OR i.zugewiesen_an = $${idx})`);
|
||||
values.push(userId);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (filters?.typ_id && filters.typ_id.length > 0) {
|
||||
conditions.push(`i.typ_id = ANY($${idx}::int[])`);
|
||||
values.push(filters.typ_id);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (filters?.prioritaet && filters.prioritaet.length > 0) {
|
||||
conditions.push(`i.prioritaet = ANY($${idx}::text[])`);
|
||||
values.push(filters.prioritaet);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (filters?.status && filters.status.length > 0) {
|
||||
conditions.push(`i.status = ANY($${idx}::text[])`);
|
||||
values.push(filters.status);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (filters?.erstellt_von) {
|
||||
conditions.push(`i.erstellt_von = $${idx}`);
|
||||
values.push(filters.erstellt_von);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (filters?.zugewiesen_an) {
|
||||
conditions.push(`i.zugewiesen_an = $${idx}`);
|
||||
values.push(filters.zugewiesen_an);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const query = `${BASE_SELECT} ${where} ORDER BY i.created_at DESC`;
|
||||
const result = await pool.query(query, values);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.getIssues failed', { error });
|
||||
@@ -26,13 +81,7 @@ async function getIssues(userId: string, canViewAll: boolean) {
|
||||
async function getIssueById(id: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT i.*,
|
||||
u1.name AS erstellt_von_name,
|
||||
u2.name AS zugewiesen_an_name
|
||||
FROM issues i
|
||||
LEFT JOIN users u1 ON u1.id = i.erstellt_von
|
||||
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
|
||||
WHERE i.id = $1`,
|
||||
`${BASE_SELECT} WHERE i.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
@@ -43,63 +92,96 @@ async function getIssueById(id: number) {
|
||||
}
|
||||
|
||||
async function createIssue(
|
||||
data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string },
|
||||
data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string },
|
||||
userId: string
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO issues (titel, beschreibung, typ, prioritaet, erstellt_von)
|
||||
`INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.titel,
|
||||
data.beschreibung || null,
|
||||
data.typ || 'sonstiges',
|
||||
data.typ_id || 3,
|
||||
data.prioritaet || 'mittel',
|
||||
userId,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
return getIssueById(result.rows[0].id);
|
||||
} catch (error) {
|
||||
logger.error('IssueService.createIssue failed', { error });
|
||||
throw new Error('Issue konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinel value to explicitly set zugewiesen_an to NULL (not used currently but kept for reference)
|
||||
const UNASSIGN = '__UNASSIGN__';
|
||||
|
||||
async function updateIssue(
|
||||
id: number,
|
||||
data: {
|
||||
titel?: string;
|
||||
beschreibung?: string;
|
||||
typ?: string;
|
||||
typ_id?: number;
|
||||
prioritaet?: string;
|
||||
status?: string;
|
||||
zugewiesen_an?: string | null;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE issues
|
||||
SET titel = COALESCE($1, titel),
|
||||
beschreibung = COALESCE($2, beschreibung),
|
||||
typ = COALESCE($3, typ),
|
||||
prioritaet = COALESCE($4, prioritaet),
|
||||
status = COALESCE($5, status),
|
||||
zugewiesen_an = COALESCE($6, zugewiesen_an),
|
||||
updated_at = NOW()
|
||||
WHERE id = $7
|
||||
RETURNING *`,
|
||||
[
|
||||
data.titel,
|
||||
data.beschreibung,
|
||||
data.typ,
|
||||
data.prioritaet,
|
||||
data.status,
|
||||
data.zugewiesen_an,
|
||||
id,
|
||||
]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.titel !== undefined) {
|
||||
setClauses.push(`titel = $${idx}`);
|
||||
values.push(data.titel);
|
||||
idx++;
|
||||
}
|
||||
if (data.beschreibung !== undefined) {
|
||||
setClauses.push(`beschreibung = $${idx}`);
|
||||
values.push(data.beschreibung);
|
||||
idx++;
|
||||
}
|
||||
if (data.typ_id !== undefined) {
|
||||
setClauses.push(`typ_id = $${idx}`);
|
||||
values.push(data.typ_id);
|
||||
idx++;
|
||||
}
|
||||
if (data.prioritaet !== undefined) {
|
||||
setClauses.push(`prioritaet = $${idx}`);
|
||||
values.push(data.prioritaet);
|
||||
idx++;
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
setClauses.push(`status = $${idx}`);
|
||||
values.push(data.status);
|
||||
idx++;
|
||||
}
|
||||
if ('zugewiesen_an' in data) {
|
||||
setClauses.push(`zugewiesen_an = $${idx}`);
|
||||
values.push(data.zugewiesen_an ?? null);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return getIssueById(id);
|
||||
}
|
||||
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const query = `
|
||||
UPDATE issues
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE id = $${idx}
|
||||
RETURNING id
|
||||
`;
|
||||
const result = await pool.query(query, values);
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
return getIssueById(id);
|
||||
} catch (error) {
|
||||
logger.error('IssueService.updateIssue failed', { error, id });
|
||||
throw new Error('Issue konnte nicht aktualisiert werden');
|
||||
@@ -151,6 +233,134 @@ async function addComment(issueId: number, autorId: string, inhalt: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getTypes() {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM issue_typen WHERE aktiv = true ORDER BY sort_order ASC, id ASC`
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.getTypes failed', { error });
|
||||
throw new Error('Issue-Typen konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createType(data: {
|
||||
name: string;
|
||||
parent_id?: number | null;
|
||||
icon?: string | null;
|
||||
farbe?: string | null;
|
||||
erlaubt_abgelehnt?: boolean;
|
||||
sort_order?: number;
|
||||
}) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO issue_typen (name, parent_id, icon, farbe, erlaubt_abgelehnt, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.name,
|
||||
data.parent_id ?? null,
|
||||
data.icon ?? null,
|
||||
data.farbe ?? null,
|
||||
data.erlaubt_abgelehnt ?? true,
|
||||
data.sort_order ?? 0,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('IssueService.createType failed', { error });
|
||||
throw new Error('Issue-Typ konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateType(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
parent_id?: number | null;
|
||||
icon?: string | null;
|
||||
farbe?: string | null;
|
||||
erlaubt_abgelehnt?: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
setClauses.push(`name = $${idx}`);
|
||||
values.push(data.name);
|
||||
idx++;
|
||||
}
|
||||
if ('parent_id' in data) {
|
||||
setClauses.push(`parent_id = $${idx}`);
|
||||
values.push(data.parent_id);
|
||||
idx++;
|
||||
}
|
||||
if ('icon' in data) {
|
||||
setClauses.push(`icon = $${idx}`);
|
||||
values.push(data.icon);
|
||||
idx++;
|
||||
}
|
||||
if ('farbe' in data) {
|
||||
setClauses.push(`farbe = $${idx}`);
|
||||
values.push(data.farbe);
|
||||
idx++;
|
||||
}
|
||||
if (data.erlaubt_abgelehnt !== undefined) {
|
||||
setClauses.push(`erlaubt_abgelehnt = $${idx}`);
|
||||
values.push(data.erlaubt_abgelehnt);
|
||||
idx++;
|
||||
}
|
||||
if (data.sort_order !== undefined) {
|
||||
setClauses.push(`sort_order = $${idx}`);
|
||||
values.push(data.sort_order);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
const result = await pool.query(`SELECT * FROM issue_typen WHERE id = $1`, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const query = `UPDATE issue_typen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`;
|
||||
const result = await pool.query(query, values);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.updateType failed', { error, id });
|
||||
throw new Error('Issue-Typ konnte nicht aktualisiert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateType(id: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE issue_typen SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.deactivateType failed', { error, id });
|
||||
throw new Error('Issue-Typ konnte nicht deaktiviert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssignableMembers() {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name FROM users WHERE id IS NOT NULL ORDER BY name`
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.getAssignableMembers failed', { error });
|
||||
throw new Error('Mitglieder konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getIssues,
|
||||
getIssueById,
|
||||
@@ -159,4 +369,10 @@ export default {
|
||||
deleteIssue,
|
||||
getComments,
|
||||
addComment,
|
||||
getTypes,
|
||||
createType,
|
||||
updateType,
|
||||
deactivateType,
|
||||
getAssignableMembers,
|
||||
UNASSIGN,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,11 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
||||
'Anfragen': ['create_request', 'approve', 'link_orders', 'order_for_user', 'edit'],
|
||||
'Widget': ['widget'],
|
||||
},
|
||||
issues: {
|
||||
'Ansehen': ['view_own', 'view_all'],
|
||||
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
|
||||
'Admin': ['edit_settings'],
|
||||
},
|
||||
admin: {
|
||||
'Allgemein': ['view', 'write'],
|
||||
'Services': ['view_services', 'edit_services'],
|
||||
|
||||
@@ -193,12 +193,15 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` });
|
||||
|
||||
// Build Issues sub-items dynamically (tab order must match Issues.tsx)
|
||||
const issuesSubItems: SubItem[] = [{ text: 'Meine Issues', path: '/issues?tab=0' }];
|
||||
const issuesSubItems: SubItem[] = [
|
||||
{ text: 'Meine Issues', path: '/issues?tab=0' },
|
||||
{ text: 'Zugewiesene', path: '/issues?tab=1' },
|
||||
];
|
||||
if (hasPermission('issues:view_all')) {
|
||||
issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=1' });
|
||||
issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=2' });
|
||||
}
|
||||
if (hasPermission('issues:manage')) {
|
||||
issuesSubItems.push({ text: 'Erledigte Issues', path: `/issues?tab=${issuesSubItems.length}` });
|
||||
if (hasPermission('issues:edit_settings')) {
|
||||
issuesSubItems.push({ text: 'Kategorien', path: `/issues?tab=${issuesSubItems.length}` });
|
||||
}
|
||||
|
||||
const items = baseNavigationItems
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
|
||||
BugReport, FiberNew, HelpOutline, Send as SendIcon,
|
||||
Circle as CircleIcon,
|
||||
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
|
||||
DragIndicator,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -18,7 +20,7 @@ import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { issuesApi } from '../services/issues';
|
||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
|
||||
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember } from '../types/issue.types';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -39,18 +41,6 @@ const STATUS_LABELS: Record<Issue['status'], string> = {
|
||||
abgelehnt: 'Abgelehnt',
|
||||
};
|
||||
|
||||
const TYP_ICONS: Record<Issue['typ'], JSX.Element> = {
|
||||
bug: <BugReport fontSize="small" color="error" />,
|
||||
feature: <FiberNew fontSize="small" color="info" />,
|
||||
sonstiges: <HelpOutline fontSize="small" color="action" />,
|
||||
};
|
||||
|
||||
const TYP_LABELS: Record<Issue['typ'], string> = {
|
||||
bug: 'Bug',
|
||||
feature: 'Feature',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
|
||||
hoch: '#d32f2f',
|
||||
mittel: '#ed6c02',
|
||||
@@ -63,6 +53,18 @@ const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
|
||||
niedrig: 'Niedrig',
|
||||
};
|
||||
|
||||
const ICON_MAP: Record<string, JSX.Element> = {
|
||||
BugReport: <BugReport fontSize="small" />,
|
||||
FiberNew: <FiberNew fontSize="small" />,
|
||||
HelpOutline: <HelpOutline fontSize="small" />,
|
||||
};
|
||||
|
||||
function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element {
|
||||
const icon = ICON_MAP[iconName || ''] || <HelpOutline fontSize="small" />;
|
||||
const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action';
|
||||
return <Box component="span" sx={{ display: 'inline-flex', color: `${colorProp}.main` }}>{icon}</Box>;
|
||||
}
|
||||
|
||||
// ── Tab Panel ──
|
||||
|
||||
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
|
||||
@@ -73,7 +75,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
|
||||
// ── Comment Section ──
|
||||
|
||||
function CommentSection({ issueId }: { issueId: number }) {
|
||||
function CommentSection({ issueId, canComment }: { issueId: number; canComment: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showError } = useNotification();
|
||||
const [text, setText] = useState('');
|
||||
@@ -109,30 +111,32 @@ function CommentSection({ issueId }: { issueId: number }) {
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Kommentar schreiben..."
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && text.trim()) {
|
||||
e.preventDefault();
|
||||
addMut.mutate(text.trim());
|
||||
}
|
||||
}}
|
||||
multiline
|
||||
maxRows={4}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={!text.trim() || addMut.isPending}
|
||||
onClick={() => addMut.mutate(text.trim())}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{canComment && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Kommentar schreiben..."
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && text.trim()) {
|
||||
e.preventDefault();
|
||||
addMut.mutate(text.trim());
|
||||
}
|
||||
}}
|
||||
multiline
|
||||
maxRows={4}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={!text.trim() || addMut.isPending}
|
||||
onClick={() => addMut.mutate(text.trim())}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -141,19 +145,48 @@ function CommentSection({ issueId }: { issueId: number }) {
|
||||
|
||||
function IssueRow({
|
||||
issue,
|
||||
canManage,
|
||||
isOwner,
|
||||
userId,
|
||||
hasEdit,
|
||||
hasChangeStatus,
|
||||
hasDelete,
|
||||
members,
|
||||
onDelete,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canManage: boolean;
|
||||
isOwner: boolean;
|
||||
userId: string;
|
||||
hasEdit: boolean;
|
||||
hasChangeStatus: boolean;
|
||||
hasDelete: boolean;
|
||||
members: AssignableMember[];
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [reopenOpen, setReopenOpen] = useState(false);
|
||||
const [reopenComment, setReopenComment] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const isOwner = issue.erstellt_von === userId;
|
||||
const isAssignee = issue.zugewiesen_an === userId;
|
||||
const canDelete = hasDelete || isOwner;
|
||||
const canComment = isOwner || isAssignee || hasChangeStatus || hasEdit;
|
||||
|
||||
// Determine status change capability
|
||||
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
|
||||
const ownerOnlyErledigt = isOwner && !canChangeStatus;
|
||||
|
||||
// Build allowed statuses
|
||||
const allowedStatuses = useMemo(() => {
|
||||
if (hasEdit) return ['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt'] as Issue['status'][];
|
||||
if (hasChangeStatus || isAssignee) {
|
||||
const statuses: Issue['status'][] = ['offen', 'in_bearbeitung', 'erledigt'];
|
||||
if (issue.typ_erlaubt_abgelehnt) statuses.push('abgelehnt');
|
||||
return statuses;
|
||||
}
|
||||
if (isOwner) return [issue.status, 'erledigt'] as Issue['status'][];
|
||||
return [issue.status] as Issue['status'][];
|
||||
}, [hasEdit, hasChangeStatus, isAssignee, isOwner, issue.status, issue.typ_erlaubt_abgelehnt]);
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
|
||||
onSuccess: () => {
|
||||
@@ -163,6 +196,20 @@ function IssueRow({
|
||||
onError: () => showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
|
||||
const handleReopen = () => {
|
||||
updateMut.mutate({ status: 'offen', kommentar: reopenComment.trim() }, {
|
||||
onSuccess: () => {
|
||||
setReopenOpen(false);
|
||||
setReopenComment('');
|
||||
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||
showSuccess('Issue wiedereröffnet');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Owner on erledigt issue: show reopen button instead of status select
|
||||
const showReopenButton = ownerOnlyErledigt && issue.status === 'erledigt';
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
@@ -173,12 +220,12 @@ function IssueRow({
|
||||
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{TYP_ICONS[issue.typ]}
|
||||
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
|
||||
<Typography variant="body2">{issue.titel}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" />
|
||||
<Chip label={issue.typ_name} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
@@ -194,6 +241,7 @@ function IssueRow({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
|
||||
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
|
||||
<TableCell>{formatDate(issue.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
|
||||
@@ -202,7 +250,7 @@ function IssueRow({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 0 }}>
|
||||
<TableCell colSpan={9} sx={{ py: 0 }}>
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2 }}>
|
||||
{issue.beschreibung && (
|
||||
@@ -210,14 +258,19 @@ function IssueRow({
|
||||
{issue.beschreibung}
|
||||
</Typography>
|
||||
)}
|
||||
{issue.zugewiesen_an_name && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Zugewiesen an: {issue.zugewiesen_an_name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{(canManage || isOwner) && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Status control */}
|
||||
{showReopenButton ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={(e) => { e.stopPropagation(); setReopenOpen(true); }}
|
||||
>
|
||||
Wiedereröffnen
|
||||
</Button>
|
||||
) : canChangeStatus || isOwner ? (
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
@@ -226,31 +279,47 @@ function IssueRow({
|
||||
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem value="offen">Offen</MenuItem>
|
||||
<MenuItem value="in_bearbeitung">In Bearbeitung</MenuItem>
|
||||
<MenuItem value="erledigt">Erledigt</MenuItem>
|
||||
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
|
||||
{allowedStatuses.map(s => (
|
||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{canManage && (
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Priorität</InputLabel>
|
||||
<Select
|
||||
value={issue.prioritaet}
|
||||
label="Priorität"
|
||||
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value as Issue['prioritaet'] })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem value="niedrig">Niedrig</MenuItem>
|
||||
<MenuItem value="mittel">Mittel</MenuItem>
|
||||
<MenuItem value="hoch">Hoch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{(canManage || isOwner) && (
|
||||
{/* Priority control — only with issues:edit */}
|
||||
{hasEdit && (
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Priorität</InputLabel>
|
||||
<Select
|
||||
value={issue.prioritaet}
|
||||
label="Priorität"
|
||||
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value as Issue['prioritaet'] })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem value="niedrig">Niedrig</MenuItem>
|
||||
<MenuItem value="mittel">Mittel</MenuItem>
|
||||
<MenuItem value="hoch">Hoch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* Assignment — only with issues:edit */}
|
||||
{hasEdit && (
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ minWidth: 200 }}
|
||||
options={members}
|
||||
getOptionLabel={(o) => o.name}
|
||||
value={members.find(m => m.id === issue.zugewiesen_an) || null}
|
||||
onChange={(_e, val) => updateMut.mutate({ zugewiesen_an: val?.id || null })}
|
||||
renderInput={(params) => <TextField {...params} label="Zugewiesen an" size="small" />}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
@@ -263,18 +332,59 @@ function IssueRow({
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<CommentSection issueId={issue.id} />
|
||||
<CommentSection issueId={issue.id} canComment={canComment} />
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Reopen Dialog */}
|
||||
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Issue wiedereröffnen</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Kommentar (Pflicht)"
|
||||
required
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
value={reopenComment}
|
||||
onChange={(e) => setReopenComment(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!reopenComment.trim() || updateMut.isPending}
|
||||
onClick={handleReopen}
|
||||
>
|
||||
Wiedereröffnen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issue Table ──
|
||||
|
||||
function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) {
|
||||
function IssueTable({
|
||||
issues,
|
||||
userId,
|
||||
hasEdit,
|
||||
hasChangeStatus,
|
||||
hasDelete,
|
||||
members,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
userId: string;
|
||||
hasEdit: boolean;
|
||||
hasChangeStatus: boolean;
|
||||
hasDelete: boolean;
|
||||
members: AssignableMember[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
@@ -306,6 +416,7 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
|
||||
<TableCell>Priorität</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Erstellt von</TableCell>
|
||||
<TableCell>Zugewiesen an</TableCell>
|
||||
<TableCell>Erstellt am</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
@@ -315,8 +426,11 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
canManage={canManage}
|
||||
isOwner={issue.erstellt_von === userId}
|
||||
userId={userId}
|
||||
hasEdit={hasEdit}
|
||||
hasChangeStatus={hasChangeStatus}
|
||||
hasDelete={hasDelete}
|
||||
members={members}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
@@ -326,6 +440,295 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage:
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter Bar (for "Alle Issues" tab) ──
|
||||
|
||||
function FilterBar({
|
||||
filters,
|
||||
onChange,
|
||||
types,
|
||||
members,
|
||||
}: {
|
||||
filters: IssueFilters;
|
||||
onChange: (f: IssueFilters) => void;
|
||||
types: IssueTyp[];
|
||||
members: AssignableMember[];
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Type filter */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
sx={{ minWidth: 200 }}
|
||||
options={types}
|
||||
getOptionLabel={(t) => t.name}
|
||||
value={types.filter(t => filters.typ_id?.includes(t.id))}
|
||||
onChange={(_e, val) => onChange({ ...filters, typ_id: val.map(v => v.id) })}
|
||||
renderInput={(params) => <TextField {...params} label="Typ" size="small" />}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
/>
|
||||
|
||||
{/* Priority filter */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
sx={{ minWidth: 180 }}
|
||||
options={['niedrig', 'mittel', 'hoch']}
|
||||
getOptionLabel={(p) => PRIO_LABELS[p as Issue['prioritaet']] || p}
|
||||
value={filters.prioritaet || []}
|
||||
onChange={(_e, val) => onChange({ ...filters, prioritaet: val })}
|
||||
renderInput={(params) => <TextField {...params} label="Priorität" size="small" />}
|
||||
/>
|
||||
|
||||
{/* Status filter */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
sx={{ minWidth: 200 }}
|
||||
options={['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt']}
|
||||
getOptionLabel={(s) => STATUS_LABELS[s as Issue['status']] || s}
|
||||
value={filters.status || []}
|
||||
onChange={(_e, val) => onChange({ ...filters, status: val })}
|
||||
renderInput={(params) => <TextField {...params} label="Status" size="small" />}
|
||||
/>
|
||||
|
||||
{/* Erstellt von */}
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ minWidth: 180 }}
|
||||
options={members}
|
||||
getOptionLabel={(m) => m.name}
|
||||
value={members.find(m => m.id === filters.erstellt_von) || null}
|
||||
onChange={(_e, val) => onChange({ ...filters, erstellt_von: val?.id })}
|
||||
renderInput={(params) => <TextField {...params} label="Erstellt von" size="small" />}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
/>
|
||||
|
||||
{/* Clear */}
|
||||
{(filters.typ_id?.length || filters.prioritaet?.length || filters.status?.length || filters.erstellt_von) && (
|
||||
<Button size="small" onClick={() => onChange({})}>Filter zurücksetzen</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issue Type Admin ──
|
||||
|
||||
function IssueTypeAdmin() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<IssueTyp>>({});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createData, setCreateData] = useState<Partial<IssueTyp>>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
|
||||
|
||||
const { data: types = [], isLoading } = useQuery({
|
||||
queryKey: ['issue-types'],
|
||||
queryFn: issuesApi.getTypes,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: Partial<IssueTyp>) => issuesApi.createType(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issue-types'] });
|
||||
showSuccess('Kategorie erstellt');
|
||||
setCreateOpen(false);
|
||||
setCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<IssueTyp> }) => issuesApi.updateType(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issue-types'] });
|
||||
showSuccess('Kategorie aktualisiert');
|
||||
setEditId(null);
|
||||
},
|
||||
onError: () => showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deleteType(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issue-types'] });
|
||||
showSuccess('Kategorie gelöscht');
|
||||
},
|
||||
onError: () => showError('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
const startEdit = (t: IssueTyp) => {
|
||||
setEditId(t.id);
|
||||
setEditData({ name: t.name, icon: t.icon, farbe: t.farbe, erlaubt_abgelehnt: t.erlaubt_abgelehnt, sort_order: t.sort_order, aktiv: t.aktiv, parent_id: t.parent_id });
|
||||
};
|
||||
|
||||
// Flatten types with children indented
|
||||
const flatTypes = useMemo(() => {
|
||||
const roots = types.filter(t => !t.parent_id);
|
||||
const result: { type: IssueTyp; indent: boolean }[] = [];
|
||||
for (const root of roots) {
|
||||
result.push({ type: root, indent: false });
|
||||
const children = types.filter(t => t.parent_id === root.id);
|
||||
for (const child of children) {
|
||||
result.push({ type: child, indent: true });
|
||||
}
|
||||
}
|
||||
// Add orphans (parent_id set but parent missing)
|
||||
const listed = new Set(result.map(r => r.type.id));
|
||||
for (const t of types) {
|
||||
if (!listed.has(t.id)) result.push({ type: t, indent: false });
|
||||
}
|
||||
return result;
|
||||
}, [types]);
|
||||
|
||||
if (isLoading) return <CircularProgress />;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Issue-Kategorien</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setCreateOpen(true)}>
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Icon</TableCell>
|
||||
<TableCell>Farbe</TableCell>
|
||||
<TableCell>Abgelehnt erlaubt</TableCell>
|
||||
<TableCell>Sort</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{flatTypes.map(({ type: t, indent }) => (
|
||||
<TableRow key={t.id} sx={indent ? { bgcolor: 'action.hover' } : undefined}>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{indent && <DragIndicator fontSize="small" sx={{ opacity: 0.3, ml: 2 }} />}
|
||||
{editId === t.id ? (
|
||||
<TextField size="small" value={editData.name || ''} onChange={(e) => setEditData({ ...editData, name: e.target.value })} />
|
||||
) : (
|
||||
<Typography variant="body2">{t.name}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editId === t.id ? (
|
||||
<Select size="small" value={editData.icon || 'HelpOutline'} onChange={(e) => setEditData({ ...editData, icon: e.target.value })}>
|
||||
<MenuItem value="BugReport">BugReport</MenuItem>
|
||||
<MenuItem value="FiberNew">FiberNew</MenuItem>
|
||||
<MenuItem value="HelpOutline">HelpOutline</MenuItem>
|
||||
</Select>
|
||||
) : (
|
||||
<Box sx={{ display: 'inline-flex' }}>{getTypIcon(t.icon, t.farbe)}</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editId === t.id ? (
|
||||
<Select size="small" value={editData.farbe || 'action'} onChange={(e) => setEditData({ ...editData, farbe: e.target.value })}>
|
||||
<MenuItem value="error">error</MenuItem>
|
||||
<MenuItem value="info">info</MenuItem>
|
||||
<MenuItem value="action">action</MenuItem>
|
||||
</Select>
|
||||
) : (
|
||||
<Chip label={t.farbe || 'action'} size="small" variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editId === t.id ? (
|
||||
<Switch checked={editData.erlaubt_abgelehnt ?? true} onChange={(e) => setEditData({ ...editData, erlaubt_abgelehnt: e.target.checked })} size="small" />
|
||||
) : (
|
||||
<Typography variant="body2">{t.erlaubt_abgelehnt ? 'Ja' : 'Nein'}</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editId === t.id ? (
|
||||
<TextField size="small" type="number" sx={{ width: 70 }} value={editData.sort_order ?? 0} onChange={(e) => setEditData({ ...editData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
) : (
|
||||
t.sort_order
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editId === t.id ? (
|
||||
<Switch checked={editData.aktiv ?? true} onChange={(e) => setEditData({ ...editData, aktiv: e.target.checked })} size="small" />
|
||||
) : (
|
||||
<Chip label={t.aktiv ? 'Aktiv' : 'Inaktiv'} size="small" color={t.aktiv ? 'success' : 'default'} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editId === t.id ? (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button size="small" variant="contained" onClick={() => updateMut.mutate({ id: t.id, data: editData })} disabled={updateMut.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setEditId(null)}>Abbrechen</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => startEdit(t)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Create Type Dialog */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Neue Kategorie erstellen</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField label="Name" required fullWidth value={createData.name || ''} onChange={(e) => setCreateData({ ...createData, name: e.target.value })} autoFocus />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Übergeordnete Kategorie</InputLabel>
|
||||
<Select value={createData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setCreateData({ ...createData, parent_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<MenuItem value="">Keine</MenuItem>
|
||||
{types.filter(t => !t.parent_id).map(t => (
|
||||
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Icon</InputLabel>
|
||||
<Select value={createData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setCreateData({ ...createData, icon: e.target.value })}>
|
||||
<MenuItem value="BugReport">BugReport</MenuItem>
|
||||
<MenuItem value="FiberNew">FiberNew</MenuItem>
|
||||
<MenuItem value="HelpOutline">HelpOutline</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Farbe</InputLabel>
|
||||
<Select value={createData.farbe || 'action'} label="Farbe" onChange={(e) => setCreateData({ ...createData, farbe: e.target.value })}>
|
||||
<MenuItem value="error">error</MenuItem>
|
||||
<MenuItem value="info">info</MenuItem>
|
||||
<MenuItem value="action">action</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={createData.erlaubt_abgelehnt ?? true} onChange={(e) => setCreateData({ ...createData, erlaubt_abgelehnt: e.target.checked })} />}
|
||||
label="Abgelehnt erlaubt"
|
||||
/>
|
||||
<TextField label="Sortierung" type="number" value={createData.sort_order ?? 0} onChange={(e) => setCreateData({ ...createData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
|
||||
<Button variant="contained" disabled={!createData.name?.trim() || createMut.isPending} onClick={() => createMut.mutate(createData)}>
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ──
|
||||
|
||||
export default function Issues() {
|
||||
@@ -336,30 +739,65 @@ export default function Issues() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canViewAll = hasPermission('issues:view_all');
|
||||
const canManage = hasPermission('issues:manage');
|
||||
const hasEdit = hasPermission('issues:edit');
|
||||
const hasChangeStatus = hasPermission('issues:change_status');
|
||||
const hasDeletePerm = hasPermission('issues:delete');
|
||||
const hasEditSettings = hasPermission('issues:edit_settings');
|
||||
const canCreate = hasPermission('issues:create');
|
||||
const userId = user?.id || '';
|
||||
|
||||
// Build tab list dynamically
|
||||
const tabs = useMemo(() => {
|
||||
const t: { label: string; key: string }[] = [
|
||||
{ label: 'Meine Issues', key: 'mine' },
|
||||
{ label: 'Zugewiesene Issues', key: 'assigned' },
|
||||
];
|
||||
if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' });
|
||||
if (hasEditSettings) t.push({ label: 'Kategorien', key: 'types' });
|
||||
return t;
|
||||
}, [canViewAll, hasEditSettings]);
|
||||
|
||||
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
|
||||
const maxTab = canManage ? 2 : (canViewAll ? 1 : 0);
|
||||
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > maxTab ? 0 : tabParam;
|
||||
const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam;
|
||||
|
||||
const [showDone, setShowDone] = useState(false);
|
||||
const [showDoneMine, setShowDoneMine] = useState(false);
|
||||
const [showDoneAssigned, setShowDoneAssigned] = useState(false);
|
||||
const [filters, setFilters] = useState<IssueFilters>({});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
|
||||
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', prioritaet: 'mittel' });
|
||||
|
||||
// Fetch all issues for mine/assigned tabs
|
||||
const { data: issues = [], isLoading } = useQuery({
|
||||
queryKey: ['issues'],
|
||||
queryFn: () => issuesApi.getIssues(),
|
||||
});
|
||||
|
||||
// Fetch filtered issues for "Alle Issues" tab
|
||||
const activeTab = tabs[tab]?.key;
|
||||
const { data: filteredIssues = [], isLoading: isFilteredLoading } = useQuery({
|
||||
queryKey: ['issues', 'filtered', filters],
|
||||
queryFn: () => issuesApi.getIssues(filters),
|
||||
enabled: activeTab === 'all',
|
||||
});
|
||||
|
||||
const { data: types = [] } = useQuery({
|
||||
queryKey: ['issue-types'],
|
||||
queryFn: issuesApi.getTypes,
|
||||
});
|
||||
|
||||
const { data: members = [] } = useQuery({
|
||||
queryKey: ['issue-members'],
|
||||
queryFn: issuesApi.getMembers,
|
||||
enabled: hasEdit,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||
showSuccess('Issue erstellt');
|
||||
setCreateOpen(false);
|
||||
setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' });
|
||||
setForm({ titel: '', prioritaet: 'mittel' });
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen'),
|
||||
});
|
||||
@@ -368,9 +806,15 @@ export default function Issues() {
|
||||
setSearchParams({ tab: String(newValue) });
|
||||
};
|
||||
|
||||
// Filter logic for client-side tabs
|
||||
const isDone = (i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt';
|
||||
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
|
||||
const myIssuesFiltered = myIssues.filter((i: Issue) => showDone || (i.status !== 'erledigt' && i.status !== 'abgelehnt'));
|
||||
const doneIssues = issues.filter((i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt');
|
||||
const myIssuesFiltered = showDoneMine ? myIssues : myIssues.filter((i: Issue) => !isDone(i));
|
||||
const assignedIssues = issues.filter((i: Issue) => i.zugewiesen_an === userId);
|
||||
const assignedFiltered = showDoneAssigned ? assignedIssues : assignedIssues.filter((i: Issue) => !isDone(i));
|
||||
|
||||
// Default typ_id to first active type
|
||||
const defaultTypId = types.find(t => t.aktiv)?.id;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
@@ -378,47 +822,53 @@ export default function Issues() {
|
||||
<Typography variant="h5" gutterBottom>Issues</Typography>
|
||||
|
||||
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
|
||||
<Tab label="Meine Issues" />
|
||||
{canViewAll && <Tab label="Alle Issues" />}
|
||||
{canManage && <Tab label="Erledigte Issues" />}
|
||||
{tabs.map((t, i) => <Tab key={i} label={t.label} />)}
|
||||
</Tabs>
|
||||
|
||||
{/* Tab 0: Meine Issues */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showDone} onChange={(e) => setShowDone(e.target.checked)} size="small" />}
|
||||
control={<Switch checked={showDoneMine} onChange={(e) => setShowDoneMine(e.target.checked)} size="small" />}
|
||||
label="Erledigte anzeigen"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<IssueTable issues={myIssuesFiltered} canManage={canManage} userId={userId} />
|
||||
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Tab 1: Zugewiesene Issues */}
|
||||
<TabPanel value={tab} index={1}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showDoneAssigned} onChange={(e) => setShowDoneAssigned(e.target.checked)} size="small" />}
|
||||
label="Erledigte anzeigen"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Tab 2: Alle Issues (conditional) */}
|
||||
{canViewAll && (
|
||||
<TabPanel value={tab} index={1}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<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={issues} canManage={canManage} userId={userId} />
|
||||
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
|
||||
)}
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<TabPanel value={tab} index={canViewAll ? 2 : 1}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<IssueTable issues={doneIssues} canManage={canManage} userId={userId} />
|
||||
)}
|
||||
{/* Tab 3: Kategorien (conditional) */}
|
||||
{hasEditSettings && (
|
||||
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'types')}>
|
||||
<IssueTypeAdmin />
|
||||
</TabPanel>
|
||||
)}
|
||||
</Box>
|
||||
@@ -446,13 +896,13 @@ export default function Issues() {
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Typ</InputLabel>
|
||||
<Select
|
||||
value={form.typ || 'bug'}
|
||||
value={form.typ_id ?? defaultTypId ?? ''}
|
||||
label="Typ"
|
||||
onChange={(e) => setForm({ ...form, typ: e.target.value as Issue['typ'] })}
|
||||
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
|
||||
>
|
||||
<MenuItem value="bug">Bug</MenuItem>
|
||||
<MenuItem value="feature">Feature</MenuItem>
|
||||
<MenuItem value="sonstiges">Sonstiges</MenuItem>
|
||||
{types.filter(t => t.aktiv).map(t => (
|
||||
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
@@ -473,7 +923,7 @@ export default function Issues() {
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!form.titel.trim() || createMut.isPending}
|
||||
onClick={() => createMut.mutate(form)}
|
||||
onClick={() => createMut.mutate({ ...form, typ_id: form.typ_id ?? defaultTypId })}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { api } from './api';
|
||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
|
||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember } from '../types/issue.types';
|
||||
|
||||
export const issuesApi = {
|
||||
getIssues: async (): Promise<Issue[]> => {
|
||||
const r = await api.get('/api/issues');
|
||||
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.typ_id?.length) params.set('typ_id', filters.typ_id.join(','));
|
||||
if (filters?.prioritaet?.length) params.set('prioritaet', filters.prioritaet.join(','));
|
||||
if (filters?.status?.length) params.set('status', filters.status.join(','));
|
||||
if (filters?.erstellt_von) params.set('erstellt_von', filters.erstellt_von);
|
||||
if (filters?.zugewiesen_an) params.set('zugewiesen_an', filters.zugewiesen_an);
|
||||
const qs = params.toString();
|
||||
const r = await api.get(`/api/issues${qs ? `?${qs}` : ''}`);
|
||||
return r.data.data;
|
||||
},
|
||||
getIssue: async (id: number): Promise<Issue> => {
|
||||
@@ -29,4 +36,25 @@ export const issuesApi = {
|
||||
const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt });
|
||||
return r.data.data;
|
||||
},
|
||||
// Types CRUD
|
||||
getTypes: async (): Promise<IssueTyp[]> => {
|
||||
const r = await api.get('/api/issues/typen');
|
||||
return r.data.data;
|
||||
},
|
||||
createType: async (data: Partial<IssueTyp>): Promise<IssueTyp> => {
|
||||
const r = await api.post('/api/issues/typen', data);
|
||||
return r.data.data;
|
||||
},
|
||||
updateType: async (id: number, data: Partial<IssueTyp>): Promise<IssueTyp> => {
|
||||
const r = await api.patch(`/api/issues/typen/${id}`, data);
|
||||
return r.data.data;
|
||||
},
|
||||
deleteType: async (id: number): Promise<void> => {
|
||||
await api.delete(`/api/issues/typen/${id}`);
|
||||
},
|
||||
// Members for assignment
|
||||
getMembers: async (): Promise<AssignableMember[]> => {
|
||||
const r = await api.get('/api/issues/members');
|
||||
return r.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
export interface IssueTyp {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
icon: string | null;
|
||||
farbe: string | null;
|
||||
erlaubt_abgelehnt: boolean;
|
||||
sort_order: number;
|
||||
aktiv: boolean;
|
||||
children?: IssueTyp[];
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: number;
|
||||
titel: string;
|
||||
beschreibung: string | null;
|
||||
typ: 'bug' | 'feature' | 'sonstiges';
|
||||
typ_id: number;
|
||||
typ_name: string;
|
||||
typ_icon: string | null;
|
||||
typ_farbe: string | null;
|
||||
typ_erlaubt_abgelehnt: boolean;
|
||||
prioritaet: 'niedrig' | 'mittel' | 'hoch';
|
||||
status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
|
||||
erstellt_von: string;
|
||||
@@ -25,15 +41,29 @@ export interface IssueComment {
|
||||
export interface CreateIssuePayload {
|
||||
titel: string;
|
||||
beschreibung?: string;
|
||||
typ?: 'bug' | 'feature' | 'sonstiges';
|
||||
typ_id?: number;
|
||||
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
|
||||
}
|
||||
|
||||
export interface UpdateIssuePayload {
|
||||
titel?: string;
|
||||
beschreibung?: string;
|
||||
typ?: 'bug' | 'feature' | 'sonstiges';
|
||||
typ_id?: number;
|
||||
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
|
||||
status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
|
||||
zugewiesen_an?: string | null;
|
||||
kommentar?: string;
|
||||
}
|
||||
|
||||
export interface IssueFilters {
|
||||
typ_id?: number[];
|
||||
prioritaet?: string[];
|
||||
status?: string[];
|
||||
erstellt_von?: string;
|
||||
zugewiesen_an?: string;
|
||||
}
|
||||
|
||||
export interface AssignableMember {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user