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:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
frontend/src/components/auth/FirstLoginDialog.tsx
Normal file
74
frontend/src/components/auth/FirstLoginDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
44
frontend/src/components/common/PageBreadcrumbs.tsx
Normal file
44
frontend/src/components/common/PageBreadcrumbs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
frontend/src/components/common/index.ts
Normal file
2
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PageBreadcrumbs } from './PageBreadcrumbs';
|
||||||
|
export type { BreadcrumbItem } from './PageBreadcrumbs';
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user