fix permissions
This commit is contained in:
@@ -352,6 +352,79 @@ class IssueController {
|
|||||||
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWidgetSummary(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const counts = await issueService.getIssueCounts();
|
||||||
|
res.status(200).json({ success: true, data: counts });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getWidgetSummary error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Issue-Counts konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatusmeldungen(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const items = await issueService.getStatusmeldungen();
|
||||||
|
res.status(200).json({ success: true, data: items });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.getStatusmeldungen error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Statusmeldungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStatusmeldung(req: Request, res: Response): Promise<void> {
|
||||||
|
const { titel } = req.body;
|
||||||
|
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await issueService.createStatusmeldung(req.body, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.createStatusmeldung error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Statusmeldung konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatusmeldung(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.updateStatusmeldung(id, req.body);
|
||||||
|
if (!item) {
|
||||||
|
res.status(404).json({ success: false, message: 'Statusmeldung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: item });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.updateStatusmeldung error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Statusmeldung konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStatusmeldung(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 deleted = await issueService.deleteStatusmeldung(id);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ success: false, message: 'Statusmeldung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: 'Statusmeldung gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueController.deleteStatusmeldung error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Statusmeldung konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new IssueController();
|
export default new IssueController();
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration 057: issue_statusmeldungen table
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issue_statusmeldungen (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
titel VARCHAR(255) NOT NULL,
|
||||||
|
inhalt TEXT,
|
||||||
|
schwere VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
aktiv BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
erstellt_von VARCHAR(255) REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_issue_statusmeldungen_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_issue_statusmeldungen_updated_at
|
||||||
|
BEFORE UPDATE ON issue_statusmeldungen
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_issue_statusmeldungen_updated_at();
|
||||||
@@ -5,6 +5,42 @@ import { requirePermission } from '../middleware/rbac.middleware';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// --- Statusmeldungen routes (BEFORE /:id) ---
|
||||||
|
router.get(
|
||||||
|
'/statusmeldungen',
|
||||||
|
authenticate,
|
||||||
|
issueController.getStatusmeldungen.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/statusmeldungen',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('issues:edit_settings'),
|
||||||
|
issueController.createStatusmeldung.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/statusmeldungen/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('issues:edit_settings'),
|
||||||
|
issueController.updateStatusmeldung.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/statusmeldungen/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('issues:edit_settings'),
|
||||||
|
issueController.deleteStatusmeldung.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Widget summary route (BEFORE /:id) ---
|
||||||
|
router.get(
|
||||||
|
'/widget-summary',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('issues:view_all'),
|
||||||
|
issueController.getWidgetSummary.bind(issueController)
|
||||||
|
);
|
||||||
|
|
||||||
// --- Type management routes (BEFORE /:id to avoid conflict) ---
|
// --- Type management routes (BEFORE /:id to avoid conflict) ---
|
||||||
router.get(
|
router.get(
|
||||||
'/typen',
|
'/typen',
|
||||||
|
|||||||
@@ -361,6 +361,96 @@ async function getAssignableMembers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getIssueCounts() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT status, COUNT(*)::int AS count FROM issues GROUP BY status`
|
||||||
|
);
|
||||||
|
const counts: Record<string, number> = { offen: 0, in_bearbeitung: 0, erledigt: 0, abgelehnt: 0 };
|
||||||
|
for (const row of result.rows) {
|
||||||
|
counts[row.status] = row.count;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getIssueCounts failed', { error });
|
||||||
|
throw new Error('Issue-Counts konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatusmeldungen() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM issue_statusmeldungen WHERE aktiv = true ORDER BY created_at DESC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.getStatusmeldungen failed', { error });
|
||||||
|
throw new Error('Statusmeldungen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStatusmeldung(
|
||||||
|
data: { titel: string; inhalt?: string; schwere?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO issue_statusmeldungen (titel, inhalt, schwere, erstellt_von)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[data.titel, data.inhalt ?? null, data.schwere ?? 'info', userId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.createStatusmeldung failed', { error });
|
||||||
|
throw new Error('Statusmeldung konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatusmeldung(
|
||||||
|
id: number,
|
||||||
|
data: { titel?: string; inhalt?: string; schwere?: string; aktiv?: boolean }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (data.titel !== undefined) { setClauses.push(`titel = $${idx}`); values.push(data.titel); idx++; }
|
||||||
|
if ('inhalt' in data) { setClauses.push(`inhalt = $${idx}`); values.push(data.inhalt ?? null); idx++; }
|
||||||
|
if (data.schwere !== undefined) { setClauses.push(`schwere = $${idx}`); values.push(data.schwere); 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_statusmeldungen WHERE id = $1`, [id]);
|
||||||
|
return r.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE issue_statusmeldungen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.updateStatusmeldung failed', { error, id });
|
||||||
|
throw new Error('Statusmeldung konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStatusmeldung(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM issue_statusmeldungen WHERE id = $1 RETURNING id`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('IssueService.deleteStatusmeldung failed', { error, id });
|
||||||
|
throw new Error('Statusmeldung konnte nicht gelöscht werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getIssues,
|
getIssues,
|
||||||
getIssueById,
|
getIssueById,
|
||||||
@@ -374,5 +464,10 @@ export default {
|
|||||||
updateType,
|
updateType,
|
||||||
deactivateType,
|
deactivateType,
|
||||||
getAssignableMembers,
|
getAssignableMembers,
|
||||||
|
getIssueCounts,
|
||||||
|
getStatusmeldungen,
|
||||||
|
createStatusmeldung,
|
||||||
|
updateStatusmeldung,
|
||||||
|
deleteStatusmeldung,
|
||||||
UNASSIGN,
|
UNASSIGN,
|
||||||
};
|
};
|
||||||
|
|||||||
86
frontend/src/components/dashboard/IssueOverviewWidget.tsx
Normal file
86
frontend/src/components/dashboard/IssueOverviewWidget.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
|
||||||
|
import { BugReport } from '@mui/icons-material';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['issues-widget-summary'],
|
||||||
|
queryFn: issuesApi.getWidgetSummary,
|
||||||
|
refetchInterval: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Issues</Typography>
|
||||||
|
<Skeleton variant="rectangular" height={40} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Issues</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Issues konnten nicht geladen werden.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleChips = STATUS_CHIPS.filter((s) => data && data[s.key] > 0);
|
||||||
|
|
||||||
|
if (visibleChips.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Issues</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
|
||||||
|
<BugReport fontSize="small" />
|
||||||
|
<Typography variant="body2">Keine offenen Issues</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
|
<Typography variant="h6">Issues</Typography>
|
||||||
|
<BugReport fontSize="small" color="action" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{visibleChips.map((s) => (
|
||||||
|
<Chip
|
||||||
|
key={s.key}
|
||||||
|
label={`${data![s.key]} ${s.label}`}
|
||||||
|
color={s.color}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IssueOverviewWidget;
|
||||||
@@ -21,3 +21,4 @@ export { default as WidgetGroup } from './WidgetGroup';
|
|||||||
export { default as BestellungenWidget } from './BestellungenWidget';
|
export { default as BestellungenWidget } from './BestellungenWidget';
|
||||||
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
|
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
|
||||||
export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
|
export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
|
||||||
|
export { default as IssueOverviewWidget } from './IssueOverviewWidget';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const WIDGETS = [
|
|||||||
{ key: 'bestellungen', label: 'Bestellungen', defaultVisible: true },
|
{ key: 'bestellungen', label: 'Bestellungen', defaultVisible: true },
|
||||||
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
|
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
|
||||||
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
|
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
|
||||||
|
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetKey = typeof WIDGETS[number]['key'];
|
export type WidgetKey = typeof WIDGETS[number]['key'];
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import WidgetGroup from '../components/dashboard/WidgetGroup';
|
|||||||
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
||||||
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
|
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
|
||||||
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
||||||
|
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { configApi } from '../services/config';
|
import { configApi } from '../services/config';
|
||||||
import { WidgetKey } from '../constants/widgets';
|
import { WidgetKey } from '../constants/widgets';
|
||||||
@@ -220,6 +221,14 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasPermission('issues:view_all') && widgetVisible('issueOverview') && (
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '750ms' }}>
|
||||||
|
<Box>
|
||||||
|
<IssueOverviewWidget />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && (
|
{hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && (
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
|
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
|
||||||
BugReport, FiberNew, HelpOutline, Send as SendIcon,
|
BugReport, FiberNew, HelpOutline, Send as SendIcon,
|
||||||
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
|
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
|
||||||
DragIndicator,
|
DragIndicator, Check as CheckIcon, Close as CloseIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
@@ -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 } from '../types/issue.types';
|
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung } from '../types/issue.types';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -732,6 +732,156 @@ function IssueTypeAdmin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Issue Settings (Statusmeldungen + Kategorien) ──
|
||||||
|
|
||||||
|
function IssueSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createData, setCreateData] = useState<{ titel: string; inhalt: string; schwere: 'info' | 'warnung' | 'fehler' }>({ titel: '', inhalt: '', schwere: 'info' });
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [editData, setEditData] = useState<Partial<IssueStatusmeldung>>({});
|
||||||
|
|
||||||
|
const { data: statusmeldungen = [], isLoading: smLoading } = useQuery({
|
||||||
|
queryKey: ['issue-statusmeldungen'],
|
||||||
|
queryFn: issuesApi.getStatusmeldungen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSmMut = useMutation({
|
||||||
|
mutationFn: (data: { titel: string; inhalt?: string; schwere?: string }) => issuesApi.createStatusmeldung(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issue-statusmeldungen'] });
|
||||||
|
showSuccess('Statusmeldung erstellt');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateData({ titel: '', inhalt: '', schwere: 'info' });
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Erstellen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSmMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<IssueStatusmeldung> }) => issuesApi.updateStatusmeldung(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issue-statusmeldungen'] });
|
||||||
|
showSuccess('Statusmeldung aktualisiert');
|
||||||
|
setEditId(null);
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Aktualisieren'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSmMut = useMutation({
|
||||||
|
mutationFn: (id: number) => issuesApi.deleteStatusmeldung(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issue-statusmeldungen'] });
|
||||||
|
showSuccess('Statusmeldung gelöscht');
|
||||||
|
},
|
||||||
|
onError: () => showError('Fehler beim Löschen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schwereColors: Record<string, 'info' | 'warning' | 'error'> = { info: 'info', warnung: 'warning', fehler: 'error' };
|
||||||
|
const schwereLabels: Record<string, string> = { info: 'Info', warnung: 'Warnung', fehler: 'Fehler' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{/* Section 1: Statusmeldungen */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Statusmeldungen</Typography>
|
||||||
|
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setCreateOpen(true)}>
|
||||||
|
Neue Meldung
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{smLoading ? <CircularProgress /> : (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Titel</TableCell>
|
||||||
|
<TableCell>Schwere</TableCell>
|
||||||
|
<TableCell>Aktiv</TableCell>
|
||||||
|
<TableCell>Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{statusmeldungen.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ textAlign: 'center', color: 'text.secondary' }}>
|
||||||
|
Keine Statusmeldungen
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : statusmeldungen.map((sm) => (
|
||||||
|
<TableRow key={sm.id}>
|
||||||
|
{editId === sm.id ? (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<TextField size="small" value={editData.titel ?? sm.titel} onChange={(e) => setEditData({ ...editData, titel: e.target.value })} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select size="small" value={editData.schwere ?? sm.schwere} onChange={(e) => setEditData({ ...editData, schwere: e.target.value as 'info' | 'warnung' | 'fehler' })}>
|
||||||
|
<MenuItem value="info">Info</MenuItem>
|
||||||
|
<MenuItem value="warnung">Warnung</MenuItem>
|
||||||
|
<MenuItem value="fehler">Fehler</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch checked={editData.aktiv ?? sm.aktiv} onChange={(e) => setEditData({ ...editData, aktiv: e.target.checked })} size="small" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => updateSmMut.mutate({ id: sm.id, data: editData })}><CheckIcon /></IconButton>
|
||||||
|
<IconButton size="small" onClick={() => setEditId(null)}><CloseIcon /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TableCell>{sm.titel}</TableCell>
|
||||||
|
<TableCell><Chip label={schwereLabels[sm.schwere]} color={schwereColors[sm.schwere]} size="small" /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch checked={sm.aktiv} onChange={(e) => updateSmMut.mutate({ id: sm.id, data: { aktiv: e.target.checked } })} size="small" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => { setEditId(sm.id); setEditData({ titel: sm.titel, schwere: sm.schwere, aktiv: sm.aktiv, inhalt: sm.inhalt ?? '' }); }}><EditIcon /></IconButton>
|
||||||
|
<IconButton size="small" onClick={() => deleteSmMut.mutate(sm.id)}><DeleteIcon /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 2: Kategorien */}
|
||||||
|
<Box>
|
||||||
|
<IssueTypeAdmin />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Create Statusmeldung Dialog */}
|
||||||
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neue Statusmeldung</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||||
|
<TextField label="Titel" required fullWidth value={createData.titel} onChange={(e) => setCreateData({ ...createData, titel: e.target.value })} autoFocus />
|
||||||
|
<TextField label="Inhalt" fullWidth multiline rows={3} value={createData.inhalt} onChange={(e) => setCreateData({ ...createData, inhalt: e.target.value })} />
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Schwere</InputLabel>
|
||||||
|
<Select label="Schwere" value={createData.schwere} onChange={(e) => setCreateData({ ...createData, schwere: e.target.value as 'info' | 'warnung' | 'fehler' })}>
|
||||||
|
<MenuItem value="info">Info</MenuItem>
|
||||||
|
<MenuItem value="warnung">Warnung</MenuItem>
|
||||||
|
<MenuItem value="fehler">Fehler</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => createSmMut.mutate(createData)} disabled={!createData.titel.trim() || createSmMut.isPending}>
|
||||||
|
Erstellen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main Page ──
|
// ── Main Page ──
|
||||||
|
|
||||||
export default function Issues() {
|
export default function Issues() {
|
||||||
@@ -756,7 +906,7 @@ export default function Issues() {
|
|||||||
{ label: 'Zugewiesene Issues', key: 'assigned' },
|
{ label: 'Zugewiesene Issues', key: 'assigned' },
|
||||||
];
|
];
|
||||||
if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' });
|
if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' });
|
||||||
if (hasEditSettings) t.push({ label: 'Kategorien', key: 'types' });
|
if (hasEditSettings) t.push({ label: 'Einstellungen', key: 'settings' });
|
||||||
return t;
|
return t;
|
||||||
}, [canViewAll, hasEditSettings]);
|
}, [canViewAll, hasEditSettings]);
|
||||||
|
|
||||||
@@ -868,10 +1018,10 @@ export default function Issues() {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 3: Kategorien (conditional) */}
|
{/* Tab: Einstellungen (conditional) */}
|
||||||
{hasEditSettings && (
|
{hasEditSettings && (
|
||||||
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'types')}>
|
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'settings')}>
|
||||||
<IssueTypeAdmin />
|
<IssueSettings />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember } from '../types/issue.types';
|
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload, IssueTyp, IssueFilters, AssignableMember, IssueStatusmeldung, IssueWidgetSummary } from '../types/issue.types';
|
||||||
|
|
||||||
export const issuesApi = {
|
export const issuesApi = {
|
||||||
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
|
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
|
||||||
@@ -57,4 +57,25 @@ export const issuesApi = {
|
|||||||
const r = await api.get('/api/issues/members');
|
const r = await api.get('/api/issues/members');
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
// Statusmeldungen CRUD
|
||||||
|
getStatusmeldungen: async (): Promise<IssueStatusmeldung[]> => {
|
||||||
|
const r = await api.get('/api/issues/statusmeldungen');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createStatusmeldung: async (data: { titel: string; inhalt?: string; schwere?: string }): Promise<IssueStatusmeldung> => {
|
||||||
|
const r = await api.post('/api/issues/statusmeldungen', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateStatusmeldung: async (id: number, data: Partial<IssueStatusmeldung>): Promise<IssueStatusmeldung> => {
|
||||||
|
const r = await api.patch(`/api/issues/statusmeldungen/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteStatusmeldung: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/issues/statusmeldungen/${id}`);
|
||||||
|
},
|
||||||
|
// Widget summary
|
||||||
|
getWidgetSummary: async (): Promise<IssueWidgetSummary> => {
|
||||||
|
const r = await api.get('/api/issues/widget-summary');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,3 +67,21 @@ export interface AssignableMember {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IssueStatusmeldung {
|
||||||
|
id: number;
|
||||||
|
titel: string;
|
||||||
|
inhalt: string | null;
|
||||||
|
schwere: 'info' | 'warnung' | 'fehler';
|
||||||
|
aktiv: boolean;
|
||||||
|
erstellt_von: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueWidgetSummary {
|
||||||
|
offen: number;
|
||||||
|
in_bearbeitung: number;
|
||||||
|
erledigt: number;
|
||||||
|
abgelehnt: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user