feat(dashboard,admin): widget group customization and FDISK data purge
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, TextField, Button, Alert,
|
||||
CircularProgress, Divider,
|
||||
CircularProgress, Divider, Autocomplete,
|
||||
} from '@mui/material';
|
||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { api } from '../../services/api';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { ConfirmDialog } from '../templates';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { UserOverview } from '../../types/admin.types';
|
||||
|
||||
interface CleanupSection {
|
||||
key: string;
|
||||
@@ -52,6 +55,32 @@ interface SectionState {
|
||||
export default function DataManagementTab() {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
// ── FDISK purge ──
|
||||
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: adminApi.getUsers,
|
||||
});
|
||||
const [selectedUser, setSelectedUser] = useState<UserOverview | null>(null);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false);
|
||||
|
||||
const handlePurge = useCallback(async () => {
|
||||
if (!selectedUser) return;
|
||||
setPurging(true);
|
||||
try {
|
||||
const result = await adminApi.purgeFdiskData(selectedUser.id);
|
||||
const total = result.profileFieldsCleared + result.ausbildungen + result.befoerderungen + result.untersuchungen + result.fahrgenehmigungen;
|
||||
showSuccess(`${total} FDISK-Eintraege fuer ${selectedUser.name || selectedUser.email} geloescht`);
|
||||
setSelectedUser(null);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Fehler beim Loeschen';
|
||||
showError(msg);
|
||||
} finally {
|
||||
setPurging(false);
|
||||
setPurgeConfirmOpen(false);
|
||||
}
|
||||
}, [selectedUser, showSuccess, showError]);
|
||||
|
||||
const [states, setStates] = useState<Record<string, SectionState>>(() =>
|
||||
Object.fromEntries(SECTIONS.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }]))
|
||||
);
|
||||
@@ -136,6 +165,63 @@ export default function DataManagementTab() {
|
||||
Alte Daten bereinigen, um die Datenbank schlank zu halten. Geloeschte Daten koennen nicht wiederhergestellt werden.
|
||||
</Typography>
|
||||
|
||||
{/* FDISK data purge */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
||||
FDISK-Daten eines Benutzers loeschen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Loescht alle FDISK-synchronisierten Daten eines Benutzers: Profilfelder, Ausbildungen,
|
||||
Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden
|
||||
die Daten erneut importiert.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Autocomplete
|
||||
options={users}
|
||||
loading={usersLoading}
|
||||
value={selectedUser}
|
||||
onChange={(_e, v) => setSelectedUser(v)}
|
||||
getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`}
|
||||
isOptionEqualToValue={(a, b) => a.id === b.id}
|
||||
sx={{ minWidth: 320, flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Benutzer waehlen" size="small" />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!selectedUser || purging}
|
||||
startIcon={purging ? <CircularProgress size={16} /> : <DeleteSweepIcon />}
|
||||
onClick={() => setPurgeConfirmOpen(true)}
|
||||
>
|
||||
FDISK-Daten loeschen
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<ConfirmDialog
|
||||
open={purgeConfirmOpen}
|
||||
onClose={() => !purging && setPurgeConfirmOpen(false)}
|
||||
onConfirm={handlePurge}
|
||||
title="FDISK-Daten loeschen?"
|
||||
message={selectedUser ? (
|
||||
<>
|
||||
Alle FDISK-synchronisierten Daten fuer <strong>{selectedUser.name ?? selectedUser.email}</strong> werden geloescht:
|
||||
<ul style={{ margin: '8px 0', paddingLeft: 20 }}>
|
||||
<li>Profilfelder (Status, Dienstgrad, Geburtsdatum, Geburtsort, Geschlecht, Beruf, Wohnort, PLZ)</li>
|
||||
<li>Alle Ausbildungen, Befoerderungen, Untersuchungen und Fahrgenehmigungen</li>
|
||||
</ul>
|
||||
Die Standesbuchnummer bleibt erhalten. Beim naechsten FDISK-Sync werden die Daten erneut importiert.
|
||||
</>
|
||||
) : ''}
|
||||
confirmLabel="Endgueltig loeschen"
|
||||
confirmColor="error"
|
||||
isLoading={purging}
|
||||
/>
|
||||
|
||||
{SECTIONS.map((section, idx) => {
|
||||
const s = states[section.key];
|
||||
return (
|
||||
|
||||
@@ -1,50 +1,21 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Accordion, AccordionDetails, AccordionSummary,
|
||||
Box, Button, Card, CardContent, Checkbox, Chip, Paper, Typography,
|
||||
Autocomplete, TextField, Dialog, DialogTitle, DialogContent,
|
||||
DialogContentText, DialogActions, CircularProgress, FormControlLabel,
|
||||
Box, Button, Card, CardContent, Checkbox, Chip, Typography,
|
||||
CircularProgress, FormControlLabel,
|
||||
IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import type { UserOverview } from '../../types/admin.types';
|
||||
|
||||
export default function DebugTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
// ── Profile deletion ──
|
||||
const { data: users = [], isLoading: usersLoading } = useQuery<UserOverview[]>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: adminApi.getUsers,
|
||||
});
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<UserOverview | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await adminApi.deleteUserProfile(selectedUser.id);
|
||||
showSuccess(`Profildaten fuer ${selectedUser.name || selectedUser.email} geloescht`);
|
||||
setSelectedUser(null);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Fehler beim Loeschen';
|
||||
showError(msg);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── FDISK Sync ──
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
@@ -92,42 +63,6 @@ export default function DebugTab() {
|
||||
Werkzeuge fuer Fehlersuche und Datenbereinigung.
|
||||
</Typography>
|
||||
|
||||
{/* Profile deletion */}
|
||||
<Paper sx={{ p: 3, mb: 3, maxWidth: 600 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Profildaten loeschen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Loescht die synchronisierten Profildaten (mitglieder_profile) eines Benutzers.
|
||||
Beim naechsten Login werden die Daten erneut von Authentik und FDISK synchronisiert.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Autocomplete
|
||||
options={users}
|
||||
loading={usersLoading}
|
||||
value={selectedUser}
|
||||
onChange={(_e, v) => setSelectedUser(v)}
|
||||
getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`}
|
||||
isOptionEqualToValue={(a, b) => a.id === b.id}
|
||||
sx={{ minWidth: 320, flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Benutzer waehlen" size="small" />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!selectedUser || deleting}
|
||||
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
Profildaten loeschen
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* FDISK Sync */}
|
||||
<Accordion defaultExpanded={false}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
@@ -231,29 +166,6 @@ export default function DebugTab() {
|
||||
</Card>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={confirmOpen} onClose={() => !deleting && setConfirmOpen(false)}>
|
||||
<DialogTitle>Profildaten loeschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Profildaten fuer <strong>{selectedUser?.name || selectedUser?.email}</strong> werden geloescht.
|
||||
Beim naechsten Login werden die Daten erneut synchronisiert.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmOpen(false)} disabled={deleting}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleting}
|
||||
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
|
||||
>
|
||||
{deleting ? 'Wird geloescht...' : 'Loeschen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { Box, Typography, IconButton } from '@mui/material';
|
||||
import { Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
|
||||
interface WidgetGroupProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
gridColumn?: string;
|
||||
groupId?: string;
|
||||
editMode?: boolean;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function WidgetGroup({ title, children, gridColumn }: WidgetGroupProps) {
|
||||
function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete }: WidgetGroupProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: groupId ? `group-${groupId}` : 'group-default',
|
||||
disabled: !editMode,
|
||||
});
|
||||
|
||||
// Count non-null children to hide empty groups
|
||||
const validChildren = React.Children.toArray(children).filter(Boolean);
|
||||
|
||||
if (validChildren.length === 0) return null;
|
||||
if (validChildren.length === 0 && !editMode) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
p: 2.5,
|
||||
pt: 3.5,
|
||||
gridColumn,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.04)',
|
||||
bgcolor: isOver && editMode ? 'rgba(25, 118, 210, 0.04)' : 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid',
|
||||
borderColor: isOver && editMode ? 'primary.light' : 'rgba(0, 0, 0, 0.04)',
|
||||
transition: 'background-color 200ms, border-color 200ms',
|
||||
minHeight: editMode ? 60 : undefined,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -9,
|
||||
left: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'background.default',
|
||||
px: 1.5,
|
||||
py: 0.25,
|
||||
backgroundColor: 'background.default',
|
||||
fontSize: '0.6875rem',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{editMode && onDelete && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onDelete}
|
||||
sx={{ p: 0, ml: 0.5, color: 'text.secondary', '&:hover': { color: 'error.main' } }}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
|
||||
Reference in New Issue
Block a user