new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:09:42 +01:00
parent e9a9478aac
commit 8c66492b27
40 changed files with 2016 additions and 117 deletions

View File

@@ -29,6 +29,7 @@ import Wissen from './pages/Wissen';
import Bestellungen from './pages/Bestellungen';
import BestellungDetail from './pages/BestellungDetail';
import Shop from './pages/Shop';
import Issues from './pages/Issues';
import AdminDashboard from './pages/AdminDashboard';
import AdminSettings from './pages/AdminSettings';
import NotFound from './pages/NotFound';
@@ -243,6 +244,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/issues"
element={
<ProtectedRoute>
<Issues />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import {
Box, Paper, Typography, Button, Autocomplete, TextField,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
CircularProgress,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext';
import type { UserOverview } from '../../types/admin.types';
export default function DebugTab() {
const { showSuccess, showError } = useNotification();
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
queryKey: ['admin', 'users'],
queryFn: adminApi.getUsers,
});
const [selectedUser, setSelectedUser] = useState<UserOverview | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!selectedUser) return;
setDeleting(true);
try {
await adminApi.deleteUserProfile(selectedUser.id);
showSuccess(`Profildaten fuer ${selectedUser.name || selectedUser.email} geloescht`);
setSelectedUser(null);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Fehler beim Loeschen';
showError(msg);
} finally {
setDeleting(false);
setConfirmOpen(false);
}
};
return (
<Box sx={{ maxWidth: 600 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Debug-Werkzeuge</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Werkzeuge fuer Fehlersuche und Datenbereinigung.
</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
Profildaten loeschen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Loescht die synchronisierten Profildaten (mitglieder_profile) eines Benutzers.
Beim naechsten Login werden die Daten erneut von Authentik und FDISK synchronisiert.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Autocomplete
options={users}
loading={usersLoading}
value={selectedUser}
onChange={(_e, v) => setSelectedUser(v)}
getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`}
isOptionEqualToValue={(a, b) => a.id === b.id}
sx={{ minWidth: 320, flex: 1 }}
renderInput={(params) => (
<TextField {...params} label="Benutzer waehlen" size="small" />
)}
/>
<Button
variant="contained"
color="error"
disabled={!selectedUser || deleting}
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
onClick={() => setConfirmOpen(true)}
>
Profildaten loeschen
</Button>
</Box>
</Paper>
<Dialog open={confirmOpen} onClose={() => !deleting && setConfirmOpen(false)}>
<DialogTitle>Profildaten loeschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Profildaten fuer <strong>{selectedUser?.name || selectedUser?.email}</strong> werden geloescht.
Beim naechsten Login werden die Daten erneut synchronisiert.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)} disabled={deleting}>Abbrechen</Button>
<Button
onClick={handleDelete}
color="error"
variant="contained"
disabled={deleting}
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
>
{deleting ? 'Wird geloescht...' : 'Loeschen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -27,6 +27,7 @@ import {
ExpandLess,
LocalShipping,
Store,
BugReport,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
@@ -65,6 +66,7 @@ const adminSubItems: SubItem[] = [
{ text: 'Berechtigungen', path: '/admin?tab=7' },
{ text: 'Bestellungen', path: '/admin?tab=8' },
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
{ text: 'Debug', path: '/admin?tab=10' },
];
const baseNavigationItems: NavigationItem[] = [
@@ -124,8 +126,24 @@ const baseNavigationItems: NavigationItem[] = [
text: 'Shop',
icon: <Store />,
path: '/shop',
subItems: [
{ text: 'Katalog', path: '/shop?tab=0' },
{ text: 'Meine Anfragen', path: '/shop?tab=1' },
{ text: 'Alle Anfragen', path: '/shop?tab=2' },
{ text: 'Übersicht', path: '/shop?tab=3' },
],
permission: 'shop:view',
},
{
text: 'Issues',
icon: <BugReport />,
path: '/issues',
subItems: [
{ text: 'Meine Issues', path: '/issues?tab=0' },
{ text: 'Alle Issues', path: '/issues?tab=1' },
],
permission: 'issues:create',
},
];
const adminItem: NavigationItem = {

View File

@@ -12,6 +12,7 @@ import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import BestellungenTab from '../components/admin/BestellungenTab';
import DataManagementTab from '../components/admin/DataManagementTab';
import DebugTab from '../components/admin/DebugTab';
import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps {
@@ -25,7 +26,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
const ADMIN_TAB_COUNT = 10;
const ADMIN_TAB_COUNT = 11;
function AdminDashboard() {
const navigate = useNavigate();
@@ -61,6 +62,7 @@ function AdminDashboard() {
<Tab label="Berechtigungen" />
<Tab label="Bestellungen" />
<Tab label="Datenverwaltung" />
<Tab label="Debug" />
</Tabs>
</Box>
@@ -94,6 +96,9 @@ function AdminDashboard() {
<TabPanel value={tab} index={9}>
<DataManagementTab />
</TabPanel>
<TabPanel value={tab} index={10}>
<DebugTab />
</TabPanel>
</DashboardLayout>
);
}

View File

@@ -61,6 +61,7 @@ import {
AusruestungStatusLabel,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
UpdateAusruestungWartungslogPayload,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
@@ -422,6 +423,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingEntry, setEditingEntry] = useState<AusruestungWartungslog | null>(null);
const emptyForm: CreateAusruestungWartungslogPayload = {
datum: '',
@@ -430,10 +432,33 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
ergebnis: undefined,
kosten: undefined,
pruefende_stelle: undefined,
naechste_pruefung_am: undefined,
};
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
const openAddDialog = () => {
setEditingEntry(null);
setForm(emptyForm);
setSaveError(null);
setDialogOpen(true);
};
const openEditDialog = (entry: AusruestungWartungslog) => {
setEditingEntry(entry);
setForm({
datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '',
art: entry.art,
beschreibung: entry.beschreibung,
ergebnis: entry.ergebnis ?? undefined,
kosten: entry.kosten ?? undefined,
pruefende_stelle: entry.pruefende_stelle ?? undefined,
naechste_pruefung_am: undefined,
});
setSaveError(null);
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!form.datum || !form.art || !form.beschreibung.trim()) {
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
@@ -442,14 +467,33 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
try {
setSaving(true);
setSaveError(null);
await equipmentApi.addWartungslog(equipmentId, {
...form,
datum: fromGermanDate(form.datum) || form.datum,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
});
const datumIso = fromGermanDate(form.datum) || form.datum;
const naechstePruefungIso = form.naechste_pruefung_am
? (fromGermanDate(form.naechste_pruefung_am) || form.naechste_pruefung_am)
: undefined;
if (editingEntry) {
const payload: UpdateAusruestungWartungslogPayload = {
datum: datumIso,
art: form.art,
beschreibung: form.beschreibung,
ergebnis: form.ergebnis || null,
pruefende_stelle: form.pruefende_stelle || null,
naechste_pruefung_am: naechstePruefungIso || null,
};
await equipmentApi.updateWartungslog(equipmentId, editingEntry.id, payload);
} else {
await equipmentApi.addWartungslog(equipmentId, {
...form,
datum: datumIso,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
naechste_pruefung_am: naechstePruefungIso,
});
}
setDialogOpen(false);
setForm(emptyForm);
setEditingEntry(null);
onAdded();
} catch {
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
@@ -463,6 +507,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
);
const showNaechstePruefung = form.ergebnis === 'bestanden';
return (
<Box>
{sorted.length === 0 ? (
@@ -496,13 +542,19 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`,
entry.pruefende_stelle && entry.pruefende_stelle,
].filter(Boolean).join(' · ')}
</Typography>
{entry.pruefende_stelle && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{entry.pruefende_stelle}
</Typography>
)}
</Box>
{canWrite && (
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(entry)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
);
})}
@@ -513,14 +565,14 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
<ChatAwareFab
size="small"
aria-label="Wartung eintragen"
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
onClick={openAddDialog}
>
<Add />
</ChatAwareFab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Prüfung eintragen</DialogTitle>
<DialogTitle>{editingEntry ? 'Wartungseintrag bearbeiten' : 'Wartung / Prüfung eintragen'}</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
@@ -585,21 +637,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Kosten (EUR)"
type="number"
fullWidth
value={form.kosten ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
kosten: e.target.value ? Number(e.target.value) : undefined,
}))
}
inputProps={{ min: 0, step: 0.01 }}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Prüfende Stelle"
fullWidth
@@ -608,6 +645,19 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
placeholder="Name der prüfenden Stelle oder Person"
/>
</Grid>
{showNaechstePruefung && (
<Grid item xs={12} sm={6}>
<TextField
label="Nächste Prüfung fällig am"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.naechste_pruefung_am ?? ''}
onChange={(e) => setForm((f) => ({ ...f, naechste_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
helperText="Wird als nächster Prüftermin übernommen"
/>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>

View File

@@ -124,7 +124,7 @@ export default function Bestellungen() {
setOrderForm({ ...emptyOrderForm });
navigate(`/bestellungen/${created.id}`);
},
onError: () => showError('Fehler beim Erstellen der Bestellung'),
onError: (error: any) => showError(error?.response?.data?.message || 'Fehler beim Erstellen der Bestellung'),
});
const createVendor = useMutation({

View File

@@ -65,8 +65,12 @@ import {
FahrzeugStatus,
FahrzeugStatusLabel,
CreateWartungslogPayload,
UpdateWartungslogPayload,
UpdateStatusPayload,
WartungslogArt,
WartungslogErgebnis,
WartungslogErgebnisLabel,
WartungslogErgebnisColor,
OverlappingBooking,
} from '../types/vehicle.types';
import type { AusruestungListItem } from '../types/equipment.types';
@@ -470,6 +474,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingWartungId, setEditingWartungId] = useState<string | null>(null);
const emptyForm: CreateWartungslogPayload = {
datum: '',
@@ -479,10 +484,36 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
kraftstoff_liter: undefined,
kosten: undefined,
externe_werkstatt: '',
ergebnis: undefined,
naechste_faelligkeit: '',
};
const [form, setForm] = useState<CreateWartungslogPayload>(emptyForm);
const openCreateDialog = () => {
setEditingWartungId(null);
setForm(emptyForm);
setSaveError(null);
setDialogOpen(true);
};
const openEditDialog = (entry: FahrzeugWartungslog) => {
setEditingWartungId(entry.id);
setForm({
datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '',
art: entry.art ?? undefined,
beschreibung: entry.beschreibung,
km_stand: entry.km_stand ?? undefined,
kraftstoff_liter: entry.kraftstoff_liter ?? undefined,
kosten: entry.kosten ?? undefined,
externe_werkstatt: entry.externe_werkstatt ?? '',
ergebnis: entry.ergebnis ?? undefined,
naechste_faelligkeit: entry.naechste_faelligkeit ? entry.naechste_faelligkeit.slice(0, 10) : '',
});
setSaveError(null);
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!form.datum || !form.beschreibung.trim()) {
setSaveError('Datum und Beschreibung sind erforderlich.');
@@ -491,13 +522,29 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
try {
setSaving(true);
setSaveError(null);
await vehiclesApi.addWartungslog(fahrzeugId, {
...form,
datum: fromGermanDate(form.datum) || form.datum,
externe_werkstatt: form.externe_werkstatt || undefined,
});
const isoDate = fromGermanDate(form.datum) || form.datum;
if (editingWartungId) {
const payload: UpdateWartungslogPayload = {
datum: isoDate,
art: form.art,
beschreibung: form.beschreibung,
km_stand: form.km_stand,
externe_werkstatt: form.externe_werkstatt || undefined,
ergebnis: form.ergebnis,
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
};
await vehiclesApi.updateWartungslog(fahrzeugId, editingWartungId, payload);
} else {
await vehiclesApi.addWartungslog(fahrzeugId, {
...form,
datum: isoDate,
externe_werkstatt: form.externe_werkstatt || undefined,
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
});
}
setDialogOpen(false);
setForm(emptyForm);
setEditingWartungId(null);
onAdded();
} catch {
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
@@ -521,14 +568,20 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
{entry.ergebnis && (
<Chip
label={WartungslogErgebnisLabel[entry.ergebnis]}
size="small"
color={WartungslogErgebnisColor[entry.ergebnis]}
/>
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
entry.kraftstoff_liter != null && `${Number(entry.kraftstoff_liter).toFixed(1)} L`,
entry.kosten != null && `${Number(entry.kosten).toFixed(2)}`,
entry.externe_werkstatt && entry.externe_werkstatt,
entry.naechste_faelligkeit && `Nächste Fälligkeit: ${fmtDate(entry.naechste_faelligkeit)}`,
].filter(Boolean).join(' · ')}
</Typography>
{entry.dokument_url ? (
@@ -568,6 +621,11 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
</Button>
) : null}
</Box>
{canWrite && (
<IconButton size="small" onClick={() => openEditDialog(entry)} aria-label="Bearbeiten">
<Edit fontSize="small" />
</IconButton>
)}
</Box>
);
})}
@@ -578,14 +636,14 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<ChatAwareFab
size="small"
aria-label="Wartung eintragen"
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
onClick={openCreateDialog}
>
<Add />
</ChatAwareFab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Service eintragen</DialogTitle>
<DialogTitle>{editingWartungId ? 'Wartungseintrag bearbeiten' : 'Wartung / Service eintragen'}</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
@@ -624,7 +682,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={4}>
<Grid item xs={12} sm={6}>
<TextField
label="km-Stand"
type="number"
@@ -634,27 +692,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
inputProps={{ min: 0 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kraftstoff (L)"
type="number"
fullWidth
value={form.kraftstoff_liter ?? ''}
onChange={(e) => setForm((f) => ({ ...f, kraftstoff_liter: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0, step: 0.1 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kosten (€)"
type="number"
fullWidth
value={form.kosten ?? ''}
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0, step: 0.01 }}
/>
</Grid>
<Grid item xs={12}>
<Grid item xs={12} sm={6}>
<TextField
label="Externe Werkstatt"
fullWidth
@@ -663,6 +701,31 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
placeholder="Name der Werkstatt (wenn extern)"
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? ''}
onChange={(e) => setForm((f) => ({ ...f, ergebnis: (e.target.value || undefined) as WartungslogErgebnis | undefined }))}
>
<MenuItem value=""> Kein Ergebnis </MenuItem>
<MenuItem value="bestanden">Bestanden</MenuItem>
<MenuItem value="bestanden_mit_maengeln">Bestanden mit Mängeln</MenuItem>
<MenuItem value="nicht_bestanden">Nicht bestanden</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Nächste Fälligkeit"
type="date"
fullWidth
value={form.naechste_faelligkeit ?? ''}
onChange={(e) => setForm((f) => ({ ...f, naechste_faelligkeit: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>

View File

@@ -0,0 +1,471 @@
import { useState } from 'react';
import {
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, Collapse, Divider, CircularProgress,
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess,
BugReport, FiberNew, HelpOutline, Send as SendIcon,
Circle as CircleIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
// ── Helpers ──
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
const STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
offen: 'info',
in_bearbeitung: 'warning',
erledigt: 'success',
abgelehnt: 'error',
};
const STATUS_LABELS: Record<Issue['status'], string> = {
offen: 'Offen',
in_bearbeitung: 'In Bearbeitung',
erledigt: 'Erledigt',
abgelehnt: 'Abgelehnt',
};
const TYP_ICONS: Record<Issue['typ'], JSX.Element> = {
bug: <BugReport fontSize="small" color="error" />,
feature: <FiberNew fontSize="small" color="info" />,
sonstiges: <HelpOutline fontSize="small" color="action" />,
};
const TYP_LABELS: Record<Issue['typ'], string> = {
bug: 'Bug',
feature: 'Feature',
sonstiges: 'Sonstiges',
};
const PRIO_COLORS: Record<Issue['prioritaet'], string> = {
hoch: '#d32f2f',
mittel: '#ed6c02',
niedrig: '#9e9e9e',
};
const PRIO_LABELS: Record<Issue['prioritaet'], string> = {
hoch: 'Hoch',
mittel: 'Mittel',
niedrig: 'Niedrig',
};
// ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
function TabPanel({ children, value, index }: TabPanelProps) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ── Comment Section ──
function CommentSection({ issueId }: { issueId: number }) {
const queryClient = useQueryClient();
const { showError } = useNotification();
const [text, setText] = useState('');
const { data: comments = [], isLoading } = useQuery({
queryKey: ['issues', issueId, 'comments'],
queryFn: () => issuesApi.getComments(issueId),
});
const addMut = useMutation({
mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] });
setText('');
},
onError: () => showError('Kommentar konnte nicht erstellt werden'),
});
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Kommentare</Typography>
{isLoading ? (
<CircularProgress size={20} />
) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary">Noch keine Kommentare</Typography>
) : (
comments.map((c: IssueComment) => (
<Box key={c.id} sx={{ mb: 1.5, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
{c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-wrap' }}>{c.inhalt}</Typography>
</Box>
))
)}
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Kommentar schreiben..."
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && text.trim()) {
e.preventDefault();
addMut.mutate(text.trim());
}
}}
multiline
maxRows={4}
/>
<IconButton
color="primary"
disabled={!text.trim() || addMut.isPending}
onClick={() => addMut.mutate(text.trim())}
>
<SendIcon />
</IconButton>
</Box>
</Box>
);
}
// ── Issue Row ──
function IssueRow({
issue,
canManage,
isOwner,
onDelete,
}: {
issue: Issue;
canManage: boolean;
isOwner: boolean;
onDelete: (id: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const updateMut = useMutation({
mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
return (
<>
<TableRow
hover
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
onClick={() => setExpanded(!expanded)}
>
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{TYP_ICONS[issue.typ]}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={TYP_LABELS[issue.typ]} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: PRIO_COLORS[issue.prioritaet] }} />
<Typography variant="body2">{PRIO_LABELS[issue.prioritaet]}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={STATUS_LABELS[issue.status]}
size="small"
color={STATUS_COLORS[issue.status]}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
<TableCell>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}>
{expanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={8} sx={{ py: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2 }}>
{issue.beschreibung && (
<Typography variant="body2" sx={{ mb: 2, whiteSpace: 'pre-wrap' }}>
{issue.beschreibung}
</Typography>
)}
{issue.zugewiesen_an_name && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Zugewiesen an: {issue.zugewiesen_an_name}
</Typography>
)}
{canManage && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
value={issue.status}
label="Status"
onChange={(e) => updateMut.mutate({ status: e.target.value as Issue['status'] })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="offen">Offen</MenuItem>
<MenuItem value="in_bearbeitung">In Bearbeitung</MenuItem>
<MenuItem value="erledigt">Erledigt</MenuItem>
<MenuItem value="abgelehnt">Abgelehnt</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Priorität</InputLabel>
<Select
value={issue.prioritaet}
label="Priorität"
onChange={(e) => updateMut.mutate({ prioritaet: e.target.value as Issue['prioritaet'] })}
onClick={(e) => e.stopPropagation()}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</MenuItem>
</Select>
</FormControl>
</Box>
)}
{(canManage || isOwner) && (
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={(e) => { e.stopPropagation(); onDelete(issue.id); }}
sx={{ mb: 1 }}
>
Löschen
</Button>
)}
<Divider sx={{ my: 1 }} />
<CommentSection issueId={issue.id} />
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
}
// ── Issue Table ──
function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const deleteMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteIssue(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue gelöscht');
},
onError: () => showError('Fehler beim Löschen'),
});
if (issues.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Keine Issues vorhanden
</Typography>
);
}
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Titel</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Priorität</TableCell>
<TableCell>Status</TableCell>
<TableCell>Erstellt von</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{issues.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
canManage={canManage}
isOwner={issue.erstellt_von === userId}
onDelete={(id) => deleteMut.mutate(id)}
/>
))}
</TableBody>
</Table>
</TableContainer>
);
}
// ── Main Page ──
export default function Issues() {
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
const tab = isNaN(tabParam) || tabParam < 0 || tabParam > 1 ? 0 : tabParam;
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const canViewAll = hasPermission('issues:view_all');
const canManage = hasPermission('issues:manage');
const canCreate = hasPermission('issues:create');
const userId = user?.id || '';
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreateIssuePayload>({ titel: '', typ: 'bug', prioritaet: 'mittel' });
const { data: issues = [], isLoading } = useQuery({
queryKey: ['issues'],
queryFn: () => issuesApi.getIssues(),
});
const createMut = useMutation({
mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues'] });
showSuccess('Issue erstellt');
setCreateOpen(false);
setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' });
},
onError: () => showError('Fehler beim Erstellen'),
});
const handleTabChange = (_: unknown, newValue: number) => {
setSearchParams({ tab: String(newValue) });
};
const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId);
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>Issues</Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
<Tab label="Meine Issues" />
{canViewAll && <Tab label="Alle Issues" />}
</Tabs>
<TabPanel value={tab} index={0}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<IssueTable issues={myIssues} canManage={canManage} userId={userId} />
)}
</TabPanel>
{canViewAll && (
<TabPanel value={tab} index={1}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<IssueTable issues={issues} canManage={canManage} userId={userId} />
)}
</TabPanel>
)}
</Box>
{/* Create Issue Dialog */}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neues Issue erstellen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Titel"
required
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
/>
<TextField
label="Beschreibung"
multiline
rows={4}
fullWidth
value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ || 'bug'}
label="Typ"
onChange={(e) => setForm({ ...form, typ: e.target.value as Issue['typ'] })}
>
<MenuItem value="bug">Bug</MenuItem>
<MenuItem value="feature">Feature</MenuItem>
<MenuItem value="sonstiges">Sonstiges</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priorität</InputLabel>
<Select
value={form.prioritaet || 'mittel'}
label="Priorität"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value as Issue['prioritaet'] })}
>
<MenuItem value="niedrig">Niedrig</MenuItem>
<MenuItem value="mittel">Mittel</MenuItem>
<MenuItem value="hoch">Hoch</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
{/* FAB */}
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Neues Issue"
onClick={() => setCreateOpen(true)}
>
<AddIcon />
</ChatAwareFab>
)}
</DashboardLayout>
);
}

