feat(admin): centralize tool & module settings in Werkzeuge tab with per-tool permissions, DB-backed config, connection tests, and cog-button navigation

This commit is contained in:
Matthias Hochmeister
2026-04-17 08:37:29 +02:00
parent 6ead698294
commit 6614fbaa68
28 changed files with 2472 additions and 1426 deletions

View File

@@ -20,15 +20,6 @@ import {
DialogTitle,
DialogContent,
DialogActions,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Stack,
Tooltip,
} from '@mui/material';
@@ -38,12 +29,10 @@ import {
ContentCopy,
Cancel,
Edit,
Delete,
Save,
Close,
EventBusy,
Settings,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { format, parseISO } from 'date-fns';
import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -103,8 +92,6 @@ function FahrzeugBuchungen() {
const canCreate = hasPermission('fahrzeugbuchungen:create');
const canManage = hasPermission('fahrzeugbuchungen:manage');
const [tabIndex, setTabIndex] = useState(0);
// ── Filters ────────────────────────────────────────────────────────────────
const today = new Date();
const defaultFrom = format(today, 'yyyy-MM-dd');
@@ -120,11 +107,6 @@ function FahrzeugBuchungen() {
queryFn: fetchVehicles,
});
const { data: kategorien = [] } = useQuery({
queryKey: ['buchungskategorien-all'],
queryFn: kategorieApi.getAll,
});
const { data: activeKategorien = [] } = useQuery({
queryKey: ['buchungskategorien'],
queryFn: kategorieApi.getActive,
@@ -191,46 +173,6 @@ function FahrzeugBuchungen() {
}
};
// ── Einstellungen: Categories management ───────────────────────────────────
const [editRowId, setEditRowId] = useState<number | null>(null);
const [editRowData, setEditRowData] = useState<Partial<BuchungsKategorie>>({});
const [newKatDialog, setNewKatDialog] = useState(false);
const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' });
const createKatMutation = useMutation({
mutationFn: (data: Omit<BuchungsKategorie, 'id'>) => kategorieApi.create(data),
onSuccess: () => {
notification.showSuccess('Kategorie erstellt');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
setNewKatDialog(false);
setNewKatForm({ bezeichnung: '', farbe: '#1976d2' });
},
onError: () => notification.showError('Fehler beim Erstellen'),
});
const updateKatMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BuchungsKategorie> }) =>
kategorieApi.update(id, data),
onSuccess: () => {
notification.showSuccess('Kategorie aktualisiert');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
setEditRowId(null);
},
onError: () => notification.showError('Fehler beim Aktualisieren'),
});
const deleteKatMutation = useMutation({
mutationFn: (id: number) => kategorieApi.delete(id),
onSuccess: () => {
notification.showSuccess('Kategorie deaktiviert');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
},
onError: () => notification.showError('Fehler beim Löschen'),
});
// ── Render ─────────────────────────────────────────────────────────────────
if (!isFeatureEnabled('fahrzeugbuchungen')) {
return <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />;
@@ -244,23 +186,23 @@ function FahrzeugBuchungen() {
<Typography variant="h4" fontWeight={700}>
Fahrzeugbuchungen
</Typography>
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
iCal abonnieren
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{canManage && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=fahrzeugbuchungen')}>
<Settings />
</IconButton>
</Tooltip>
)}
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
iCal abonnieren
</Button>
</Box>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
<Tab label="Buchungen" />
{canManage && <Tab label="Einstellungen" />}
</Tabs>
</Box>
{/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */}
{tabIndex === 0 && (
<>
{/* Filters */}
{/* ── Buchungen ─────────────────────────────────────────────── */}
<>
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<GermanDateField
@@ -410,173 +352,9 @@ function FahrzeugBuchungen() {
</Card>
))}
</>
)}
{/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */}
{tabIndex === 1 && canManage && (
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Buchungskategorien</Typography>
<Button
startIcon={<Add />}
variant="contained"
size="small"
onClick={() => setNewKatDialog(true)}
>
Neue Kategorie
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Sortierung</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((kat) => {
const isEditing = editRowId === kat.id;
return (
<TableRow key={kat.id}>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editRowData.bezeichnung ?? kat.bezeichnung}
onChange={(e) =>
setEditRowData((d) => ({ ...d, bezeichnung: e.target.value }))
}
/>
) : (
kat.bezeichnung
)}
</TableCell>
<TableCell>
{isEditing ? (
<input
type="color"
value={editRowData.farbe ?? kat.farbe}
onChange={(e) =>
setEditRowData((d) => ({ ...d, farbe: e.target.value }))
}
style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }}
/>
) : (
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1,
bgcolor: kat.farbe,
border: '1px solid',
borderColor: 'divider',
}}
/>
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={editRowData.sort_order ?? kat.sort_order}
onChange={(e) =>
setEditRowData((d) => ({
...d,
sort_order: parseInt(e.target.value) || 0,
}))
}
sx={{ width: 80 }}
/>
) : (
kat.sort_order
)}
</TableCell>
<TableCell>
{isEditing ? (
<Checkbox
checked={editRowData.aktiv ?? kat.aktiv}
onChange={(e) =>
setEditRowData((d) => ({ ...d, aktiv: e.target.checked }))
}
/>
) : (
<Checkbox checked={kat.aktiv} disabled />
)}
</TableCell>
<TableCell align="right">
{isEditing ? (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
color="primary"
onClick={() =>
updateKatMutation.mutate({ id: kat.id, data: editRowData })
}
>
<Save fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setEditRowId(null);
setEditRowData({});
}}
>
<Close fontSize="small" />
</IconButton>
</Stack>
) : (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
onClick={() => {
setEditRowId(kat.id);
setEditRowData({
bezeichnung: kat.bezeichnung,
farbe: kat.farbe,
sort_order: kat.sort_order,
aktiv: kat.aktiv,
});
}}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteKatMutation.mutate(kat.id)}
>
<Delete fontSize="small" />
</IconButton>
</Stack>
)}
</TableCell>
</TableRow>
);
})}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Keine Kategorien vorhanden
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
{/* ── FAB ── */}
{canCreate && tabIndex === 0 && (
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Buchung erstellen"
@@ -655,57 +433,6 @@ function FahrzeugBuchungen() {
</DialogActions>
</Dialog>
{/* ── New category dialog ── */}
<Dialog
open={newKatDialog}
onClose={() => setNewKatDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
fullWidth
size="small"
label="Bezeichnung"
required
value={newKatForm.bezeichnung}
onChange={(e) =>
setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value }))
}
/>
<Box>
<Typography variant="body2" sx={{ mb: 0.5 }}>
Farbe
</Typography>
<input
type="color"
value={newKatForm.farbe}
onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))}
style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }}
/>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatDialog(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={() =>
createKatMutation.mutate({
bezeichnung: newKatForm.bezeichnung,
farbe: newKatForm.farbe,
aktiv: true,
sort_order: kategorien.length,
})
}
disabled={!newKatForm.bezeichnung || createKatMutation.isPending}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);