fix permissions
This commit is contained in:
@@ -363,6 +363,108 @@ class IssueController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getIssueStatuses(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const items = await issueService.getIssueStatuses();
|
||||||
|
res.status(200).json({ success: true, data: items });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getIssueStatuses error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue-Status konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssueStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
const { schluessel, bezeichnung } = req.body;
|
||||||
|
if (!schluessel?.trim() || !bezeichnung?.trim()) {
|
||||||
|
res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await issueService.createIssueStatus(req.body);
|
||||||
|
res.status(201).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.createIssueStatus error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIssueStatus(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 item = await issueService.updateIssueStatus(id, req.body);
|
||||||
|
if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; }
|
||||||
|
res.status(200).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.updateIssueStatus error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIssueStatus(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 item = await issueService.deleteIssueStatus(id);
|
||||||
|
if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; }
|
||||||
|
res.status(200).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.deleteIssueStatus error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht deaktiviert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssuePriorities(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const items = await issueService.getIssuePriorities();
|
||||||
|
res.status(200).json({ success: true, data: items });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getIssuePriorities error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Prioritäten konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssuePriority(req: Request, res: Response): Promise<void> {
|
||||||
|
const { schluessel, bezeichnung } = req.body;
|
||||||
|
if (!schluessel?.trim() || !bezeichnung?.trim()) {
|
||||||
|
res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await issueService.createIssuePriority(req.body);
|
||||||
|
res.status(201).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.createIssuePriority error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Priorität konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIssuePriority(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 item = await issueService.updateIssuePriority(id, req.body);
|
||||||
|
if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; }
|
||||||
|
res.status(200).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.updateIssuePriority error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Priorität konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIssuePriority(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 item = await issueService.deleteIssuePriority(id);
|
||||||
|
if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; }
|
||||||
|
res.status(200).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.deleteIssuePriority error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getStatusmeldungen(_req: Request, res: Response): Promise<void> {
|
async getStatusmeldungen(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const items = await issueService.getStatusmeldungen();
|
const items = await issueService.getStatusmeldungen();
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration 058: dynamic issue statuses and priorities
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issue_statuses (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
schluessel VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
bezeichnung VARCHAR(100) NOT NULL,
|
||||||
|
farbe VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||||
|
ist_abschluss BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ist_initial BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
benoetigt_typ_freigabe BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
aktiv BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO issue_statuses (schluessel, bezeichnung, farbe, ist_abschluss, ist_initial, benoetigt_typ_freigabe, sort_order)
|
||||||
|
VALUES
|
||||||
|
('offen', 'Offen', 'info', false, true, false, 0),
|
||||||
|
('in_bearbeitung', 'In Bearbeitung', 'warning', false, false, false, 1),
|
||||||
|
('erledigt', 'Erledigt', 'success', true, false, false, 2),
|
||||||
|
('abgelehnt', 'Abgelehnt', 'error', true, false, true, 3)
|
||||||
|
ON CONFLICT (schluessel) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issue_prioritaeten (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
schluessel VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
bezeichnung VARCHAR(100) NOT NULL,
|
||||||
|
farbe VARCHAR(50) NOT NULL DEFAULT '#9e9e9e',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
aktiv BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO issue_prioritaeten (schluessel, bezeichnung, farbe, sort_order)
|
||||||
|
VALUES
|
||||||
|
('hoch', 'Hoch', '#d32f2f', 0),
|
||||||
|
('mittel', 'Mittel', '#ed6c02', 1),
|
||||||
|
('niedrig', 'Niedrig', '#9e9e9e', 2)
|
||||||
|
ON CONFLICT (schluessel) DO NOTHING;
|
||||||
@@ -41,6 +41,18 @@ router.get(
|
|||||||
issueController.getWidgetSummary.bind(issueController)
|
issueController.getWidgetSummary.bind(issueController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Statuses CRUD (BEFORE /:id) ---
|
||||||
|
router.get('/statuses', authenticate, issueController.getIssueStatuses.bind(issueController));
|
||||||
|
router.post('/statuses', authenticate, requirePermission('issues:edit_settings'), issueController.createIssueStatus.bind(issueController));
|
||||||
|
router.patch('/statuses/:id', authenticate, requirePermission('issues:edit_settings'), issueController.updateIssueStatus.bind(issueController));
|
||||||
|
router.delete('/statuses/:id', authenticate, requirePermission('issues:edit_settings'), issueController.deleteIssueStatus.bind(issueController));
|
||||||
|
|
||||||
|
// --- Priorities CRUD (BEFORE /:id) ---
|
||||||
|
router.get('/priorities', authenticate, issueController.getIssuePriorities.bind(issueController));
|
||||||
|
router.post('/priorities', authenticate, requirePermission('issues:edit_settings'), issueController.createIssuePriority.bind(issueController));
|
||||||
|
router.patch('/priorities/:id', authenticate, requirePermission('issues:edit_settings'), issueController.updateIssuePriority.bind(issueController));
|
||||||
|
router.delete('/priorities/:id', authenticate, requirePermission('issues:edit_settings'), issueController.deleteIssuePriority.bind(issueController));
|
||||||
|
|
||||||
// --- Type management routes (BEFORE /:id to avoid conflict) ---
|
// --- Type management routes (BEFORE /:id to avoid conflict) ---
|
||||||
router.get(
|
router.get(
|
||||||
'/typen',
|
'/typen',
|
||||||
|
|||||||
@@ -363,20 +363,202 @@ async function getAssignableMembers() {
|
|||||||
|
|
||||||
async function getIssueCounts() {
|
async function getIssueCounts() {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(`
|
||||||
`SELECT status, COUNT(*)::int AS count FROM issues GROUP BY status`
|
SELECT
|
||||||
);
|
COALESCE(s.schluessel, i.status) AS schluessel,
|
||||||
const counts: Record<string, number> = { offen: 0, in_bearbeitung: 0, erledigt: 0, abgelehnt: 0 };
|
COALESCE(s.bezeichnung, i.status) AS bezeichnung,
|
||||||
for (const row of result.rows) {
|
COALESCE(s.farbe, 'default') AS farbe,
|
||||||
counts[row.status] = row.count;
|
COALESCE(s.ist_abschluss, false) AS ist_abschluss,
|
||||||
}
|
COALESCE(s.sort_order, 99) AS sort_order,
|
||||||
return counts;
|
COUNT(*)::int AS count
|
||||||
|
FROM issues i
|
||||||
|
LEFT JOIN issue_statuses s ON s.schluessel = i.status
|
||||||
|
GROUP BY
|
||||||
|
COALESCE(s.schluessel, i.status),
|
||||||
|
COALESCE(s.bezeichnung, i.status),
|
||||||
|
COALESCE(s.farbe, 'default'),
|
||||||
|
COALESCE(s.ist_abschluss, false),
|
||||||
|
COALESCE(s.sort_order, 99)
|
||||||
|
ORDER BY COALESCE(s.sort_order, 99)
|
||||||
|
`);
|
||||||
|
return result.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueService.getIssueCounts failed', { error });
|
logger.error('IssueService.getIssueCounts failed', { error });
|
||||||
throw new Error('Issue-Counts konnten nicht geladen werden');
|
throw new Error('Issue-Counts konnten nicht geladen werden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getIssueStatuses() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM issue_statuses ORDER BY sort_order ASC, id ASC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getIssueStatuses failed', { error });
|
||||||
|
throw new Error('Issue-Status konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIssueStatus(data: {
|
||||||
|
schluessel: string;
|
||||||
|
bezeichnung: string;
|
||||||
|
farbe?: string;
|
||||||
|
ist_abschluss?: boolean;
|
||||||
|
ist_initial?: boolean;
|
||||||
|
benoetigt_typ_freigabe?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO issue_statuses (schluessel, bezeichnung, farbe, ist_abschluss, ist_initial, benoetigt_typ_freigabe, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.schluessel,
|
||||||
|
data.bezeichnung,
|
||||||
|
data.farbe ?? 'default',
|
||||||
|
data.ist_abschluss ?? false,
|
||||||
|
data.ist_initial ?? false,
|
||||||
|
data.benoetigt_typ_freigabe ?? false,
|
||||||
|
data.sort_order ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.createIssueStatus failed', { error });
|
||||||
|
throw new Error('Issue-Status konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIssueStatus(id: number, data: {
|
||||||
|
bezeichnung?: string;
|
||||||
|
farbe?: string;
|
||||||
|
ist_abschluss?: boolean;
|
||||||
|
ist_initial?: boolean;
|
||||||
|
benoetigt_typ_freigabe?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
aktiv?: boolean;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; }
|
||||||
|
if (data.farbe !== undefined) { setClauses.push(`farbe = $${idx}`); values.push(data.farbe); idx++; }
|
||||||
|
if (data.ist_abschluss !== undefined) { setClauses.push(`ist_abschluss = $${idx}`); values.push(data.ist_abschluss); idx++; }
|
||||||
|
if (data.ist_initial !== undefined) { setClauses.push(`ist_initial = $${idx}`); values.push(data.ist_initial); idx++; }
|
||||||
|
if (data.benoetigt_typ_freigabe !== undefined) { setClauses.push(`benoetigt_typ_freigabe = $${idx}`); values.push(data.benoetigt_typ_freigabe); idx++; }
|
||||||
|
if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; }
|
||||||
|
if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; }
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
const r = await pool.query(`SELECT * FROM issue_statuses WHERE id = $1`, [id]);
|
||||||
|
return r.rows[0] || null;
|
||||||
|
}
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE issue_statuses SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.updateIssueStatus failed', { error, id });
|
||||||
|
throw new Error('Issue-Status konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteIssueStatus(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE issue_statuses SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.deleteIssueStatus failed', { error, id });
|
||||||
|
throw new Error('Issue-Status konnte nicht deaktiviert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIssuePriorities() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM issue_prioritaeten ORDER BY sort_order ASC, id ASC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getIssuePriorities failed', { error });
|
||||||
|
throw new Error('Issue-Prioritäten konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIssuePriority(data: {
|
||||||
|
schluessel: string;
|
||||||
|
bezeichnung: string;
|
||||||
|
farbe?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO issue_prioritaeten (schluessel, bezeichnung, farbe, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[data.schluessel, data.bezeichnung, data.farbe ?? '#9e9e9e', data.sort_order ?? 0]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.createIssuePriority failed', { error });
|
||||||
|
throw new Error('Priorität konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIssuePriority(id: number, data: {
|
||||||
|
bezeichnung?: string;
|
||||||
|
farbe?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
aktiv?: boolean;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (data.bezeichnung !== undefined) { setClauses.push(`bezeichnung = $${idx}`); values.push(data.bezeichnung); idx++; }
|
||||||
|
if (data.farbe !== undefined) { setClauses.push(`farbe = $${idx}`); values.push(data.farbe); idx++; }
|
||||||
|
if (data.sort_order !== undefined) { setClauses.push(`sort_order = $${idx}`); values.push(data.sort_order); idx++; }
|
||||||
|
if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; }
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
const r = await pool.query(`SELECT * FROM issue_prioritaeten WHERE id = $1`, [id]);
|
||||||
|
return r.rows[0] || null;
|
||||||
|
}
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE issue_prioritaeten SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.updateIssuePriority failed', { error, id });
|
||||||
|
throw new Error('Priorität konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteIssuePriority(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE issue_prioritaeten SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.deleteIssuePriority failed', { error, id });
|
||||||
|
throw new Error('Priorität konnte nicht deaktiviert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getStatusmeldungen() {
|
async function getStatusmeldungen() {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -465,6 +647,14 @@ export default {
|
|||||||
deactivateType,
|
deactivateType,
|
||||||
getAssignableMembers,
|
getAssignableMembers,
|
||||||
getIssueCounts,
|
getIssueCounts,
|
||||||
|
getIssueStatuses,
|
||||||
|
createIssueStatus,
|
||||||
|
updateIssueStatus,
|
||||||
|
deleteIssueStatus,
|
||||||
|
getIssuePriorities,
|
||||||
|
createIssuePriority,
|
||||||
|
updateIssuePriority,
|
||||||
|
deleteIssuePriority,
|
||||||
getStatusmeldungen,
|
getStatusmeldungen,
|
||||||
createStatusmeldung,
|
createStatusmeldung,
|
||||||
updateStatusmeldung,
|
updateStatusmeldung,
|
||||||
|
|||||||
@@ -4,17 +4,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { issuesApi } from '../../services/issues';
|
import { issuesApi } from '../../services/issues';
|
||||||
|
|
||||||
const STATUS_CHIPS = [
|
|
||||||
{ key: 'offen' as const, label: 'Offen', color: 'info' as const },
|
|
||||||
{ key: 'in_bearbeitung' as const, label: 'In Bearbeitung', color: 'warning' as const },
|
|
||||||
{ key: 'erledigt' as const, label: 'Erledigt', color: 'success' as const },
|
|
||||||
{ key: 'abgelehnt' as const, label: 'Abgelehnt', color: 'error' as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
function IssueOverviewWidget() {
|
function IssueOverviewWidget() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data = [], isLoading, isError } = useQuery({
|
||||||
queryKey: ['issues-widget-summary'],
|
queryKey: ['issues-widget-summary'],
|
||||||
queryFn: issuesApi.getWidgetSummary,
|
queryFn: issuesApi.getWidgetSummary,
|
||||||
refetchInterval: 5 * 60 * 1000,
|
refetchInterval: 5 * 60 * 1000,
|
||||||
@@ -45,9 +38,9 @@ function IssueOverviewWidget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleChips = STATUS_CHIPS.filter((s) => data && data[s.key] > 0);
|
const visibleCounts = data.filter((s) => s.count > 0);
|
||||||
|
|
||||||
if (visibleChips.length === 0) {
|
if (visibleCounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
|
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -69,11 +62,11 @@ function IssueOverviewWidget() {
|
|||||||
<BugReport fontSize="small" color="action" />
|
<BugReport fontSize="small" color="action" />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{visibleChips.map((s) => (
|
{visibleCounts.map((s) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={s.key}
|
key={s.schluessel}
|
||||||
label={`${data![s.key]} ${s.label}`}
|
label={`${s.count} ${s.bezeichnung}`}
|
||||||
color={s.color}
|
color={s.farbe as any}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=2' });
|
issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=2' });
|
||||||
}
|
}
|
||||||
if (hasPermission('issues:edit_settings')) {
|
if (hasPermission('issues:edit_settings')) {
|
||||||
issuesSubItems.push({ text: 'Kategorien', path: `/issues?tab=${issuesSubItems.length}` });
|
issuesSubItems.push({ text: 'Einstellungen', path: `/issues?tab=${issuesSubItems.length}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = baseNavigationItems
|
const items = baseNavigationItems
|
||||||
|
|||||||
@@ -20,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, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung } from '../types/issue.types';
|
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung, IssueStatusDef, IssuePriorityDef } from '../types/issue.types';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -30,31 +30,31 @@ const formatDate = (iso?: string) =>
|
|||||||
const formatIssueId = (issue: Issue) =>
|
const formatIssueId = (issue: Issue) =>
|
||||||
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
|
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
|
||||||
|
|
||||||
const STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
offen: 'info',
|
offen: 'info', in_bearbeitung: 'warning', erledigt: 'success', abgelehnt: 'error',
|
||||||
in_bearbeitung: 'warning',
|
};
|
||||||
erledigt: 'success',
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
abgelehnt: 'error',
|
offen: 'Offen', in_bearbeitung: 'In Bearbeitung', erledigt: 'Erledigt', abgelehnt: 'Abgelehnt',
|
||||||
|
};
|
||||||
|
const PRIO_COLORS: Record<string, string> = {
|
||||||
|
hoch: '#d32f2f', mittel: '#ed6c02', niedrig: '#9e9e9e',
|
||||||
|
};
|
||||||
|
const PRIO_LABELS: Record<string, string> = {
|
||||||
|
hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<Issue['status'], string> = {
|
function getStatusLabel(statuses: IssueStatusDef[], key: string) {
|
||||||
offen: 'Offen',
|
return statuses.find(s => s.schluessel === key)?.bezeichnung ?? STATUS_LABELS[key] ?? key;
|
||||||
in_bearbeitung: 'In Bearbeitung',
|
}
|
||||||
erledigt: 'Erledigt',
|
function getStatusColor(statuses: IssueStatusDef[], key: string): any {
|
||||||
abgelehnt: 'Abgelehnt',
|
return statuses.find(s => s.schluessel === key)?.farbe ?? STATUS_COLORS[key] ?? 'default';
|
||||||
};
|
}
|
||||||
|
function getPrioColor(priorities: IssuePriorityDef[], key: string) {
|
||||||
const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
|
return priorities.find(p => p.schluessel === key)?.farbe ?? PRIO_COLORS[key] ?? '#9e9e9e';
|
||||||
hoch: '#d32f2f',
|
}
|
||||||
mittel: '#ed6c02',
|
function getPrioLabel(priorities: IssuePriorityDef[], key: string) {
|
||||||
niedrig: '#9e9e9e',
|
return priorities.find(p => p.schluessel === key)?.bezeichnung ?? PRIO_LABELS[key] ?? key;
|
||||||
};
|
}
|
||||||
|
|
||||||
const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
|
|
||||||
hoch: 'Hoch',
|
|
||||||
mittel: 'Mittel',
|
|
||||||
niedrig: 'Niedrig',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_MAP: Record<string, JSX.Element> = {
|
const ICON_MAP: Record<string, JSX.Element> = {
|
||||||
BugReport: <BugReport fontSize="small" />,
|
BugReport: <BugReport fontSize="small" />,
|
||||||
@@ -153,6 +153,8 @@ function IssueRow({
|
|||||||
hasChangeStatus,
|
hasChangeStatus,
|
||||||
hasDelete,
|
hasDelete,
|
||||||
members,
|
members,
|
||||||
|
statuses,
|
||||||
|
priorities,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
@@ -161,6 +163,8 @@ function IssueRow({
|
|||||||
hasChangeStatus: boolean;
|
hasChangeStatus: boolean;
|
||||||
hasDelete: boolean;
|
hasDelete: boolean;
|
||||||
members: AssignableMember[];
|
members: AssignableMember[];
|
||||||
|
statuses: IssueStatusDef[];
|
||||||
|
priorities: IssuePriorityDef[];
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -178,17 +182,14 @@ function IssueRow({
|
|||||||
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
|
const canChangeStatus = hasEdit || hasChangeStatus || isAssignee;
|
||||||
const ownerOnlyErledigt = isOwner && !canChangeStatus;
|
const ownerOnlyErledigt = isOwner && !canChangeStatus;
|
||||||
|
|
||||||
// Build allowed statuses
|
// Build allowed statuses from dynamic list
|
||||||
const allowedStatuses = useMemo(() => {
|
const allowedStatuses = useMemo(() => {
|
||||||
if (hasEdit) return ['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt'] as Issue['status'][];
|
const active = statuses.filter(s => s.aktiv);
|
||||||
if (hasChangeStatus || isAssignee) {
|
if (hasEdit) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
|
||||||
const statuses: Issue['status'][] = ['offen', 'in_bearbeitung', 'erledigt'];
|
if (hasChangeStatus || isAssignee) return active.filter(s => !s.benoetigt_typ_freigabe || issue.typ_erlaubt_abgelehnt);
|
||||||
if (issue.typ_erlaubt_abgelehnt) statuses.push('abgelehnt');
|
if (isOwner) return active.filter(s => s.schluessel === issue.status || s.ist_abschluss);
|
||||||
return statuses;
|
return active.filter(s => s.schluessel === issue.status);
|
||||||
}
|
}, [statuses, hasEdit, hasChangeStatus, isAssignee, isOwner, issue.status, issue.typ_erlaubt_abgelehnt]);
|
||||||
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),
|
||||||
@@ -199,8 +200,15 @@ function IssueRow({
|
|||||||
onError: () => showError('Fehler beim Aktualisieren'),
|
onError: () => showError('Fehler beim Aktualisieren'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Owner on erledigt issue: show reopen button instead of status select
|
||||||
|
const currentStatusDef = statuses.find(s => s.schluessel === issue.status);
|
||||||
|
const isTerminal = currentStatusDef?.ist_abschluss ?? (issue.status === 'erledigt');
|
||||||
|
const showReopenButton = ownerOnlyErledigt && isTerminal;
|
||||||
|
|
||||||
|
const initialStatusKey = statuses.find(s => s.ist_initial)?.schluessel ?? 'offen';
|
||||||
|
|
||||||
const handleReopen = () => {
|
const handleReopen = () => {
|
||||||
updateMut.mutate({ status: 'offen', kommentar: reopenComment.trim() }, {
|
updateMut.mutate({ status: initialStatusKey, kommentar: reopenComment.trim() }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setReopenOpen(false);
|
setReopenOpen(false);
|
||||||
setReopenComment('');
|
setReopenComment('');
|
||||||
@@ -210,9 +218,6 @@ function IssueRow({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Owner on erledigt issue: show reopen button instead of status select
|
|
||||||
const showReopenButton = ownerOnlyErledigt && issue.status === 'erledigt';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -232,15 +237,15 @@ function IssueRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<CircleIcon sx={{ fontSize: 10, color: PRIO_COLORS[issue.prioritaet] }} />
|
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
|
||||||
<Typography variant="body2">{PRIO_LABELS[issue.prioritaet]}</Typography>
|
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<Chip
|
||||||
label={STATUS_LABELS[issue.status]}
|
label={getStatusLabel(statuses, issue.status)}
|
||||||
size="small"
|
size="small"
|
||||||
color={STATUS_COLORS[issue.status]}
|
color={getStatusColor(statuses, issue.status)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
|
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
|
||||||
@@ -279,11 +284,11 @@ function IssueRow({
|
|||||||
<Select
|
<Select
|
||||||
value={issue.status}
|
value={issue.status}
|
||||||
label="Status"
|
label="Status"
|
||||||
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
|
onChange={(e) => updateMut.mutate({ status: e.target.value })}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{allowedStatuses.map(s => (
|
{allowedStatuses.map(s => (
|
||||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
|
<MenuItem key={s.schluessel} value={s.schluessel}>{s.bezeichnung}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -296,12 +301,12 @@ function IssueRow({
|
|||||||
<Select
|
<Select
|
||||||
value={issue.prioritaet}
|
value={issue.prioritaet}
|
||||||
label="Priorität"
|
label="Priorität"
|
||||||
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value as Issue['prioritaet'] })}
|
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value })}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MenuItem value="niedrig">Niedrig</MenuItem>
|
{priorities.filter(p => p.aktiv).map(p => (
|
||||||
<MenuItem value="mittel">Mittel</MenuItem>
|
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
|
||||||
<MenuItem value="hoch">Hoch</MenuItem>
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
@@ -380,6 +385,8 @@ function IssueTable({
|
|||||||
hasChangeStatus,
|
hasChangeStatus,
|
||||||
hasDelete,
|
hasDelete,
|
||||||
members,
|
members,
|
||||||
|
statuses,
|
||||||
|
priorities,
|
||||||
}: {
|
}: {
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -387,6 +394,8 @@ function IssueTable({
|
|||||||
hasChangeStatus: boolean;
|
hasChangeStatus: boolean;
|
||||||
hasDelete: boolean;
|
hasDelete: boolean;
|
||||||
members: AssignableMember[];
|
members: AssignableMember[];
|
||||||
|
statuses: IssueStatusDef[];
|
||||||
|
priorities: IssuePriorityDef[];
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
@@ -434,6 +443,8 @@ function IssueTable({
|
|||||||
hasChangeStatus={hasChangeStatus}
|
hasChangeStatus={hasChangeStatus}
|
||||||
hasDelete={hasDelete}
|
hasDelete={hasDelete}
|
||||||
members={members}
|
members={members}
|
||||||
|
statuses={statuses}
|
||||||
|
priorities={priorities}
|
||||||
onDelete={(id) => deleteMut.mutate(id)}
|
onDelete={(id) => deleteMut.mutate(id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -450,11 +461,15 @@ function FilterBar({
|
|||||||
onChange,
|
onChange,
|
||||||
types,
|
types,
|
||||||
members,
|
members,
|
||||||
|
statuses,
|
||||||
|
priorities,
|
||||||
}: {
|
}: {
|
||||||
filters: IssueFilters;
|
filters: IssueFilters;
|
||||||
onChange: (f: IssueFilters) => void;
|
onChange: (f: IssueFilters) => void;
|
||||||
types: IssueTyp[];
|
types: IssueTyp[];
|
||||||
members: AssignableMember[];
|
members: AssignableMember[];
|
||||||
|
statuses: IssueStatusDef[];
|
||||||
|
priorities: IssuePriorityDef[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
@@ -476,8 +491,8 @@ function FilterBar({
|
|||||||
multiple
|
multiple
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ minWidth: 180 }}
|
sx={{ minWidth: 180 }}
|
||||||
options={['niedrig', 'mittel', 'hoch']}
|
options={priorities.filter(p => p.aktiv).map(p => p.schluessel)}
|
||||||
getOptionLabel={(p) => PRIO_LABELS[p as Issue['prioritaet']] || p}
|
getOptionLabel={(key) => getPrioLabel(priorities, key)}
|
||||||
value={filters.prioritaet || []}
|
value={filters.prioritaet || []}
|
||||||
onChange={(_e, val) => onChange({ ...filters, prioritaet: val })}
|
onChange={(_e, val) => onChange({ ...filters, prioritaet: val })}
|
||||||
renderInput={(params) => <TextField {...params} label="Priorität" size="small" />}
|
renderInput={(params) => <TextField {...params} label="Priorität" size="small" />}
|
||||||
@@ -488,8 +503,8 @@ function FilterBar({
|
|||||||
multiple
|
multiple
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
options={['offen', 'in_bearbeitung', 'erledigt', 'abgelehnt']}
|
options={statuses.filter(s => s.aktiv).map(s => s.schluessel)}
|
||||||
getOptionLabel={(s) => STATUS_LABELS[s as Issue['status']] || s}
|
getOptionLabel={(key) => getStatusLabel(statuses, key)}
|
||||||
value={filters.status || []}
|
value={filters.status || []}
|
||||||
onChange={(_e, val) => onChange({ ...filters, status: val })}
|
onChange={(_e, val) => onChange({ ...filters, status: val })}
|
||||||
renderInput={(params) => <TextField {...params} label="Status" size="small" />}
|
renderInput={(params) => <TextField {...params} label="Status" size="small" />}
|
||||||
@@ -732,21 +747,48 @@ function IssueTypeAdmin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Issue Settings (Statusmeldungen + Kategorien) ──
|
// ── Issue Settings (Status + Prioritäten + Statusmeldungen + Kategorien) ──
|
||||||
|
|
||||||
|
const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'];
|
||||||
|
|
||||||
function IssueSettings() {
|
function IssueSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
// ── Statusmeldungen state ──
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createData, setCreateData] = useState<{ titel: string; inhalt: string; schwere: 'info' | 'warnung' | 'fehler' }>({ titel: '', inhalt: '', schwere: 'info' });
|
const [createData, setCreateData] = useState<{ titel: string; inhalt: string; schwere: 'info' | 'warnung' | 'fehler' }>({ titel: '', inhalt: '', schwere: 'info' });
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [editData, setEditData] = useState<Partial<IssueStatusmeldung>>({});
|
const [editData, setEditData] = useState<Partial<IssueStatusmeldung>>({});
|
||||||
|
|
||||||
|
// ── Status state ──
|
||||||
|
const [statusCreateOpen, setStatusCreateOpen] = useState(false);
|
||||||
|
const [statusCreateData, setStatusCreateData] = useState<Partial<IssueStatusDef>>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 });
|
||||||
|
const [statusEditId, setStatusEditId] = useState<number | null>(null);
|
||||||
|
const [statusEditData, setStatusEditData] = useState<Partial<IssueStatusDef>>({});
|
||||||
|
|
||||||
|
// ── Priority state ──
|
||||||
|
const [prioCreateOpen, setPrioCreateOpen] = useState(false);
|
||||||
|
const [prioCreateData, setPrioCreateData] = useState<Partial<IssuePriorityDef>>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 });
|
||||||
|
const [prioEditId, setPrioEditId] = useState<number | null>(null);
|
||||||
|
const [prioEditData, setPrioEditData] = useState<Partial<IssuePriorityDef>>({});
|
||||||
|
|
||||||
const { data: statusmeldungen = [], isLoading: smLoading } = useQuery({
|
const { data: statusmeldungen = [], isLoading: smLoading } = useQuery({
|
||||||
queryKey: ['issue-statusmeldungen'],
|
queryKey: ['issue-statusmeldungen'],
|
||||||
queryFn: issuesApi.getStatusmeldungen,
|
queryFn: issuesApi.getStatusmeldungen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: issueStatuses = [], isLoading: statusLoading } = useQuery({
|
||||||
|
queryKey: ['issue-statuses'],
|
||||||
|
queryFn: issuesApi.getStatuses,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: issuePriorities = [], isLoading: prioLoading } = useQuery({
|
||||||
|
queryKey: ['issue-priorities'],
|
||||||
|
queryFn: issuesApi.getPriorities,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Statusmeldungen mutations
|
||||||
const createSmMut = useMutation({
|
const createSmMut = useMutation({
|
||||||
mutationFn: (data: { titel: string; inhalt?: string; schwere?: string }) => issuesApi.createStatusmeldung(data),
|
mutationFn: (data: { titel: string; inhalt?: string; schwere?: string }) => issuesApi.createStatusmeldung(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -780,9 +822,173 @@ function IssueSettings() {
|
|||||||
const schwereColors: Record<string, 'info' | 'warning' | 'error'> = { info: 'info', warnung: 'warning', fehler: 'error' };
|
const schwereColors: Record<string, 'info' | 'warning' | 'error'> = { info: 'info', warnung: 'warning', fehler: 'error' };
|
||||||
const schwereLabels: Record<string, string> = { info: 'Info', warnung: 'Warnung', fehler: 'Fehler' };
|
const schwereLabels: Record<string, string> = { info: 'Info', warnung: 'Warnung', fehler: 'Fehler' };
|
||||||
|
|
||||||
|
// Status mutations
|
||||||
|
const createStatusMut = useMutation({
|
||||||
|
mutationFn: (data: Partial<IssueStatusDef>) => issuesApi.createStatus(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status erstellt'); setStatusCreateOpen(false); setStatusCreateData({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); },
|
||||||
|
onError: () => showError('Fehler beim Erstellen'),
|
||||||
|
});
|
||||||
|
const updateStatusMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<IssueStatusDef> }) => issuesApi.updateStatus(id, data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status aktualisiert'); setStatusEditId(null); },
|
||||||
|
onError: () => showError('Fehler beim Aktualisieren'),
|
||||||
|
});
|
||||||
|
const deleteStatusMut = useMutation({
|
||||||
|
mutationFn: (id: number) => issuesApi.deleteStatus(id),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
|
||||||
|
onError: () => showError('Fehler beim Deaktivieren'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Priority mutations
|
||||||
|
const createPrioMut = useMutation({
|
||||||
|
mutationFn: (data: Partial<IssuePriorityDef>) => issuesApi.createPriority(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität erstellt'); setPrioCreateOpen(false); setPrioCreateData({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); },
|
||||||
|
onError: () => showError('Fehler beim Erstellen'),
|
||||||
|
});
|
||||||
|
const updatePrioMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<IssuePriorityDef> }) => issuesApi.updatePriority(id, data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität aktualisiert'); setPrioEditId(null); },
|
||||||
|
onError: () => showError('Fehler beim Aktualisieren'),
|
||||||
|
});
|
||||||
|
const deletePrioMut = useMutation({
|
||||||
|
mutationFn: (id: number) => issuesApi.deletePriority(id),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
|
||||||
|
onError: () => showError('Fehler beim Deaktivieren'),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{/* Section 1: Statusmeldungen */}
|
|
||||||
|
{/* Section 1: Status */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Status</Typography>
|
||||||
|
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setStatusCreateOpen(true)}>Neuer Status</Button>
|
||||||
|
</Box>
|
||||||
|
{statusLoading ? <CircularProgress /> : (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Bezeichnung</TableCell>
|
||||||
|
<TableCell>Schlüssel</TableCell>
|
||||||
|
<TableCell>Farbe</TableCell>
|
||||||
|
<TableCell>Abschluss</TableCell>
|
||||||
|
<TableCell>Initial</TableCell>
|
||||||
|
<TableCell>Sort</TableCell>
|
||||||
|
<TableCell>Aktiv</TableCell>
|
||||||
|
<TableCell>Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{issueStatuses.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={8} sx={{ textAlign: 'center', color: 'text.secondary' }}>Keine Status</TableCell></TableRow>
|
||||||
|
) : issueStatuses.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
{statusEditId === s.id ? (
|
||||||
|
<>
|
||||||
|
<TableCell><TextField size="small" value={statusEditData.bezeichnung ?? s.bezeichnung} onChange={(e) => setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /></TableCell>
|
||||||
|
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select size="small" value={statusEditData.farbe ?? s.farbe} onChange={(e) => setStatusEditData({ ...statusEditData, farbe: e.target.value })}>
|
||||||
|
{MUI_CHIP_COLORS.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Switch checked={statusEditData.ist_abschluss ?? s.ist_abschluss} onChange={(e) => setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /></TableCell>
|
||||||
|
<TableCell><Switch checked={statusEditData.ist_initial ?? s.ist_initial} onChange={(e) => setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /></TableCell>
|
||||||
|
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={statusEditData.sort_order ?? s.sort_order} onChange={(e) => setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
|
||||||
|
<TableCell><Switch checked={statusEditData.aktiv ?? s.aktiv} onChange={(e) => setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => updateStatusMut.mutate({ id: s.id, data: statusEditData })}><CheckIcon /></IconButton>
|
||||||
|
<IconButton size="small" onClick={() => setStatusEditId(null)}><CloseIcon /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TableCell><Chip label={s.bezeichnung} color={s.farbe as any} size="small" /></TableCell>
|
||||||
|
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="body2">{s.farbe}</Typography></TableCell>
|
||||||
|
<TableCell>{s.ist_abschluss ? '✓' : '-'}</TableCell>
|
||||||
|
<TableCell>{s.ist_initial ? '✓' : '-'}</TableCell>
|
||||||
|
<TableCell>{s.sort_order}</TableCell>
|
||||||
|
<TableCell><Switch checked={s.aktiv} onChange={(e) => updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => { setStatusEditId(s.id); setStatusEditData({ bezeichnung: s.bezeichnung, farbe: s.farbe, ist_abschluss: s.ist_abschluss, ist_initial: s.ist_initial, benoetigt_typ_freigabe: s.benoetigt_typ_freigabe, sort_order: s.sort_order, aktiv: s.aktiv }); }}><EditIcon /></IconButton>
|
||||||
|
<IconButton size="small" onClick={() => deleteStatusMut.mutate(s.id)}><DeleteIcon /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 2: Prioritäten */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Prioritäten</Typography>
|
||||||
|
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setPrioCreateOpen(true)}>Neue Priorität</Button>
|
||||||
|
</Box>
|
||||||
|
{prioLoading ? <CircularProgress /> : (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Bezeichnung</TableCell>
|
||||||
|
<TableCell>Schlüssel</TableCell>
|
||||||
|
<TableCell>Farbe</TableCell>
|
||||||
|
<TableCell>Sort</TableCell>
|
||||||
|
<TableCell>Aktiv</TableCell>
|
||||||
|
<TableCell>Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{issuePriorities.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} sx={{ textAlign: 'center', color: 'text.secondary' }}>Keine Prioritäten</TableCell></TableRow>
|
||||||
|
) : issuePriorities.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
{prioEditId === p.id ? (
|
||||||
|
<>
|
||||||
|
<TableCell><TextField size="small" value={prioEditData.bezeichnung ?? p.bezeichnung} onChange={(e) => setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /></TableCell>
|
||||||
|
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
|
||||||
|
<TableCell><TextField size="small" value={prioEditData.farbe ?? p.farbe} onChange={(e) => setPrioEditData({ ...prioEditData, farbe: e.target.value })} placeholder="#hex" sx={{ width: 90 }} /></TableCell>
|
||||||
|
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={prioEditData.sort_order ?? p.sort_order} onChange={(e) => setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
|
||||||
|
<TableCell><Switch checked={prioEditData.aktiv ?? p.aktiv} onChange={(e) => setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => updatePrioMut.mutate({ id: p.id, data: prioEditData })}><CheckIcon /></IconButton>
|
||||||
|
<IconButton size="small" onClick={() => setPrioEditId(null)}><CloseIcon /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CircleIcon sx={{ fontSize: 12, color: p.farbe }} />
|
||||||
|
<Typography variant="body2">{p.bezeichnung}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="body2">{p.farbe}</Typography></TableCell>
|
||||||
|
<TableCell>{p.sort_order}</TableCell>
|
||||||
|
<TableCell><Switch checked={p.aktiv} onChange={(e) => updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}><EditIcon /></IconButton>
|
||||||
|
<IconButton size="small" onClick={() => deletePrioMut.mutate(p.id)}><DeleteIcon /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 3: Statusmeldungen */}
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<Typography variant="h6">Statusmeldungen</Typography>
|
<Typography variant="h6">Statusmeldungen</Typography>
|
||||||
@@ -851,11 +1057,49 @@ function IssueSettings() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 2: Kategorien */}
|
{/* Section 4: Kategorien */}
|
||||||
<Box>
|
<Box>
|
||||||
<IssueTypeAdmin />
|
<IssueTypeAdmin />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Create Status Dialog */}
|
||||||
|
<Dialog open={statusCreateOpen} onClose={() => setStatusCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neuer Status</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||||
|
<TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus />
|
||||||
|
<TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} />
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Farbe</InputLabel>
|
||||||
|
<Select label="Farbe" value={statusCreateData.farbe || 'default'} onChange={(e) => setStatusCreateData({ ...statusCreateData, farbe: e.target.value })}>
|
||||||
|
{MUI_CHIP_COLORS.map(c => <MenuItem key={c} value={c}><Chip label={c} color={c as any} size="small" /></MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="Sortierung" type="number" value={statusCreateData.sort_order ?? 0} onChange={(e) => setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||||
|
<FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" />
|
||||||
|
<FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" />
|
||||||
|
<FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setStatusCreateOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => createStatusMut.mutate(statusCreateData)} disabled={!statusCreateData.schluessel?.trim() || !statusCreateData.bezeichnung?.trim() || createStatusMut.isPending}>Erstellen</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Create Priority Dialog */}
|
||||||
|
<Dialog open={prioCreateOpen} onClose={() => setPrioCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neue Priorität</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||||
|
<TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus />
|
||||||
|
<TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} />
|
||||||
|
<TextField label="Farbe (Hex)" fullWidth value={prioCreateData.farbe || '#9e9e9e'} onChange={(e) => setPrioCreateData({ ...prioCreateData, farbe: e.target.value })} placeholder="#d32f2f" />
|
||||||
|
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setPrioCreateOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => createPrioMut.mutate(prioCreateData)} disabled={!prioCreateData.schluessel?.trim() || !prioCreateData.bezeichnung?.trim() || createPrioMut.isPending}>Erstellen</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Create Statusmeldung Dialog */}
|
{/* Create Statusmeldung Dialog */}
|
||||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Neue Statusmeldung</DialogTitle>
|
<DialogTitle>Neue Statusmeldung</DialogTitle>
|
||||||
@@ -938,19 +1182,32 @@ export default function Issues() {
|
|||||||
queryFn: issuesApi.getTypes,
|
queryFn: issuesApi.getTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: issueStatuses = [] } = useQuery({
|
||||||
|
queryKey: ['issue-statuses'],
|
||||||
|
queryFn: issuesApi.getStatuses,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: issuePriorities = [] } = useQuery({
|
||||||
|
queryKey: ['issue-priorities'],
|
||||||
|
queryFn: issuesApi.getPriorities,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: members = [] } = useQuery({
|
const { data: members = [] } = useQuery({
|
||||||
queryKey: ['issue-members'],
|
queryKey: ['issue-members'],
|
||||||
queryFn: issuesApi.getMembers,
|
queryFn: issuesApi.getMembers,
|
||||||
enabled: hasEdit,
|
enabled: hasEdit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default priority: first active, sorted by sort_order
|
||||||
|
const defaultPriority = issuePriorities.find(p => p.aktiv)?.schluessel ?? 'mittel';
|
||||||
|
|
||||||
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: '', prioritaet: 'mittel' });
|
setForm({ titel: '', prioritaet: defaultPriority });
|
||||||
},
|
},
|
||||||
onError: () => showError('Fehler beim Erstellen'),
|
onError: () => showError('Fehler beim Erstellen'),
|
||||||
});
|
});
|
||||||
@@ -960,7 +1217,10 @@ export default function Issues() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Filter logic for client-side tabs
|
// Filter logic for client-side tabs
|
||||||
const isDone = (i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt';
|
const isDone = (i: Issue) => {
|
||||||
|
const def = issueStatuses.find(s => s.schluessel === i.status);
|
||||||
|
return def?.ist_abschluss ?? (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 = showDoneMine ? myIssues : myIssues.filter((i: Issue) => !isDone(i));
|
const myIssuesFiltered = showDoneMine ? myIssues : myIssues.filter((i: Issue) => !isDone(i));
|
||||||
const assignedIssues = issues.filter((i: Issue) => i.zugewiesen_an === userId);
|
const assignedIssues = issues.filter((i: Issue) => i.zugewiesen_an === userId);
|
||||||
@@ -988,7 +1248,7 @@ export default function Issues() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||||
) : (
|
) : (
|
||||||
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
|
<IssueTable issues={myIssuesFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -1002,18 +1262,18 @@ export default function Issues() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||||
) : (
|
) : (
|
||||||
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
|
<IssueTable issues={assignedFiltered} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Tab 2: Alle Issues (conditional) */}
|
{/* Tab 2: Alle Issues (conditional) */}
|
||||||
{canViewAll && (
|
{canViewAll && (
|
||||||
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'all')}>
|
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'all')}>
|
||||||
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} />
|
<FilterBar filters={filters} onChange={setFilters} types={types} members={members} statuses={issueStatuses} priorities={issuePriorities} />
|
||||||
{isFilteredLoading ? (
|
{isFilteredLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||||
) : (
|
) : (
|
||||||
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} />
|
<IssueTable issues={filteredIssues} userId={userId} hasEdit={hasEdit} hasChangeStatus={hasChangeStatus} hasDelete={hasDeletePerm} members={members} statuses={issueStatuses} priorities={issuePriorities} />
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
@@ -1061,13 +1321,13 @@ export default function Issues() {
|
|||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Priorität</InputLabel>
|
<InputLabel>Priorität</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={form.prioritaet || 'mittel'}
|
value={form.prioritaet || defaultPriority}
|
||||||
label="Priorität"
|
label="Priorität"
|
||||||
onChange={(e) => setForm({ ...form, prioritaet: e.target.value as Issue['prioritaet'] })}
|
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="niedrig">Niedrig</MenuItem>
|
{issuePriorities.filter(p => p.aktiv).map(p => (
|
||||||
<MenuItem value="mittel">Mittel</MenuItem>
|
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
|
||||||
<MenuItem value="hoch">Hoch</MenuItem>
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusmeldung, IssueWidgetSummary } from '../types/issue.types';
|
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusmeldung, IssueStatusDef, IssuePriorityDef, IssueWidgetSummary } from '../types/issue.types';
|
||||||
|
|
||||||
export const issuesApi = {
|
export const issuesApi = {
|
||||||
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
|
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
|
||||||
@@ -78,4 +78,36 @@ export const issuesApi = {
|
|||||||
const r = await api.get('/api/issues/widget-summary');
|
const r = await api.get('/api/issues/widget-summary');
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
// Statuses CRUD
|
||||||
|
getStatuses: async (): Promise<IssueStatusDef[]> => {
|
||||||
|
const r = await api.get('/api/issues/statuses');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createStatus: async (data: Partial<IssueStatusDef>): Promise<IssueStatusDef> => {
|
||||||
|
const r = await api.post('/api/issues/statuses', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateStatus: async (id: number, data: Partial<IssueStatusDef>): Promise<IssueStatusDef> => {
|
||||||
|
const r = await api.patch(`/api/issues/statuses/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteStatus: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/issues/statuses/${id}`);
|
||||||
|
},
|
||||||
|
// Priorities CRUD
|
||||||
|
getPriorities: async (): Promise<IssuePriorityDef[]> => {
|
||||||
|
const r = await api.get('/api/issues/priorities');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createPriority: async (data: Partial<IssuePriorityDef>): Promise<IssuePriorityDef> => {
|
||||||
|
const r = await api.post('/api/issues/priorities', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updatePriority: async (id: number, data: Partial<IssuePriorityDef>): Promise<IssuePriorityDef> => {
|
||||||
|
const r = await api.patch(`/api/issues/priorities/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deletePriority: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/issues/priorities/${id}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export interface Issue {
|
|||||||
typ_icon: string | null;
|
typ_icon: string | null;
|
||||||
typ_farbe: string | null;
|
typ_farbe: string | null;
|
||||||
typ_erlaubt_abgelehnt: boolean;
|
typ_erlaubt_abgelehnt: boolean;
|
||||||
prioritaet: 'niedrig' | 'mittel' | 'hoch';
|
prioritaet: string;
|
||||||
status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
|
status: string;
|
||||||
erstellt_von: string;
|
erstellt_von: string;
|
||||||
erstellt_von_name?: string;
|
erstellt_von_name?: string;
|
||||||
zugewiesen_an: string | null;
|
zugewiesen_an: string | null;
|
||||||
@@ -42,15 +42,15 @@ export interface CreateIssuePayload {
|
|||||||
titel: string;
|
titel: string;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
typ_id?: number;
|
typ_id?: number;
|
||||||
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
|
prioritaet?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateIssuePayload {
|
export interface UpdateIssuePayload {
|
||||||
titel?: string;
|
titel?: string;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
typ_id?: number;
|
typ_id?: number;
|
||||||
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
|
prioritaet?: string;
|
||||||
status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
|
status?: string;
|
||||||
zugewiesen_an?: string | null;
|
zugewiesen_an?: string | null;
|
||||||
kommentar?: string;
|
kommentar?: string;
|
||||||
}
|
}
|
||||||
@@ -79,9 +79,33 @@ export interface IssueStatusmeldung {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssueWidgetSummary {
|
export interface IssueStatusDef {
|
||||||
offen: number;
|
id: number;
|
||||||
in_bearbeitung: number;
|
schluessel: string;
|
||||||
erledigt: number;
|
bezeichnung: string;
|
||||||
abgelehnt: number;
|
farbe: string;
|
||||||
|
ist_abschluss: boolean;
|
||||||
|
ist_initial: boolean;
|
||||||
|
benoetigt_typ_freigabe: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
aktiv: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IssuePriorityDef {
|
||||||
|
id: number;
|
||||||
|
schluessel: string;
|
||||||
|
bezeichnung: string;
|
||||||
|
farbe: string;
|
||||||
|
sort_order: number;
|
||||||
|
aktiv: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueStatusCount {
|
||||||
|
schluessel: string;
|
||||||
|
bezeichnung: string;
|
||||||
|
farbe: string;
|
||||||
|
ist_abschluss: boolean;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IssueWidgetSummary = IssueStatusCount[];
|
||||||
|
|||||||
Reference in New Issue
Block a user