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

@@ -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 AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
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: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
] as const;
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 AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
import { preferencesApi } from '../services/settings';
import { configApi } from '../services/config';
import { WidgetKey } from '../constants/widgets';
@@ -220,6 +221,14 @@ function Dashboard() {
</Fade>
)}
{hasPermission('issues:view_all') && widgetVisible('issueOverview') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '750ms' }}>
<Box>
<IssueOverviewWidget />
</Box>
</Fade>
)}
{hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
<Box>

View File

@@ -10,7 +10,7 @@ import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon, Edit as EditIcon, Refresh as RefreshIcon,
DragIndicator,
DragIndicator, Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
@@ -20,7 +20,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember } from '../types/issue.types';
import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayload, IssueFilters, AssignableMember, IssueStatusmeldung } from '../types/issue.types';
// ── 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 ──
export default function Issues() {
@@ -756,7 +906,7 @@ export default function Issues() {
{ label: 'Zugewiesene Issues', key: 'assigned' },
];
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;
}, [canViewAll, hasEditSettings]);
@@ -868,10 +1018,10 @@ export default function Issues() {
</TabPanel>
)}
{/* Tab 3: Kategorien (conditional) */}
{/* Tab: Einstellungen (conditional) */}
{hasEditSettings && (
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'types')}>
<IssueTypeAdmin />
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'settings')}>
<IssueSettings />
</TabPanel>
)}
</Box>

View File

@@ -1,5 +1,5 @@
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 = {
getIssues: async (filters?: IssueFilters): Promise<Issue[]> => {
@@ -57,4 +57,25 @@ export const issuesApi = {
const r = await api.get('/api/issues/members');
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;
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;
}