feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade

- Admin can purge all personal data for a user (POST /api/admin/users/:userId/purge-data)
  while keeping the account; clears profile, notifications, bookings, ical tokens, preferences
- Add isNewUser flag to auth callback response; first-login dialog prompts for Standesbuchnummer
- Add PageBreadcrumbs component and apply to 18 sub-pages across the app
- Cascade budget_typ changes from parent pot to all children recursively, converting amounts
  (detailliert→einfach: sum into budget_gesamt; einfach→detailliert: zero all for redistribution)
- Migrate NextcloudTalkWidget to use shared WidgetCard template for consistent header styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-13 16:15:28 +02:00
parent a0b3c0ec5c
commit b477e5dbe0
32 changed files with 485 additions and 49 deletions

View File

@@ -100,6 +100,7 @@ class AuthController {
// Step 4: Find or create user in database // Step 4: Find or create user in database
let user = await userService.findByAuthentikSub(userInfo.sub); let user = await userService.findByAuthentikSub(userInfo.sub);
const isNewUser = !user;
if (!user) { if (!user) {
// User doesn't exist, create new user // User doesn't exist, create new user
@@ -230,6 +231,7 @@ class AuthController {
data: { data: {
accessToken, accessToken,
refreshToken, refreshToken,
isNewUser,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,

View File

@@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware';
import { auditExport } from '../middleware/audit.middleware'; import { auditExport } from '../middleware/audit.middleware';
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
import cleanupService from '../services/cleanup.service'; import cleanupService from '../services/cleanup.service';
import userService from '../services/user.service';
import pool from '../config/database'; import pool from '../config/database';
import logger from '../utils/logger'; import logger from '../utils/logger';
@@ -440,4 +441,53 @@ router.delete(
} }
); );
// ---------------------------------------------------------------------------
// POST /api/admin/users/:userId/purge-data — delete all user-associated data
// (keeps the users record, financial records, and audit log)
// ---------------------------------------------------------------------------
router.post(
'/users/:userId/purge-data',
authenticate,
requirePermission('admin:write'),
async (req: Request, res: Response): Promise<void> => {
try {
const targetUserId = req.params.userId as string;
const requestingUserId = req.user!.id;
const targetUser = await userService.findById(targetUserId);
if (!targetUser) {
res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
return;
}
const result = await userService.purgeUserData(targetUserId, requestingUserId);
// Audit log
await auditService.logAudit({
user_id: requestingUserId,
user_email: req.user!.email,
action: AuditAction.DELETE,
resource_type: AuditResourceType.USER,
resource_id: targetUserId,
old_value: { email: targetUser.email, name: targetUser.name },
new_value: { purged_tables: result },
ip_address: req.ip ?? null,
user_agent: req.headers['user-agent'] ?? null,
metadata: { operation: 'purge_user_data' },
});
res.json({ success: true, data: result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Löschen der Benutzerdaten';
if (message === 'Cannot purge your own data') {
res.status(400).json({ success: false, message: 'Eigene Daten können nicht gelöscht werden' });
return;
}
logger.error('Failed to purge user data', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Benutzerdaten' });
}
}
);
export default router; export default router;

View File

@@ -653,6 +653,36 @@ async function createKonto(
} }
} }
async function cascadeBudgetTypToChildren(parentId: number, newType: string): Promise<void> {
const children = await pool.query(
'SELECT id, budget_typ, budget_gwg, budget_anlagen, budget_instandhaltung, budget_gesamt FROM buchhaltung_konten WHERE parent_id = $1',
[parentId]
);
for (const child of children.rows) {
const oldType = child.budget_typ || 'detailliert';
if (oldType === newType) continue;
let gwg = 0, anlagen = 0, instandhaltung = 0, gesamt = 0;
if (oldType === 'detailliert' && newType === 'einfach') {
// Sum detail fields into gesamt
gesamt = (parseFloat(child.budget_gwg) || 0) + (parseFloat(child.budget_anlagen) || 0) + (parseFloat(child.budget_instandhaltung) || 0);
}
// einfach → detailliert: zero everything (user redistributes manually)
await pool.query(
`UPDATE buchhaltung_konten
SET budget_typ = $1, budget_gwg = $2, budget_anlagen = $3, budget_instandhaltung = $4, budget_gesamt = $5
WHERE id = $6`,
[newType, gwg, anlagen, instandhaltung, gesamt, child.id]
);
// Recurse into grandchildren
await cascadeBudgetTypToChildren(child.id, newType);
}
}
async function updateKonto( async function updateKonto(
id: number, id: number,
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number } data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }
@@ -711,6 +741,15 @@ async function updateKonto(
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, `UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values values
); );
// Cascade budget_typ change to all children recursively
if (data.budget_typ !== undefined && result.rows[0]) {
const newBudgetTyp = result.rows[0].budget_typ;
// Fetch what the old type was before the update (the RETURNING row has the new value)
// We know a change happened if the user sent budget_typ, so cascade unconditionally
await cascadeBudgetTypToChildren(id, newBudgetTyp);
}
return result.rows[0] || null; return result.rows[0] || null;
} catch (error: any) { } catch (error: any) {
logger.error('BuchhaltungService.updateKonto failed', { error, id }); logger.error('BuchhaltungService.updateKonto failed', { error, id });

View File

@@ -322,6 +322,69 @@ class UserService {
} }
} }
/**
* Purge all data associated with a user, keeping the users record itself.
* Financial/audit records (buchhaltung, bestellung_historie, audit_log) are preserved.
*/
async purgeUserData(userId: string, requestingUserId: string): Promise<Record<string, number>> {
if (userId === requestingUserId) {
throw new Error('Cannot purge your own data');
}
const user = await this.findById(userId);
if (!user) {
throw new Error('User not found');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const results: Record<string, number> = {};
// Helper to delete and track row counts
const purge = async (table: string, column = 'user_id') => {
const r = await client.query(`DELETE FROM ${table} WHERE ${column} = $1`, [userId]);
results[table] = r.rowCount ?? 0;
};
// User-owned data tables (DELETE rows)
await purge('notifications');
await purge('mitglieder_profile');
await purge('dienstgrad_verlauf');
await purge('atemschutz_traeger');
await purge('ausbildungen');
await purge('untersuchungen');
await purge('fahrgenehmigungen');
await purge('befoerderungen');
await purge('einsatz_personal');
await purge('uebung_teilnahmen');
await purge('veranstaltung_teilnahmen');
await purge('veranstaltung_ical_tokens');
await purge('fahrzeug_ical_tokens');
await purge('shop_anfragen', 'anfrager_id');
// Clear user preferences (widget layout, etc.)
await client.query(
`UPDATE users SET preferences = NULL, nextcloud_login_name = NULL, nextcloud_app_password = NULL WHERE id = $1`,
[userId]
);
results['preferences_cleared'] = 1;
await client.query('COMMIT');
logger.info('User data purged', { userId, by: requestingUserId, results });
return results;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error purging user data', { error, userId, requestingUserId });
throw error;
} finally {
client.release();
}
}
/** /**
* Sync Authentik groups for a user * Sync Authentik groups for a user
*/ */

View File

@@ -1,6 +1,13 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Box, Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -11,11 +18,14 @@ import {
Paper, Paper,
TextField, TextField,
Chip, Chip,
Tooltip,
Typography, Typography,
CircularProgress, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { useQuery } from '@tanstack/react-query'; import { DeleteSweep as DeleteSweepIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../../services/admin'; import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext';
import type { UserOverview } from '../../types/admin.types'; import type { UserOverview } from '../../types/admin.types';
function getRoleFromGroups(groups: string[] | null): string { function getRoleFromGroups(groups: string[] | null): string {
@@ -51,6 +61,10 @@ function UserOverviewTab() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name'); const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc'); const [sortDir, setSortDir] = useState<SortDir>('asc');
const [purgeTarget, setPurgeTarget] = useState<UserOverview | null>(null);
const [purging, setPurging] = useState(false);
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: users, isLoading, isError } = useQuery({ const { data: users, isLoading, isError } = useQuery({
queryKey: ['admin', 'users'], queryKey: ['admin', 'users'],
@@ -90,6 +104,21 @@ function UserOverviewTab() {
return result; return result;
}, [users, search, sortKey, sortDir]); }, [users, search, sortKey, sortDir]);
const handlePurge = async () => {
if (!purgeTarget) return;
setPurging(true);
try {
await adminApi.purgeUserData(purgeTarget.id);
showSuccess(`Daten von ${purgeTarget.name} wurden gelöscht.`);
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
} catch {
showError('Daten konnten nicht gelöscht werden.');
} finally {
setPurging(false);
setPurgeTarget(null);
}
};
if (isLoading) { if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
} }
@@ -148,6 +177,7 @@ function UserOverviewTab() {
Letzter Login Letzter Login
</TableSortLabel> </TableSortLabel>
</TableCell> </TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -177,11 +207,35 @@ function UserOverviewTab() {
/> />
</TableCell> </TableCell>
<TableCell>{formatRelativeTime(user.last_login_at)}</TableCell> <TableCell>{formatRelativeTime(user.last_login_at)}</TableCell>
<TableCell align="right">
<Tooltip title="Benutzerdaten löschen">
<IconButton size="small" onClick={() => setPurgeTarget(user)}>
<DeleteSweepIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
<Dialog open={!!purgeTarget} onClose={() => !purging && setPurgeTarget(null)}>
<DialogTitle>Benutzerdaten löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Möchtest du alle persönlichen Daten von <strong>{purgeTarget?.name}</strong> löschen?
Das Benutzerkonto bleibt erhalten, aber Profil, Benachrichtigungen und Buchungen werden
unwiderruflich entfernt.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setPurgeTarget(null)} disabled={purging}>Abbrechen</Button>
<Button onClick={handlePurge} color="error" variant="contained" disabled={purging}>
{purging ? 'Wird gelöscht...' : 'Daten löschen'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Typography,
} from '@mui/material';
import { membersService } from '../../services/members';
interface Props {
open: boolean;
userId: string;
onClose: () => void;
}
export const FirstLoginDialog = ({ open, userId, onClose }: Props) => {
const [standesbuchnummer, setStandesbuchnummer] = useState('');
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
if (standesbuchnummer.trim()) {
await membersService.updateMember(userId, {
fdisk_standesbuch_nr: standesbuchnummer.trim(),
});
}
} catch {
// non-fatal, user can update later in profile
} finally {
setSaving(false);
localStorage.setItem('firstLoginCompleted', 'true');
onClose();
}
};
const handleSkip = () => {
localStorage.setItem('firstLoginCompleted', 'true');
onClose();
};
return (
<Dialog open={open} maxWidth="sm" fullWidth>
<DialogTitle>Willkommen!</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Du wurdest erfolgreich registriert. Bitte gib deine Standesbuchnummer ein, damit dein
Profil zugeordnet werden kann. Dies kann auch später in deinen Profileinstellungen
ergänzt werden.
</Typography>
<TextField
label="Standesbuchnummer"
value={standesbuchnummer}
onChange={(e) => setStandesbuchnummer(e.target.value)}
fullWidth
size="small"
placeholder="z.B. 12345"
inputProps={{ maxLength: 32 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleSkip} color="inherit">
Überspringen
</Button>
<Button onClick={handleSave} variant="contained" disabled={saving}>
{saving ? 'Speichern...' : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -2,12 +2,15 @@ import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material'; import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
import { FirstLoginDialog } from './FirstLoginDialog';
const LoginCallback: React.FC = () => { const LoginCallback: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { login } = useAuth(); const { login, user } = useAuth();
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [showFirstLogin, setShowFirstLogin] = useState(false);
const [redirectTo, setRedirectTo] = useState('/dashboard');
const hasCalledLogin = useRef(false); const hasCalledLogin = useRef(false);
useEffect(() => { useEffect(() => {
@@ -29,7 +32,7 @@ const LoginCallback: React.FC = () => {
} }
try { try {
await login(code); const result = await login(code);
// Navigate to the originally intended page, falling back to the dashboard. // Navigate to the originally intended page, falling back to the dashboard.
// Validate that the stored path is a safe internal path: must start with '/' // Validate that the stored path is a safe internal path: must start with '/'
// but must NOT start with '//' (protocol-relative redirect). // but must NOT start with '//' (protocol-relative redirect).
@@ -39,7 +42,13 @@ const LoginCallback: React.FC = () => {
? rawFrom ? rawFrom
: '/dashboard'; : '/dashboard';
sessionStorage.removeItem('auth_redirect_from'); sessionStorage.removeItem('auth_redirect_from');
if (result.isNewUser && !localStorage.getItem('firstLoginCompleted')) {
setRedirectTo(from);
setShowFirstLogin(true);
} else {
navigate(from, { replace: true }); navigate(from, { replace: true });
}
} catch (err) { } catch (err) {
console.error('Login callback error:', err); console.error('Login callback error:', err);
const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429; const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429;
@@ -99,10 +108,20 @@ const LoginCallback: React.FC = () => {
gap: 2, gap: 2,
}} }}
> >
{showFirstLogin && user ? (
<FirstLoginDialog
open={showFirstLogin}
userId={user.id}
onClose={() => navigate(redirectTo, { replace: true })}
/>
) : (
<>
<CircularProgress size={60} /> <CircularProgress size={60} />
<Typography variant="h6" color="text.secondary"> <Typography variant="h6" color="text.secondary">
Anmeldung wird abgeschlossen... Anmeldung wird abgeschlossen...
</Typography> </Typography>
</>
)}
</Box> </Box>
); );
}; };

View File

@@ -0,0 +1,44 @@
import { Breadcrumbs, Typography, Link as MuiLink } from '@mui/material';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Link } from 'react-router-dom';
export interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
}
export const PageBreadcrumbs = ({ items }: Props) => {
return (
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
sx={{ mb: 2, fontSize: '0.8125rem' }}
>
{items.map((item, index) => {
const isLast = index === items.length - 1;
if (isLast || !item.href) {
return (
<Typography key={index} variant="body2" color="text.primary" fontWeight={500}>
{item.label}
</Typography>
);
}
return (
<MuiLink
key={index}
component={Link}
to={item.href}
underline="hover"
color="text.secondary"
variant="body2"
>
{item.label}
</MuiLink>
);
})}
</Breadcrumbs>
);
};

View File

@@ -0,0 +1,2 @@
export { PageBreadcrumbs } from './PageBreadcrumbs';
export type { BreadcrumbItem } from './PageBreadcrumbs';

View File

@@ -1,7 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { import {
Card,
CardContent,
Typography, Typography,
Box, Box,
Chip, Chip,
@@ -20,6 +18,7 @@ import { nextcloudApi } from '../../services/nextcloud';
import type { NextcloudConversation } from '../../types/nextcloud.types'; import type { NextcloudConversation } from '../../types/nextcloud.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl'; import { safeOpenUrl } from '../../utils/safeOpenUrl';
import { useCountUp } from '../../hooks/useCountUp'; import { useCountUp } from '../../hooks/useCountUp';
import { WidgetCard } from '../templates/WidgetCard';
const POLL_INTERVAL = 2000; const POLL_INTERVAL = 2000;
const POLL_TIMEOUT = 5 * 60 * 1000; const POLL_TIMEOUT = 5 * 60 * 1000;
@@ -183,22 +182,8 @@ const NextcloudTalkWidget: React.FC = () => {
} }
}; };
return ( const headerAction = (
<Card <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Forum color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Nextcloud Talk
</Typography>
{connected && totalUnread > 0 && ( {connected && totalUnread > 0 && (
<Chip <Chip
label={`${animatedUnread} ungelesen`} label={`${animatedUnread} ungelesen`}
@@ -215,7 +200,16 @@ const NextcloudTalkWidget: React.FC = () => {
</Tooltip> </Tooltip>
)} )}
</Box> </Box>
);
return (
<WidgetCard
title="Nextcloud Talk"
icon={<Forum />}
action={headerAction}
noPadding
>
<Box sx={{ px: 2.5, pb: 2.5 }}>
{isLoading && ( {isLoading && (
<Box> <Box>
{[1, 2, 3].map((n) => ( {[1, 2, 3].map((n) => (
@@ -276,8 +270,8 @@ const NextcloudTalkWidget: React.FC = () => {
))} ))}
</Box> </Box>
)} )}
</CardContent> </Box>
</Card> </WidgetCard>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
import { AuthContextType, AuthState } from '../types/auth.types'; import { AuthContextType, AuthState, LoginResult } from '../types/auth.types';
import { authService } from '../services/auth'; import { authService } from '../services/auth';
import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage'; import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage';
import { useNotification } from './NotificationContext'; import { useNotification } from './NotificationContext';
@@ -67,11 +67,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
initializeAuth(); initializeAuth();
}, []); }, []);
const login = useCallback(async (code: string): Promise<void> => { const login = useCallback(async (code: string): Promise<LoginResult> => {
try { try {
setState((prev) => ({ ...prev, isLoading: true })); setState((prev) => ({ ...prev, isLoading: true }));
const { token, refreshToken, user } = await authService.handleCallback(code); const { token, refreshToken, user, isNewUser } = await authService.handleCallback(code);
// Save to localStorage // Save to localStorage
setToken(token); setToken(token);
@@ -88,6 +88,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
// Show success notification // Show success notification
notification.showSuccess('Anmeldung erfolgreich'); notification.showSuccess('Anmeldung erfolgreich');
return { isNewUser };
} catch (error) { } catch (error) {
console.error('Login failed:', error); console.error('Login failed:', error);
setState({ setState({

View File

@@ -32,6 +32,7 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { settingsApi } from '../services/settings'; import { settingsApi } from '../services/settings';
@@ -293,6 +294,10 @@ function AdminSettings() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
<PageBreadcrumbs items={[
{ label: 'Admin', href: '/admin' },
{ label: 'Einstellungen' },
]} />
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}> <Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Admin-Einstellungen Admin-Einstellungen
</Typography> </Typography>

View File

@@ -779,6 +779,10 @@ function AusruestungDetailPage() {
<DetailLayout <DetailLayout
title={equipment.bezeichnung} title={equipment.bezeichnung}
backTo="/ausruestung" backTo="/ausruestung"
breadcrumbs={[
{ label: 'Ausrüstung', href: '/ausruestung' },
{ label: equipment.bezeichnung },
]}
tabs={tabs} tabs={tabs}
actions={ actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>

View File

@@ -11,6 +11,7 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
@@ -285,6 +286,10 @@ export default function AusruestungsanfrageArtikelDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
{ label: isCreate ? 'Neuer Katalogartikel' : (artikel?.bezeichnung ?? 'Artikel') },
]} />
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}> <IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}>

View File

@@ -13,6 +13,7 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { useNotification } from '../contexts/NotificationContext'; 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';
@@ -193,6 +194,10 @@ export default function AusruestungsanfrageDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
{ label: anfrage ? `Anfrage ${formatOrderId(anfrage)}` : 'Anfrage' },
]} />
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/ausruestungsanfrage')}> <IconButton onClick={() => navigate('/ausruestungsanfrage')}>

View File

@@ -7,6 +7,7 @@ import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-mate
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
@@ -168,6 +169,10 @@ export default function AusruestungsanfrageNeu() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
{ label: 'Neue Bestellung' },
]} />
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/ausruestungsanfrage')}> <IconButton onClick={() => navigate('/ausruestungsanfrage')}>

View File

@@ -14,6 +14,7 @@ import {
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
@@ -236,6 +237,11 @@ export default function AusruestungsanfrageZuBestellung() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ maxWidth: 960, mx: 'auto', px: 2, py: 3 }}> <Box sx={{ maxWidth: 960, mx: 'auto', px: 2, py: 3 }}>
<PageBreadcrumbs items={[
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
{ label: anfrage.bezeichnung || `Anfrage ${formatOrderId(anfrage)}`, href: `/ausruestungsanfrage/${requestId}` },
{ label: 'Bestellen' },
]} />
{/* ── Header ── */} {/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>

View File

@@ -673,6 +673,10 @@ export default function BestellungDetail() {
<PageHeader <PageHeader
title={bestellung.bezeichnung} title={bestellung.bezeichnung}
backTo="/bestellungen" backTo="/bestellungen"
breadcrumbs={[
{ label: 'Bestellungen', href: '/bestellungen' },
{ label: bestellung.bezeichnung },
]}
actions={ actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{canExport && !editMode && ( {canExport && !editMode && (

View File

@@ -23,6 +23,7 @@ import { ArrowBack } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { buchhaltungApi } from '../services/buchhaltung'; import { buchhaltungApi } from '../services/buchhaltung';
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types'; import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
import type { BankkontoStatementRow } from '../types/buchhaltung.types'; import type { BankkontoStatementRow } from '../types/buchhaltung.types';
@@ -61,6 +62,10 @@ export default function BuchhaltungBankkontoDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Buchhaltung', href: '/buchhaltung' },
{ label: data?.bankkonto?.bezeichnung || 'Bankkonto' },
]} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Tooltip title="Zurueck"> <Tooltip title="Zurueck">
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}> <IconButton onClick={() => navigate('/buchhaltung?tab=2')}>

View File

@@ -9,6 +9,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'; import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { buchhaltungApi } from '../services/buchhaltung'; import { buchhaltungApi } from '../services/buchhaltung';
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types'; import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types'; import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types';
@@ -132,6 +133,10 @@ export default function BuchhaltungKontoDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
<PageBreadcrumbs items={[
{ label: 'Buchhaltung', href: '/buchhaltung' },
{ label: `${konto.kontonummer}${konto.bezeichnung}` },
]} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}> <Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
Zurück Zurück

View File

@@ -8,6 +8,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material'; import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { buchhaltungApi } from '../services/buchhaltung'; import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types'; import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
@@ -172,6 +173,11 @@ export default function BuchhaltungKontoManage() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Buchhaltung', href: '/buchhaltung' },
{ label: `${konto.kontonummer}${konto.bezeichnung}`, href: `/buchhaltung/konto/${id}` },
{ label: 'Verwalten' },
]} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung?tab=2')}> <Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung?tab=2')}>
Zurück Zurück

View File

@@ -19,6 +19,7 @@ import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-materia
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten'; import { checklistenApi } from '../services/checklisten';
@@ -243,6 +244,10 @@ export default function ChecklistAusfuehrung() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Checklisten', href: '/checklisten' },
{ label: execution.vorlage_name ?? 'Checkliste' },
]} />
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small"> <Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small">
Checklisten Checklisten
</Button> </Button>

View File

@@ -284,6 +284,10 @@ function EinsatzDetail() {
title={`Einsatz ${einsatz.einsatz_nr}`} title={`Einsatz ${einsatz.einsatz_nr}`}
subtitle={address || undefined} subtitle={address || undefined}
backTo="/einsaetze" backTo="/einsaetze"
breadcrumbs={[
{ label: 'Einsätze', href: '/einsaetze' },
{ label: `Einsatz ${einsatz.einsatz_nr}` },
]}
actions={ actions={
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<Chip <Chip

View File

@@ -1006,6 +1006,10 @@ function FahrzeugDetail() {
<DetailLayout <DetailLayout
title={titleText} title={titleText}
backTo="/fahrzeuge" backTo="/fahrzeuge"
breadcrumbs={[
{ label: 'Fahrzeuge', href: '/fahrzeuge' },
{ label: titleText },
]}
tabs={tabs} tabs={tabs}
actions={ actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { import {
Alert, Alert,
Box, Box,
@@ -143,6 +144,10 @@ export default function HaushaltsplanDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<PageBreadcrumbs items={[
{ label: 'Haushaltsplan', href: '/haushaltsplan' },
{ label: planung.bezeichnung },
]} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton> <IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography> <Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>

View File

@@ -264,6 +264,10 @@ export default function IssueDetail() {
<PageHeader <PageHeader
title={`${formatIssueId(issue)}${issue.titel}`} title={`${formatIssueId(issue)}${issue.titel}`}
backTo="/issues" backTo="/issues"
breadcrumbs={[
{ label: 'Issues', href: '/issues' },
{ label: `${formatIssueId(issue)}${issue.titel}` },
]}
actions={ actions={
<Chip <Chip
label={getStatusLabel(statuses, issue.status)} label={getStatusLabel(statuses, issue.status)}

View File

@@ -419,6 +419,10 @@ function MitgliedDetail() {
<PageHeader <PageHeader
title={displayName} title={displayName}
backTo="/mitglieder" backTo="/mitglieder"
breadcrumbs={[
{ label: 'Mitglieder', href: '/mitglieder' },
{ label: displayName },
]}
/> />
{/* Header card */} {/* Header card */}

View File

@@ -42,6 +42,7 @@ import {
} 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 DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { trainingApi } from '../services/training'; import { trainingApi } from '../services/training';
import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types'; import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types';
@@ -332,6 +333,10 @@ export default function UebungDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ maxWidth: 720, mx: 'auto' }}> <Box sx={{ maxWidth: 720, mx: 'auto' }}>
<PageBreadcrumbs items={[
{ label: 'Kalender', href: '/kalender' },
{ label: event.titel || 'Übung' },
]} />
{/* Back button */} {/* Back button */}
<Button <Button
startIcon={<BackIcon />} startIcon={<BackIcon />}

View File

@@ -34,6 +34,7 @@ import {
Category as CategoryIcon, Category as CategoryIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events'; import { eventsApi } from '../services/events';
@@ -339,6 +340,10 @@ export default function VeranstaltungKategorien() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg" sx={{ py: 3 }}> <Container maxWidth="lg" sx={{ py: 3 }}>
<PageBreadcrumbs items={[
{ label: 'Veranstaltungen', href: '/veranstaltungen' },
{ label: 'Kategorien' },
]} />
{/* Header */} {/* Header */}
<Box <Box
sx={{ sx={{

View File

@@ -31,4 +31,5 @@ export const adminApi = {
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').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), fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
purgeFdiskData: (userId: string) => api.delete<ApiResponse<{ profileFieldsCleared: number; ausbildungen: number; befoerderungen: number; untersuchungen: number; fahrgenehmigungen: number }>>(`/api/admin/users/${userId}/fdisk-data`).then(r => r.data.data), purgeFdiskData: (userId: string) => api.delete<ApiResponse<{ profileFieldsCleared: number; ausbildungen: number; befoerderungen: number; untersuchungen: number; fahrgenehmigungen: number }>>(`/api/admin/users/${userId}/fdisk-data`).then(r => r.data.data),
purgeUserData: (userId: string) => api.post<ApiResponse<Record<string, number>>>(`/api/admin/users/${userId}/purge-data`).then(r => r.data.data),
}; };

View File

@@ -8,6 +8,7 @@ export interface AuthCallbackResponse {
token: string; token: string;
refreshToken: string; refreshToken: string;
user: User; user: User;
isNewUser?: boolean;
} }
// The backend returns camelCase field names; map them to the snake_case User type. // The backend returns camelCase field names; map them to the snake_case User type.
@@ -45,7 +46,7 @@ export const authService = {
const response = await api.post<{ const response = await api.post<{
success: boolean; success: boolean;
message: string; message: string;
data: { accessToken: string; refreshToken: string; user: Record<string, unknown> }; data: { accessToken: string; refreshToken: string; isNewUser?: boolean; user: Record<string, unknown> };
}>('/api/auth/callback', { }>('/api/auth/callback', {
code, code,
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
@@ -54,6 +55,7 @@ export const authService = {
token: response.data.data.accessToken, token: response.data.data.accessToken,
refreshToken: response.data.data.refreshToken, refreshToken: response.data.data.refreshToken,
user: mapBackendUser(response.data.data.user), user: mapBackendUser(response.data.data.user),
isNewUser: response.data.data.isNewUser,
}; };
}, },

View File

@@ -21,8 +21,12 @@ export interface AuthState {
isLoading: boolean; isLoading: boolean;
} }
export interface LoginResult {
isNewUser?: boolean;
}
export interface AuthContextType extends AuthState { export interface AuthContextType extends AuthState {
login: (code: string) => Promise<void>; login: (code: string) => Promise<LoginResult>;
logout: () => void; logout: () => void;
refreshAuth: () => Promise<void>; refreshAuth: () => Promise<void>;
} }