From b477e5dbe002d411d78d6a40cfa224840e76a40e Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 13 Apr 2026 16:15:28 +0200 Subject: [PATCH] feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/controllers/auth.controller.ts | 2 + backend/src/routes/admin.routes.ts | 50 +++++++++++++ backend/src/services/buchhaltung.service.ts | 39 ++++++++++ backend/src/services/user.service.ts | 63 ++++++++++++++++ .../src/components/admin/UserOverviewTab.tsx | 56 +++++++++++++- .../src/components/auth/FirstLoginDialog.tsx | 74 +++++++++++++++++++ .../src/components/auth/LoginCallback.tsx | 33 +++++++-- .../src/components/common/PageBreadcrumbs.tsx | 44 +++++++++++ frontend/src/components/common/index.ts | 2 + .../dashboard/NextcloudTalkWidget.tsx | 66 ++++++++--------- frontend/src/contexts/AuthContext.tsx | 8 +- frontend/src/pages/AdminSettings.tsx | 5 ++ frontend/src/pages/AusruestungDetail.tsx | 4 + .../AusruestungsanfrageArtikelDetail.tsx | 5 ++ .../src/pages/AusruestungsanfrageDetail.tsx | 5 ++ frontend/src/pages/AusruestungsanfrageNeu.tsx | 5 ++ .../pages/AusruestungsanfrageZuBestellung.tsx | 6 ++ frontend/src/pages/BestellungDetail.tsx | 4 + .../src/pages/BuchhaltungBankkontoDetail.tsx | 5 ++ frontend/src/pages/BuchhaltungKontoDetail.tsx | 5 ++ frontend/src/pages/BuchhaltungKontoManage.tsx | 6 ++ frontend/src/pages/ChecklistAusfuehrung.tsx | 5 ++ frontend/src/pages/EinsatzDetail.tsx | 4 + frontend/src/pages/FahrzeugDetail.tsx | 4 + frontend/src/pages/HaushaltsplanDetail.tsx | 5 ++ frontend/src/pages/IssueDetail.tsx | 4 + frontend/src/pages/MitgliedDetail.tsx | 4 + frontend/src/pages/UebungDetail.tsx | 5 ++ .../src/pages/VeranstaltungKategorien.tsx | 5 ++ frontend/src/services/admin.ts | 1 + frontend/src/services/auth.ts | 4 +- frontend/src/types/auth.types.ts | 6 +- 32 files changed, 485 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/auth/FirstLoginDialog.tsx create mode 100644 frontend/src/components/common/PageBreadcrumbs.tsx create mode 100644 frontend/src/components/common/index.ts diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index fc86d63..5ccad9c 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -100,6 +100,7 @@ class AuthController { // Step 4: Find or create user in database let user = await userService.findByAuthentikSub(userInfo.sub); + const isNewUser = !user; if (!user) { // User doesn't exist, create new user @@ -230,6 +231,7 @@ class AuthController { data: { accessToken, refreshToken, + isNewUser, user: { id: user.id, email: user.email, diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 3da500a..52919b3 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware'; import { auditExport } from '../middleware/audit.middleware'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; import cleanupService from '../services/cleanup.service'; +import userService from '../services/user.service'; import pool from '../config/database'; 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 => { + 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; diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 486b9d1..575479e 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -653,6 +653,36 @@ async function createKonto( } } +async function cascadeBudgetTypToChildren(parentId: number, newType: string): Promise { + 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( 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 } @@ -711,6 +741,15 @@ async function updateKonto( `UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, 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; } catch (error: any) { logger.error('BuchhaltungService.updateKonto failed', { error, id }); diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index d597559..1b404e6 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -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> { + 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 = {}; + + // 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 */ diff --git a/frontend/src/components/admin/UserOverviewTab.tsx b/frontend/src/components/admin/UserOverviewTab.tsx index a3bf74d..38c9296 100644 --- a/frontend/src/components/admin/UserOverviewTab.tsx +++ b/frontend/src/components/admin/UserOverviewTab.tsx @@ -1,6 +1,13 @@ import { useState, useMemo } from 'react'; import { Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, Table, TableBody, TableCell, @@ -11,11 +18,14 @@ import { Paper, TextField, Chip, + Tooltip, Typography, CircularProgress, } 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 { useNotification } from '../../contexts/NotificationContext'; import type { UserOverview } from '../../types/admin.types'; function getRoleFromGroups(groups: string[] | null): string { @@ -51,6 +61,10 @@ function UserOverviewTab() { const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState('asc'); + const [purgeTarget, setPurgeTarget] = useState(null); + const [purging, setPurging] = useState(false); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); const { data: users, isLoading, isError } = useQuery({ queryKey: ['admin', 'users'], @@ -90,6 +104,21 @@ function UserOverviewTab() { return result; }, [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) { return ; } @@ -148,6 +177,7 @@ function UserOverviewTab() { Letzter Login + Aktionen @@ -177,11 +207,35 @@ function UserOverviewTab() { /> {formatRelativeTime(user.last_login_at)} + + + setPurgeTarget(user)}> + + + + ))} + + !purging && setPurgeTarget(null)}> + Benutzerdaten löschen? + + + Möchtest du alle persönlichen Daten von {purgeTarget?.name} löschen? + Das Benutzerkonto bleibt erhalten, aber Profil, Benachrichtigungen und Buchungen werden + unwiderruflich entfernt. + + + + + + + ); } diff --git a/frontend/src/components/auth/FirstLoginDialog.tsx b/frontend/src/components/auth/FirstLoginDialog.tsx new file mode 100644 index 0000000..ea26b2b --- /dev/null +++ b/frontend/src/components/auth/FirstLoginDialog.tsx @@ -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 ( + + Willkommen! + + + 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. + + setStandesbuchnummer(e.target.value)} + fullWidth + size="small" + placeholder="z.B. 12345" + inputProps={{ maxLength: 32 }} + /> + + + + + + + ); +}; diff --git a/frontend/src/components/auth/LoginCallback.tsx b/frontend/src/components/auth/LoginCallback.tsx index 85d86e1..e723b5f 100644 --- a/frontend/src/components/auth/LoginCallback.tsx +++ b/frontend/src/components/auth/LoginCallback.tsx @@ -2,12 +2,15 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material'; +import { FirstLoginDialog } from './FirstLoginDialog'; const LoginCallback: React.FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { login } = useAuth(); + const { login, user } = useAuth(); const [error, setError] = useState(''); + const [showFirstLogin, setShowFirstLogin] = useState(false); + const [redirectTo, setRedirectTo] = useState('/dashboard'); const hasCalledLogin = useRef(false); useEffect(() => { @@ -29,7 +32,7 @@ const LoginCallback: React.FC = () => { } try { - await login(code); + const result = await login(code); // Navigate to the originally intended page, falling back to the dashboard. // Validate that the stored path is a safe internal path: must start with '/' // but must NOT start with '//' (protocol-relative redirect). @@ -39,7 +42,13 @@ const LoginCallback: React.FC = () => { ? rawFrom : '/dashboard'; sessionStorage.removeItem('auth_redirect_from'); - navigate(from, { replace: true }); + + if (result.isNewUser && !localStorage.getItem('firstLoginCompleted')) { + setRedirectTo(from); + setShowFirstLogin(true); + } else { + navigate(from, { replace: true }); + } } catch (err) { console.error('Login callback error:', err); const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429; @@ -99,10 +108,20 @@ const LoginCallback: React.FC = () => { gap: 2, }} > - - - Anmeldung wird abgeschlossen... - + {showFirstLogin && user ? ( + navigate(redirectTo, { replace: true })} + /> + ) : ( + <> + + + Anmeldung wird abgeschlossen... + + + )} ); }; diff --git a/frontend/src/components/common/PageBreadcrumbs.tsx b/frontend/src/components/common/PageBreadcrumbs.tsx new file mode 100644 index 0000000..1195bfc --- /dev/null +++ b/frontend/src/components/common/PageBreadcrumbs.tsx @@ -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 ( + } + sx={{ mb: 2, fontSize: '0.8125rem' }} + > + {items.map((item, index) => { + const isLast = index === items.length - 1; + if (isLast || !item.href) { + return ( + + {item.label} + + ); + } + return ( + + {item.label} + + ); + })} + + ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts new file mode 100644 index 0000000..ecbdaad --- /dev/null +++ b/frontend/src/components/common/index.ts @@ -0,0 +1,2 @@ +export { PageBreadcrumbs } from './PageBreadcrumbs'; +export type { BreadcrumbItem } from './PageBreadcrumbs'; diff --git a/frontend/src/components/dashboard/NextcloudTalkWidget.tsx b/frontend/src/components/dashboard/NextcloudTalkWidget.tsx index f7ad41c..eec7f16 100644 --- a/frontend/src/components/dashboard/NextcloudTalkWidget.tsx +++ b/frontend/src/components/dashboard/NextcloudTalkWidget.tsx @@ -1,7 +1,5 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { - Card, - CardContent, Typography, Box, Chip, @@ -20,6 +18,7 @@ import { nextcloudApi } from '../../services/nextcloud'; import type { NextcloudConversation } from '../../types/nextcloud.types'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; import { useCountUp } from '../../hooks/useCountUp'; +import { WidgetCard } from '../templates/WidgetCard'; const POLL_INTERVAL = 2000; const POLL_TIMEOUT = 5 * 60 * 1000; @@ -183,39 +182,34 @@ const NextcloudTalkWidget: React.FC = () => { } }; - return ( - - - - - - Nextcloud Talk - - {connected && totalUnread > 0 && ( - - )} - {connected && ( - - - - - - )} - + const headerAction = ( + + {connected && totalUnread > 0 && ( + + )} + {connected && ( + + + + + + )} + + ); + return ( + } + action={headerAction} + noPadding + > + {isLoading && ( {[1, 2, 3].map((n) => ( @@ -276,8 +270,8 @@ const NextcloudTalkWidget: React.FC = () => { ))} )} - - + + ); }; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 41ffe30..62812ab 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ 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 { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage'; import { useNotification } from './NotificationContext'; @@ -67,11 +67,11 @@ export const AuthProvider: React.FC = ({ children }) => { initializeAuth(); }, []); - const login = useCallback(async (code: string): Promise => { + const login = useCallback(async (code: string): Promise => { try { 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 setToken(token); @@ -88,6 +88,8 @@ export const AuthProvider: React.FC = ({ children }) => { // Show success notification notification.showSuccess('Anmeldung erfolgreich'); + + return { isNewUser }; } catch (error) { console.error('Login failed:', error); setState({ diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index aeda6bb..ffdb016 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -32,6 +32,7 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Navigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { settingsApi } from '../services/settings'; @@ -293,6 +294,10 @@ function AdminSettings() { return ( + Admin-Einstellungen diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index 97571e6..9bee17f 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -779,6 +779,10 @@ function AusruestungDetailPage() { diff --git a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx index cd2e98a..a2f945a 100644 --- a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx @@ -11,6 +11,7 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; @@ -285,6 +286,10 @@ export default function AusruestungsanfrageArtikelDetail() { return ( + {/* Header */} navigate('/ausruestungsanfrage?tab=2')}> diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx index cdfc3f1..d3a5517 100644 --- a/frontend/src/pages/AusruestungsanfrageDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -13,6 +13,7 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; @@ -193,6 +194,10 @@ export default function AusruestungsanfrageDetail() { return ( + {/* Header */} navigate('/ausruestungsanfrage')}> diff --git a/frontend/src/pages/AusruestungsanfrageNeu.tsx b/frontend/src/pages/AusruestungsanfrageNeu.tsx index 5201112..dac85ae 100644 --- a/frontend/src/pages/AusruestungsanfrageNeu.tsx +++ b/frontend/src/pages/AusruestungsanfrageNeu.tsx @@ -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 { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; @@ -168,6 +169,10 @@ export default function AusruestungsanfrageNeu() { return ( + {/* Header */} navigate('/ausruestungsanfrage')}> diff --git a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx index 97b9fd7..44abaeb 100644 --- a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx +++ b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx @@ -14,6 +14,7 @@ import { import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; @@ -236,6 +237,11 @@ export default function AusruestungsanfrageZuBestellung() { return ( + {/* ── Header ── */} diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 875b9b0..cf3c2ba 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -673,6 +673,10 @@ export default function BestellungDetail() { {canExport && !editMode && ( diff --git a/frontend/src/pages/BuchhaltungBankkontoDetail.tsx b/frontend/src/pages/BuchhaltungBankkontoDetail.tsx index aa090ff..aae5f8d 100644 --- a/frontend/src/pages/BuchhaltungBankkontoDetail.tsx +++ b/frontend/src/pages/BuchhaltungBankkontoDetail.tsx @@ -23,6 +23,7 @@ import { ArrowBack } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { buchhaltungApi } from '../services/buchhaltung'; import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types'; import type { BankkontoStatementRow } from '../types/buchhaltung.types'; @@ -61,6 +62,10 @@ export default function BuchhaltungBankkontoDetail() { return ( + navigate('/buchhaltung?tab=2')}> diff --git a/frontend/src/pages/BuchhaltungKontoDetail.tsx b/frontend/src/pages/BuchhaltungKontoDetail.tsx index 7b852ee..697edcb 100644 --- a/frontend/src/pages/BuchhaltungKontoDetail.tsx +++ b/frontend/src/pages/BuchhaltungKontoDetail.tsx @@ -9,6 +9,7 @@ import { } from '@mui/material'; import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { buchhaltungApi } from '../services/buchhaltung'; import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types'; import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types'; @@ -132,6 +133,10 @@ export default function BuchhaltungKontoDetail() { return ( + diff --git a/frontend/src/pages/EinsatzDetail.tsx b/frontend/src/pages/EinsatzDetail.tsx index 1e10c97..e5bafa4 100644 --- a/frontend/src/pages/EinsatzDetail.tsx +++ b/frontend/src/pages/EinsatzDetail.tsx @@ -284,6 +284,10 @@ function EinsatzDetail() { title={`Einsatz ${einsatz.einsatz_nr}`} subtitle={address || undefined} backTo="/einsaetze" + breadcrumbs={[ + { label: 'Einsätze', href: '/einsaetze' }, + { label: `Einsatz ${einsatz.einsatz_nr}` }, + ]} actions={ diff --git a/frontend/src/pages/HaushaltsplanDetail.tsx b/frontend/src/pages/HaushaltsplanDetail.tsx index e5b0b0e..d4e53cb 100644 --- a/frontend/src/pages/HaushaltsplanDetail.tsx +++ b/frontend/src/pages/HaushaltsplanDetail.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { Alert, Box, @@ -143,6 +144,10 @@ export default function HaushaltsplanDetail() { return ( + navigate('/haushaltsplan')}> {planung.bezeichnung} diff --git a/frontend/src/pages/IssueDetail.tsx b/frontend/src/pages/IssueDetail.tsx index 7839297..cea5c13 100644 --- a/frontend/src/pages/IssueDetail.tsx +++ b/frontend/src/pages/IssueDetail.tsx @@ -264,6 +264,10 @@ export default function IssueDetail() { {/* Header card */} diff --git a/frontend/src/pages/UebungDetail.tsx b/frontend/src/pages/UebungDetail.tsx index c4c23d8..fd207c7 100644 --- a/frontend/src/pages/UebungDetail.tsx +++ b/frontend/src/pages/UebungDetail.tsx @@ -42,6 +42,7 @@ import { } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageBreadcrumbs } from '../components/common'; import { usePermissionContext } from '../contexts/PermissionContext'; import { trainingApi } from '../services/training'; import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types'; @@ -332,6 +333,10 @@ export default function UebungDetail() { return ( + {/* Back button */}