View File

@@ -1339,11 +1339,20 @@ function VeranstaltungFormDialog({
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
anmeldung_bis: null,
});
setWiederholungAktiv(false);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
// Populate recurrence fields if parent event has config (read-only display)
if (editingEvent.wiederholung) {
setWiederholungAktiv(true);
setWiederholungTyp(editingEvent.wiederholung.typ);
setWiederholungIntervall(editingEvent.wiederholung.intervall ?? 1);
setWiederholungBis(editingEvent.wiederholung.bis ?? '');
setWiederholungWochentag(editingEvent.wiederholung.wochentag ?? 0);
} else {
setWiederholungAktiv(false);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
}
} else {
const now = new Date();
now.setMinutes(0, 0, 0);
@@ -1358,6 +1367,29 @@ function VeranstaltungFormDialog({
}
}, [open, editingEvent]);
// Auto-correct: end date should never be before start date
useEffect(() => {
const von = new Date(form.datum_von);
const bis = new Date(form.datum_bis);
if (!isNaN(von.getTime()) && !isNaN(bis.getTime()) && bis < von) {
// Set datum_bis to datum_von (preserve time offset for non-ganztaegig)
if (form.ganztaegig) {
handleChange('datum_bis', von.toISOString());
} else {
const adjusted = new Date(von);
adjusted.setHours(adjusted.getHours() + 2);
handleChange('datum_bis', adjusted.toISOString());
}
}
// Also auto-correct wiederholungBis
if (wiederholungBis) {
const vonDateOnly = form.datum_von.slice(0, 10);
if (wiederholungBis < vonDateOnly) {
setWiederholungBis(vonDateOnly);
}
}
}, [form.datum_von]); // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
if (field === 'kategorie_id' && !editingEvent) {
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
@@ -1600,22 +1632,34 @@ function VeranstaltungFormDialog({
fullWidth
/>
)}
{/* Wiederholung (only for new events) */}
{!editingEvent && (
{/* Wiederholung */}
{(!editingEvent || (editingEvent && editingEvent.wiederholung)) && (
<>
<Divider />
<FormControlLabel
control={
<Switch
checked={wiederholungAktiv}
onChange={(e) => setWiederholungAktiv(e.target.checked)}
{editingEvent && editingEvent.wiederholung ? (
<>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Wiederholung kann nicht bearbeitet werden
</Typography>
<FormControlLabel
control={<Switch checked disabled />}
label="Wiederkehrende Veranstaltung"
/>
}
label="Wiederkehrende Veranstaltung"
/>
</>
) : (
<FormControlLabel
control={
<Switch
checked={wiederholungAktiv}
onChange={(e) => setWiederholungAktiv(e.target.checked)}
/>
}
label="Wiederkehrende Veranstaltung"
/>
)}
{wiederholungAktiv && (
<Stack spacing={2}>
<FormControl fullWidth size="small">
<FormControl fullWidth size="small" disabled={!!editingEvent}>
<InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel>
<Select
labelId="wiederholung-typ-label"
@@ -1640,11 +1684,12 @@ function VeranstaltungFormDialog({
onChange={(e) => setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))}
inputProps={{ min: 1, max: 52 }}
fullWidth
disabled={!!editingEvent}
/>
)}
{(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && (
<FormControl fullWidth size="small">
<FormControl fullWidth size="small" disabled={!!editingEvent}>
<InputLabel id="wiederholung-wochentag-label">Wochentag</InputLabel>
<Select
labelId="wiederholung-wochentag-label"
@@ -1667,6 +1712,7 @@ function VeranstaltungFormDialog({
onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
disabled={!!editingEvent}
helperText="Letztes Datum für Wiederholungen"
/>
</Stack>

View File

@@ -20,9 +20,18 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { shopApi } from '../services/shop';
import { bestellungApi } from '../services/bestellung';
import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types';
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus } from '../types/shop.types';
import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus, ShopAnfrage, ShopOverview } from '../types/shop.types';
import type { Bestellung } from '../types/bestellung.types';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatOrderId(r: ShopAnfrage): string {
if (r.bestell_jahr && r.bestell_nummer) {
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
}
return `#${r.id}`;
}
// ─── Catalog Tab ────────────────────────────────────────────────────────────
interface DraftItem {
@@ -291,7 +300,7 @@ function MeineAnfragenTab() {
<TableHead>
<TableRow>
<TableCell width={40} />
<TableCell>#</TableCell>
<TableCell>Anfrage</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</TableCell>
<TableCell>Erstellt am</TableCell>
@@ -303,9 +312,9 @@ function MeineAnfragenTab() {
<>
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedId(prev => prev === r.id ? null : r.id)}>
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
<TableCell>{r.id}</TableCell>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell><Chip label={SHOP_STATUS_LABELS[r.status]} color={SHOP_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.items_count ?? '-'}</TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
<TableCell>{r.admin_notizen || '-'}</TableCell>
</TableRow>
@@ -424,7 +433,7 @@ function AlleAnfragenTab() {
<TableHead>
<TableRow>
<TableCell width={40} />
<TableCell>#</TableCell>
<TableCell>Anfrage</TableCell>
<TableCell>Anfrager</TableCell>
<TableCell>Status</TableCell>
<TableCell>Positionen</TableCell>
@@ -437,10 +446,10 @@ function AlleAnfragenTab() {
<>
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setExpandedId(prev => prev === r.id ? null : r.id)}>
<TableCell>{expandedId === r.id ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}</TableCell>
<TableCell>{r.id}</TableCell>
<TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.anfrager_name || r.anfrager_id}</TableCell>
<TableCell><Chip label={SHOP_STATUS_LABELS[r.status]} color={SHOP_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.items_count ?? '-'}</TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell>{new Date(r.erstellt_am).toLocaleDateString('de-AT')}</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
@@ -558,6 +567,68 @@ function AlleAnfragenTab() {
);
}
// ─── Overview Tab ────────────────────────────────────────────────────────────
function UebersichtTab() {
const { data: overview, isLoading } = useQuery<ShopOverview>({
queryKey: ['shop', 'overview'],
queryFn: () => shopApi.getOverview(),
});
if (isLoading) return <Typography color="text.secondary">Lade Übersicht...</Typography>;
if (!overview) return <Typography color="text.secondary">Keine Daten verfügbar.</Typography>;
return (
<Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.pending_count}</Typography>
<Typography variant="body2" color="text.secondary">Offene Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.approved_count}</Typography>
<Typography variant="body2" color="text.secondary">Genehmigte Anfragen</Typography>
</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="h4" fontWeight={700}>{overview.total_items}</Typography>
<Typography variant="body2" color="text.secondary">Artikel insgesamt</Typography>
</Paper>
</Grid>
</Grid>
{overview.items.length === 0 ? (
<Typography color="text.secondary">Keine offenen/genehmigten Anfragen vorhanden.</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right">Gesamtmenge</TableCell>
<TableCell align="right">Anfragen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{overview.items.map(item => (
<TableRow key={item.bezeichnung}>
<TableCell>{item.bezeichnung}</TableCell>
<TableCell align="right">{item.total_menge}</TableCell>
<TableCell align="right">{item.anfrage_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function Shop() {
@@ -567,8 +638,9 @@ export default function Shop() {
const canView = hasPermission('shop:view');
const canCreate = hasPermission('shop:create_request');
const canApprove = hasPermission('shop:approve_requests');
const canViewOverview = hasPermission('shop:view_overview');
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0);
const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0);
const [activeTab, setActiveTab] = useState(() => {
const t = Number(searchParams.get('tab'));
@@ -584,9 +656,10 @@ export default function Shop() {
const map: Record<string, number> = { katalog: 0 };
let next = 1;
if (canCreate) { map.meine = next; next++; }
if (canApprove) { map.alle = next; }
if (canApprove) { map.alle = next; next++; }
if (canViewOverview) { map.uebersicht = next; }
return map;
}, [canCreate, canApprove]);
}, [canCreate, canApprove, canViewOverview]);
if (!canView) {
return (
@@ -605,12 +678,14 @@ export default function Shop() {
<Tab label="Katalog" />
{canCreate && <Tab label="Meine Anfragen" />}
{canApprove && <Tab label="Alle Anfragen" />}
{canViewOverview && <Tab label="Übersicht" />}
</Tabs>
</Box>
{activeTab === tabIndex.katalog && <KatalogTab />}
{canCreate && activeTab === tabIndex.meine && <MeineAnfragenTab />}
{canApprove && activeTab === tabIndex.alle && <AlleAnfragenTab />}
{canViewOverview && activeTab === tabIndex.uebersicht && <UebersichtTab />}
</DashboardLayout>
);
}

View File

@@ -30,4 +30,5 @@ export const adminApi = {
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
deleteUserProfile: (userId: string) => api.delete<ApiResponse<{ message: string }>>(`/api/admin/debug/user/${userId}/profile`).then(r => r.data),
};

View File

@@ -10,6 +10,7 @@ import type {
UpdateAusruestungPayload,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
UpdateAusruestungWartungslogPayload,
} from '../types/equipment.types';
async function unwrap<T>(
@@ -121,4 +122,19 @@ export const equipmentApi = {
);
return response.data.data ?? [];
},
async updateWartungslog(
equipmentId: string,
wartungId: string,
payload: UpdateAusruestungWartungslogPayload
): Promise<AusruestungWartungslog> {
const response = await api.patch<{ success: boolean; data: AusruestungWartungslog }>(
`/api/equipment/${equipmentId}/wartung/${wartungId}`,
payload
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
};

View File

@@ -0,0 +1,32 @@
import { api } from './api';
import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types';
export const issuesApi = {
getIssues: async (): Promise<Issue[]> => {
const r = await api.get('/api/issues');
return r.data.data;
},
getIssue: async (id: number): Promise<Issue> => {
const r = await api.get(`/api/issues/${id}`);
return r.data.data;
},
createIssue: async (data: CreateIssuePayload): Promise<Issue> => {
const r = await api.post('/api/issues', data);
return r.data.data;
},
updateIssue: async (id: number, data: UpdateIssuePayload): Promise<Issue> => {
const r = await api.patch(`/api/issues/${id}`, data);
return r.data.data;
},
deleteIssue: async (id: number): Promise<void> => {
await api.delete(`/api/issues/${id}`);
},
getComments: async (issueId: number): Promise<IssueComment[]> => {
const r = await api.get(`/api/issues/${issueId}/comments`);
return r.data.data;
},
addComment: async (issueId: number, inhalt: string): Promise<IssueComment> => {
const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt });
return r.data.data;
},
};

View File

@@ -5,6 +5,7 @@ import type {
ShopAnfrage,
ShopAnfrageDetailResponse,
ShopAnfrageFormItem,
ShopOverview,
} from '../types/shop.types';
export const shopApi = {
@@ -70,4 +71,10 @@ export const shopApi = {
unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
await api.delete(`/api/shop/requests/${anfrageId}/link/${bestellungId}`);
},
// ── Overview ──
getOverview: async (): Promise<ShopOverview> => {
const r = await api.get('/api/shop/overview');
return r.data.data;
},
};

View File

@@ -9,6 +9,7 @@ import type {
UpdateFahrzeugPayload,
UpdateStatusPayload,
CreateWartungslogPayload,
UpdateWartungslogPayload,
StatusUpdateResponse,
} from '../types/vehicle.types';
@@ -94,6 +95,17 @@ export const vehiclesApi = {
return response.data.data;
},
async updateWartungslog(vehicleId: string, wartungId: string, payload: UpdateWartungslogPayload): Promise<FahrzeugWartungslog> {
const response = await api.patch<{ success: boolean; data: FahrzeugWartungslog }>(
`/api/vehicles/${vehicleId}/wartung/${wartungId}`,
payload
);
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
},
async exportAlerts(): Promise<Blob> {
const response = await api.get('/api/vehicles/alerts/export', {
responseType: 'blob',

View File

@@ -128,11 +128,22 @@ export interface UpdateAusruestungStatusPayload {
}
export interface CreateAusruestungWartungslogPayload {
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
naechste_pruefung_am?: string;
}
export interface UpdateAusruestungWartungslogPayload {
datum?: string;
art?: AusruestungWartungslogArt;
beschreibung?: string;
ergebnis?: string | null;
kosten?: number | null;
pruefende_stelle?: string | null;
naechste_pruefung_am?: string | null;
}

View File

@@ -0,0 +1,39 @@
export interface Issue {
id: number;
titel: string;
beschreibung: string | null;
typ: 'bug' | 'feature' | 'sonstiges';
prioritaet: 'niedrig' | 'mittel' | 'hoch';
status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
erstellt_von: string;
erstellt_von_name?: string;
zugewiesen_an: string | null;
zugewiesen_an_name?: string | null;
created_at: string;
updated_at: string;
}
export interface IssueComment {
id: number;
issue_id: number;
autor_id: string;
autor_name?: string;
inhalt: string;
created_at: string;
}
export interface CreateIssuePayload {
titel: string;
beschreibung?: string;
typ?: 'bug' | 'feature' | 'sonstiges';
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
}
export interface UpdateIssuePayload {
titel?: string;
beschreibung?: string;
typ?: 'bug' | 'feature' | 'sonstiges';
prioritaet?: 'niedrig' | 'mittel' | 'hoch';
status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt';
zugewiesen_an?: string | null;
}

View File

@@ -52,8 +52,11 @@ export interface ShopAnfrage {
admin_notizen?: string;
bearbeitet_von?: string;
bearbeitet_von_name?: string;
bestell_nummer?: number;
bestell_jahr?: number;
erstellt_am: string;
aktualisiert_am: string;
positionen_count?: number;
items_count?: number;
}
@@ -81,3 +84,18 @@ export interface ShopAnfrageDetailResponse {
positionen: ShopAnfragePosition[];
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
}
// ── Overview ──
export interface ShopOverviewItem {
bezeichnung: string;
total_menge: number;
anfrage_count: number;
}
export interface ShopOverview {
items: ShopOverviewItem[];
pending_count: number;
approved_count: number;
total_items: number;
}

View File

@@ -48,6 +48,23 @@ export interface FahrzeugListItem {
aktiver_lehrgang: AktiverLehrgang | null;
}
export type WartungslogErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden';
export const WartungslogErgebnisLabel: Record<WartungslogErgebnis, string> = {
bestanden: 'Bestanden',
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
nicht_bestanden: 'Nicht bestanden',
};
export const WartungslogErgebnisColor: Record<WartungslogErgebnis, 'success' | 'warning' | 'error'> = {
bestanden: 'success',
bestanden_mit_maengeln: 'warning',
nicht_bestanden: 'error',
};
export interface FahrzeugWartungslog {
id: string;
fahrzeug_id: string;
@@ -58,6 +75,8 @@ export interface FahrzeugWartungslog {
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
ergebnis: WartungslogErgebnis | null;
naechste_faelligkeit: string | null;
dokument_url: string | null;
erfasst_von: string | null;
created_at: string;
@@ -160,4 +179,16 @@ export interface CreateWartungslogPayload {
kraftstoff_liter?: number;
kosten?: number;
externe_werkstatt?: string;
ergebnis?: WartungslogErgebnis;
naechste_faelligkeit?: string;
}
export interface UpdateWartungslogPayload {
datum: string;
art?: WartungslogArt;
beschreibung: string;
km_stand?: number;
externe_werkstatt?: string;
ergebnis?: WartungslogErgebnis;
naechste_faelligkeit?: string;
}