fix permissions

This commit is contained in:
Matthias Hochmeister
2026-03-24 17:54:36 +01:00
parent e6ddf67d95
commit f228dd67ba
11 changed files with 521 additions and 7 deletions

View File

@@ -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();

View File

@@ -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();

View File

@@ -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',

View File

@@ -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,
}; };

View 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;

View File

@@ -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';

View File

@@ -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'];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
},
}; };

View File

@@ -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;
}