feat(dashboard,admin): widget group customization and FDISK data purge
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
-- Migration: 083_allow_null_profile_status
|
||||
-- Allow mitglieder_profile.status to be NULL (for FDISK data purge).
|
||||
-- Rollback:
|
||||
-- ALTER TABLE mitglieder_profile DROP CONSTRAINT IF EXISTS mitglieder_profile_status_check;
|
||||
-- ALTER TABLE mitglieder_profile ALTER COLUMN status SET NOT NULL;
|
||||
-- ALTER TABLE mitglieder_profile ALTER COLUMN status SET DEFAULT 'aktiv';
|
||||
-- ALTER TABLE mitglieder_profile ADD CONSTRAINT mitglieder_profile_status_check
|
||||
-- CHECK (status IN ('aktiv','passiv','ehrenmitglied','jugendfeuerwehr','anwärter','ausgetreten'));
|
||||
|
||||
-- 1. Drop existing CHECK constraint
|
||||
ALTER TABLE mitglieder_profile DROP CONSTRAINT IF EXISTS mitglieder_profile_status_check;
|
||||
|
||||
-- 2. Allow NULLs
|
||||
ALTER TABLE mitglieder_profile ALTER COLUMN status DROP NOT NULL;
|
||||
|
||||
-- 3. Remove default
|
||||
ALTER TABLE mitglieder_profile ALTER COLUMN status DROP DEFAULT;
|
||||
|
||||
-- 4. Re-add CHECK allowing NULL
|
||||
ALTER TABLE mitglieder_profile ADD CONSTRAINT mitglieder_profile_status_check
|
||||
CHECK (status IS NULL OR status IN (
|
||||
'aktiv',
|
||||
'passiv',
|
||||
'ehrenmitglied',
|
||||
'jugendfeuerwehr',
|
||||
'anwärter',
|
||||
'ausgetreten'
|
||||
));
|
||||
@@ -368,31 +368,74 @@ router.delete(
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/admin/debug/user/:userId/profile — delete mitglieder_profile row
|
||||
// DELETE /api/admin/users/:userId/fdisk-data — purge FDISK-synced data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/debug/user/:userId/profile',
|
||||
'/users/:userId/fdisk-data',
|
||||
authenticate,
|
||||
requirePermission('admin:write'),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
const targetUserId = req.params.userId;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const result = await pool.query(
|
||||
'DELETE FROM mitglieder_profile WHERE user_id = $1',
|
||||
[userId]
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Null out FDISK-synced profile fields
|
||||
const profileResult = await client.query(
|
||||
`UPDATE mitglieder_profile
|
||||
SET status = NULL,
|
||||
eintrittsdatum = NULL,
|
||||
austrittsdatum = NULL,
|
||||
geburtsdatum = NULL,
|
||||
geburtsort = NULL,
|
||||
geschlecht = NULL,
|
||||
beruf = NULL,
|
||||
wohnort = NULL,
|
||||
plz = NULL,
|
||||
dienstgrad = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1`,
|
||||
[targetUserId]
|
||||
);
|
||||
|
||||
if ((result.rowCount ?? 0) === 0) {
|
||||
res.status(404).json({ success: false, message: 'Kein Profil fuer diesen Benutzer gefunden' });
|
||||
return;
|
||||
}
|
||||
const ausbildungen = await client.query(
|
||||
'DELETE FROM ausbildungen WHERE user_id = $1',
|
||||
[targetUserId]
|
||||
);
|
||||
const befoerderungen = await client.query(
|
||||
'DELETE FROM befoerderungen WHERE user_id = $1',
|
||||
[targetUserId]
|
||||
);
|
||||
const untersuchungen = await client.query(
|
||||
'DELETE FROM untersuchungen WHERE user_id = $1',
|
||||
[targetUserId]
|
||||
);
|
||||
const fahrgenehmigungen = await client.query(
|
||||
'DELETE FROM fahrgenehmigungen WHERE user_id = $1',
|
||||
[targetUserId]
|
||||
);
|
||||
|
||||
logger.info('Admin deleted user profile data', { userId, admin: req.user?.id });
|
||||
res.json({ success: true, message: 'Profildaten geloescht' });
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('Admin purged FDISK data', { targetUserId, adminId: req.user?.id });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
profileFieldsCleared: profileResult.rowCount ?? 0,
|
||||
ausbildungen: ausbildungen.rowCount ?? 0,
|
||||
befoerderungen: befoerderungen.rowCount ?? 0,
|
||||
untersuchungen: untersuchungen.rowCount ?? 0,
|
||||
fahrgenehmigungen: fahrgenehmigungen.rowCount ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete user profile', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Loeschen der Profildaten' });
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Failed to purge FDISK data', { error, targetUserId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Löschen der FDISK-Daten' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{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',
|
||||
|
||||
@@ -4,17 +4,28 @@ import {
|
||||
Box,
|
||||
Fade,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon, Check as CheckIcon } from '@mui/icons-material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Check as CheckIcon,
|
||||
Add as AddIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragStartEvent, DragOverEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
@@ -53,6 +64,7 @@ import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
import { configApi } from '../services/config';
|
||||
import { WidgetKey } from '../constants/widgets';
|
||||
import { ConfirmDialog } from '../components/templates';
|
||||
|
||||
// ── Widget definitions per group ──
|
||||
|
||||
@@ -63,9 +75,7 @@ interface WidgetDef {
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
type GroupName = 'status' | 'kalender' | 'dienste' | 'information';
|
||||
|
||||
const GROUP_ORDER: { name: GroupName; title: string }[] = [
|
||||
const BUILTIN_GROUPS: { name: string; title: string }[] = [
|
||||
{ name: 'status', title: 'Status' },
|
||||
{ name: 'kalender', title: 'Kalender' },
|
||||
{ name: 'dienste', title: 'Dienste' },
|
||||
@@ -73,7 +83,7 @@ const GROUP_ORDER: { name: GroupName; title: string }[] = [
|
||||
];
|
||||
|
||||
// Default widget order per group (used when no preference is set)
|
||||
const DEFAULT_ORDER: Record<GroupName, string[]> = {
|
||||
const DEFAULT_ORDER: Record<string, string[]> = {
|
||||
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung'],
|
||||
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
|
||||
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
|
||||
@@ -86,6 +96,10 @@ function Dashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [addGroupOpen, setAddGroupOpen] = useState(false);
|
||||
const [newGroupTitle, setNewGroupTitle] = useState('');
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
@@ -111,7 +125,7 @@ function Dashboard() {
|
||||
};
|
||||
|
||||
// Build widget definitions for each group
|
||||
const widgetDefs: Record<GroupName, WidgetDef[]> = useMemo(() => ({
|
||||
const widgetDefs: Record<string, WidgetDef[]> = useMemo(() => ({
|
||||
status: [
|
||||
{ key: 'vehicles', widgetKey: 'vehicles', permission: 'fahrzeuge:widget', component: <VehicleDashboardCard /> },
|
||||
{ key: 'equipment', widgetKey: 'equipment', permission: 'ausruestung:widget', component: <EquipmentDashboardCard /> },
|
||||
@@ -144,13 +158,13 @@ function Dashboard() {
|
||||
}), []);
|
||||
|
||||
// Widget order from preferences, falling back to defaults
|
||||
const [localOrder, setLocalOrder] = useState<Record<GroupName, string[]>>(DEFAULT_ORDER);
|
||||
const [localOrder, setLocalOrder] = useState<Record<string, string[]>>(DEFAULT_ORDER);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences?.widgetOrder) {
|
||||
setLocalOrder((prev) => {
|
||||
const merged = { ...prev };
|
||||
for (const group of Object.keys(DEFAULT_ORDER) as GroupName[]) {
|
||||
for (const group of Object.keys(DEFAULT_ORDER)) {
|
||||
if (preferences.widgetOrder[group]) {
|
||||
// Merge: saved order first, then any new widgets not in saved order
|
||||
const saved = preferences.widgetOrder[group] as string[];
|
||||
@@ -160,46 +174,180 @@ function Dashboard() {
|
||||
merged[group] = [...ordered, ...remaining];
|
||||
}
|
||||
}
|
||||
// Include custom groups from preferences
|
||||
if (preferences.customGroups) {
|
||||
for (const cg of preferences.customGroups as { name: string; title: string }[]) {
|
||||
if (!merged[cg.name]) merged[cg.name] = [];
|
||||
if (preferences.widgetOrder[cg.name]) {
|
||||
merged[cg.name] = preferences.widgetOrder[cg.name] as string[];
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
}, [preferences?.widgetOrder]);
|
||||
}, [preferences?.widgetOrder, preferences?.customGroups]);
|
||||
|
||||
// Flat map of all widget defs for cross-group lookup
|
||||
const allWidgetDefs = useMemo(() => {
|
||||
const map = new Map<string, WidgetDef>();
|
||||
for (const defs of Object.values(widgetDefs)) {
|
||||
for (const d of defs) map.set(d.key, d);
|
||||
}
|
||||
return map;
|
||||
}, [widgetDefs]);
|
||||
|
||||
// Get sorted + filtered widgets for a group
|
||||
const getVisibleWidgets = useCallback((group: GroupName) => {
|
||||
const order = localOrder[group];
|
||||
const defs = widgetDefs[group];
|
||||
const getVisibleWidgets = useCallback((group: string) => {
|
||||
const order = localOrder[group] || [];
|
||||
return order
|
||||
.map((key) => defs.find((d) => d.key === key))
|
||||
.map((key) => allWidgetDefs.get(key))
|
||||
.filter((d): d is WidgetDef => {
|
||||
if (!d) return false;
|
||||
if (d.permission && !hasPermission(d.permission)) return false;
|
||||
if (d.widgetKey && !widgetVisible(d.widgetKey)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [localOrder, widgetDefs, hasPermission, preferences]);
|
||||
}, [localOrder, allWidgetDefs, hasPermission, preferences]);
|
||||
|
||||
// Find which group a widget key belongs to
|
||||
const findGroupForWidget = useCallback((widgetId: string): string | undefined => {
|
||||
for (const group of Object.keys(localOrder)) {
|
||||
if (localOrder[group].includes(widgetId)) return group;
|
||||
}
|
||||
return undefined;
|
||||
}, [localOrder]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeWidget = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const sourceGroup = findGroupForWidget(activeWidget);
|
||||
// over.id could be a widget key or a group droppable id (prefixed "group-")
|
||||
let targetGroup: string | undefined;
|
||||
if (overId.startsWith('group-')) {
|
||||
targetGroup = overId.replace('group-', '');
|
||||
} else {
|
||||
targetGroup = findGroupForWidget(overId);
|
||||
}
|
||||
|
||||
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
|
||||
|
||||
// Move widget from source group to target group during drag (live preview)
|
||||
setLocalOrder((prev) => {
|
||||
const sourceItems = prev[sourceGroup].filter((k) => k !== activeWidget);
|
||||
const targetItems = [...prev[targetGroup!]];
|
||||
// Insert at the position of the over item, or at end if dropped on group
|
||||
const overIndex = targetItems.indexOf(overId);
|
||||
if (overIndex >= 0) {
|
||||
targetItems.splice(overIndex, 0, activeWidget);
|
||||
} else {
|
||||
targetItems.push(activeWidget);
|
||||
}
|
||||
return { ...prev, [sourceGroup]: sourceItems, [targetGroup!]: targetItems };
|
||||
});
|
||||
}, [findGroupForWidget]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
// Find which group both items belong to
|
||||
for (const group of Object.keys(localOrder) as GroupName[]) {
|
||||
const order = localOrder[group];
|
||||
const oldIndex = order.indexOf(active.id as string);
|
||||
const newIndex = order.indexOf(over.id as string);
|
||||
const activeWidget = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const sourceGroup = findGroupForWidget(activeWidget);
|
||||
let targetGroup: string | undefined;
|
||||
if (overId.startsWith('group-')) {
|
||||
targetGroup = overId.replace('group-', '');
|
||||
} else {
|
||||
targetGroup = findGroupForWidget(overId);
|
||||
}
|
||||
|
||||
if (!sourceGroup || !targetGroup) return;
|
||||
|
||||
if (sourceGroup === targetGroup) {
|
||||
// Same group reorder
|
||||
const order = localOrder[sourceGroup];
|
||||
const oldIndex = order.indexOf(activeWidget);
|
||||
const newIndex = order.indexOf(overId);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newOrder = arrayMove(order, oldIndex, newIndex);
|
||||
setLocalOrder((prev) => ({ ...prev, [group]: newOrder }));
|
||||
// Persist
|
||||
const updatedOrder = { ...localOrder, [group]: newOrder };
|
||||
preferencesApi.update({ widgetOrder: updatedOrder }).then(() => {
|
||||
setLocalOrder((prev) => ({ ...prev, [sourceGroup]: newOrder }));
|
||||
}
|
||||
}
|
||||
// Cross-group move was already handled in handleDragOver
|
||||
|
||||
// Persist current state
|
||||
// Use a timeout to read the latest localOrder after state updates
|
||||
setTimeout(() => {
|
||||
setLocalOrder((current) => {
|
||||
preferencesApi.update({ widgetOrder: current }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
break;
|
||||
return current;
|
||||
});
|
||||
}, 0);
|
||||
}, [localOrder, queryClient, findGroupForWidget]);
|
||||
|
||||
// ── Add custom group ──
|
||||
const handleAddGroup = useCallback(() => {
|
||||
const title = newGroupTitle.trim();
|
||||
if (!title) return;
|
||||
const name = title.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_');
|
||||
|
||||
// Guard duplicate
|
||||
const existingGroups = [...BUILTIN_GROUPS, ...((preferences?.customGroups as { name: string; title: string }[]) || [])];
|
||||
if (existingGroups.some((g) => g.name === name)) return;
|
||||
|
||||
const updatedCustomGroups = [...((preferences?.customGroups as { name: string; title: string }[]) || []), { name, title }];
|
||||
const updatedOrder = { ...localOrder, [name]: [] };
|
||||
setLocalOrder(updatedOrder);
|
||||
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
setNewGroupTitle('');
|
||||
setAddGroupOpen(false);
|
||||
}, [newGroupTitle, preferences?.customGroups, localOrder, queryClient]);
|
||||
|
||||
// ── Delete custom group — move widgets back to their default groups ──
|
||||
const handleDeleteGroup = useCallback((groupName: string) => {
|
||||
const widgetsToMove = localOrder[groupName] || [];
|
||||
const updatedOrder = { ...localOrder };
|
||||
delete updatedOrder[groupName];
|
||||
|
||||
for (const widgetKey of widgetsToMove) {
|
||||
// Find original default group
|
||||
let targetGroup = 'information';
|
||||
for (const [group, keys] of Object.entries(DEFAULT_ORDER)) {
|
||||
if (keys.includes(widgetKey)) { targetGroup = group; break; }
|
||||
}
|
||||
if (!updatedOrder[targetGroup]) updatedOrder[targetGroup] = [];
|
||||
updatedOrder[targetGroup].push(widgetKey);
|
||||
}
|
||||
}, [localOrder, queryClient]);
|
||||
|
||||
const updatedCustomGroups = ((preferences?.customGroups as { name: string; title: string }[]) || []).filter((g) => g.name !== groupName);
|
||||
setLocalOrder(updatedOrder);
|
||||
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
}, [localOrder, preferences?.customGroups, queryClient]);
|
||||
|
||||
// ── Reset to defaults ──
|
||||
const handleReset = useCallback(() => {
|
||||
setLocalOrder(DEFAULT_ORDER);
|
||||
preferencesApi.update({ widgetOrder: DEFAULT_ORDER, customGroups: [] }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
setResetOpen(false);
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -213,9 +361,10 @@ function Dashboard() {
|
||||
const nextDelay = () => `${baseDelay + (delayCounter++) * 40}ms`;
|
||||
|
||||
// Render a group's widgets
|
||||
const renderGroup = (group: GroupName) => {
|
||||
const renderGroup = (group: string, title: string) => {
|
||||
const visible = getVisibleWidgets(group);
|
||||
const keys = visible.map((d) => d.key);
|
||||
const isCustomGroup = !BUILTIN_GROUPS.some((g) => g.name === group);
|
||||
|
||||
// Special handling for information group (links are dynamic)
|
||||
if (group === 'information') {
|
||||
@@ -225,7 +374,7 @@ function Dashboard() {
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<WidgetGroup title="Information" gridColumn="1 / -1" key="information">
|
||||
<WidgetGroup title={title} gridColumn="1 / -1" key="information" groupId="information" editMode={editMode}>
|
||||
{linksVisible && linkCollections.map((collection) => (
|
||||
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
|
||||
<Box>
|
||||
@@ -244,13 +393,16 @@ function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (keys.length === 0) return null;
|
||||
if (keys.length === 0 && !editMode) return null;
|
||||
|
||||
return (
|
||||
<WidgetGroup
|
||||
title={GROUP_ORDER.find((g) => g.name === group)!.title}
|
||||
title={title}
|
||||
gridColumn="1 / -1"
|
||||
key={group}
|
||||
groupId={group}
|
||||
editMode={editMode}
|
||||
onDelete={isCustomGroup ? () => handleDeleteGroup(group) : undefined}
|
||||
>
|
||||
<SortableContext items={keys} strategy={rectSortingStrategy}>
|
||||
{visible.map((def) => {
|
||||
@@ -268,6 +420,19 @@ function Dashboard() {
|
||||
);
|
||||
};
|
||||
|
||||
// Compute the full group list: built-in + custom groups from preferences
|
||||
const groupOrder = useMemo(() => {
|
||||
const groups = [...BUILTIN_GROUPS];
|
||||
if (preferences?.customGroups) {
|
||||
for (const cg of preferences.customGroups as { name: string; title: string }[]) {
|
||||
if (!groups.some((g) => g.name === cg.name)) {
|
||||
groups.push(cg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [preferences?.customGroups]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<VikunjaOverdueNotifier />
|
||||
@@ -276,7 +441,9 @@ function Dashboard() {
|
||||
<Container maxWidth={false} disableGutters>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Box
|
||||
@@ -303,13 +470,50 @@ function Dashboard() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{GROUP_ORDER.map((g) => renderGroup(g.name))}
|
||||
{groupOrder.map((g) => renderGroup(g.name, g.title))}
|
||||
</Box>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<Box sx={{ opacity: 0.8, transform: 'scale(1.02)', pointerEvents: 'none' }}>
|
||||
{(() => {
|
||||
for (const defs of Object.values(widgetDefs)) {
|
||||
const def = defs.find((d) => d.key === activeId);
|
||||
if (def?.component) return def.component;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Box>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Container>
|
||||
|
||||
{/* Edit mode toggle — bottom */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1.5, mt: 3, mb: 1 }}>
|
||||
{editMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setAddGroupOpen(true)}
|
||||
sx={{ borderRadius: 4, px: 3 }}
|
||||
>
|
||||
Gruppe hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="warning"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => setResetOpen(true)}
|
||||
sx={{ borderRadius: 4, px: 3 }}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={editMode ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
@@ -321,6 +525,37 @@ function Dashboard() {
|
||||
{editMode ? 'Bearbeitung beenden' : 'Widgets anordnen'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Add group dialog */}
|
||||
<Dialog open={addGroupOpen} onClose={() => setAddGroupOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Neue Gruppe erstellen</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Gruppenname"
|
||||
value={newGroupTitle}
|
||||
onChange={(e) => setNewGroupTitle(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGroup(); }}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setAddGroupOpen(false); setNewGroupTitle(''); }}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={handleAddGroup} disabled={!newGroupTitle.trim()}>Hinzufügen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
onConfirm={handleReset}
|
||||
title="Layout zurücksetzen?"
|
||||
message="Das Dashboard-Layout wird auf die Standardanordnung zurückgesetzt. Alle eigenen Gruppen und Widget-Verschiebungen werden gelöscht."
|
||||
confirmLabel="Zurücksetzen"
|
||||
confirmColor="warning"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@ export const adminApi = {
|
||||
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).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),
|
||||
deleteUserProfile: (userId: string) => api.delete<ApiResponse<{ message: string }>>(`/api/admin/debug/user/${userId}/profile`).then(r => r.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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user