From dd5cd71fd1c05797f239e9ef7976cb26fabc7cbd Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 13 Apr 2026 15:06:34 +0200 Subject: [PATCH] feat(dashboard,admin): widget group customization and FDISK data purge --- .../083_allow_null_profile_status.sql | 28 ++ backend/src/routes/admin.routes.ts | 71 +++- .../components/admin/DataManagementTab.tsx | 88 ++++- frontend/src/components/admin/DebugTab.tsx | 92 +----- .../src/components/dashboard/WidgetGroup.tsx | 59 +++- frontend/src/pages/Dashboard.tsx | 307 ++++++++++++++++-- frontend/src/services/admin.ts | 2 +- 7 files changed, 491 insertions(+), 156 deletions(-) create mode 100644 backend/src/database/migrations/083_allow_null_profile_status.sql diff --git a/backend/src/database/migrations/083_allow_null_profile_status.sql b/backend/src/database/migrations/083_allow_null_profile_status.sql new file mode 100644 index 0000000..6d3225a --- /dev/null +++ b/backend/src/database/migrations/083_allow_null_profile_status.sql @@ -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' + )); diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 57fffa2..3da500a 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -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 => { + 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(); } } ); diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx index 7060b74..50e8809 100644 --- a/frontend/src/components/admin/DataManagementTab.tsx +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -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({ + queryKey: ['admin', 'users'], + queryFn: adminApi.getUsers, + }); + const [selectedUser, setSelectedUser] = useState(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>(() => 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. + {/* FDISK data purge */} + + + FDISK-Daten eines Benutzers loeschen + + + Loescht alle FDISK-synchronisierten Daten eines Benutzers: Profilfelder, Ausbildungen, + Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden + die Daten erneut importiert. + + + + setSelectedUser(v)} + getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`} + isOptionEqualToValue={(a, b) => a.id === b.id} + sx={{ minWidth: 320, flex: 1 }} + renderInput={(params) => ( + + )} + /> + + + + + + !purging && setPurgeConfirmOpen(false)} + onConfirm={handlePurge} + title="FDISK-Daten loeschen?" + message={selectedUser ? ( + <> + Alle FDISK-synchronisierten Daten fuer {selectedUser.name ?? selectedUser.email} werden geloescht: +
    +
  • Profilfelder (Status, Dienstgrad, Geburtsdatum, Geburtsort, Geschlecht, Beruf, Wohnort, PLZ)
  • +
  • Alle Ausbildungen, Befoerderungen, Untersuchungen und Fahrgenehmigungen
  • +
+ 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 ( diff --git a/frontend/src/components/admin/DebugTab.tsx b/frontend/src/components/admin/DebugTab.tsx index d8f421a..7edc95c 100644 --- a/frontend/src/components/admin/DebugTab.tsx +++ b/frontend/src/components/admin/DebugTab.tsx @@ -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({ - queryKey: ['admin', 'users'], - queryFn: adminApi.getUsers, - }); - - const [selectedUser, setSelectedUser] = useState(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(null); const [force, setForce] = useState(false); @@ -92,42 +63,6 @@ export default function DebugTab() { Werkzeuge fuer Fehlersuche und Datenbereinigung. - {/* Profile deletion */} - - - Profildaten loeschen - - - Loescht die synchronisierten Profildaten (mitglieder_profile) eines Benutzers. - Beim naechsten Login werden die Daten erneut von Authentik und FDISK synchronisiert. - - - - setSelectedUser(v)} - getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`} - isOptionEqualToValue={(a, b) => a.id === b.id} - sx={{ minWidth: 320, flex: 1 }} - renderInput={(params) => ( - - )} - /> - - - - - {/* FDISK Sync */} }> @@ -231,29 +166,6 @@ export default function DebugTab() { - - {/* Delete confirmation dialog */} - !deleting && setConfirmOpen(false)}> - Profildaten loeschen? - - - Profildaten fuer {selectedUser?.name || selectedUser?.email} werden geloescht. - Beim naechsten Login werden die Daten erneut synchronisiert. - - - - - - - ); } diff --git a/frontend/src/components/dashboard/WidgetGroup.tsx b/frontend/src/components/dashboard/WidgetGroup.tsx index 82bedf6..0b58aec 100644 --- a/frontend/src/components/dashboard/WidgetGroup.tsx +++ b/frontend/src/components/dashboard/WidgetGroup.tsx @@ -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 ( - - {title} - + + {title} + + {editMode && onDelete && ( + + + + )} + = { +const DEFAULT_ORDER: Record = { 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(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 = useMemo(() => ({ + const widgetDefs: Record = useMemo(() => ({ status: [ { key: 'vehicles', widgetKey: 'vehicles', permission: 'fahrzeuge:widget', component: }, { key: 'equipment', widgetKey: 'equipment', permission: 'ausruestung:widget', component: }, @@ -144,13 +158,13 @@ function Dashboard() { }), []); // Widget order from preferences, falling back to defaults - const [localOrder, setLocalOrder] = useState>(DEFAULT_ORDER); + const [localOrder, setLocalOrder] = useState>(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(); + 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(() => { - queryClient.invalidateQueries({ queryKey: ['user-preferences'] }); - }); - break; + setLocalOrder((prev) => ({ ...prev, [sourceGroup]: newOrder })); } } - }, [localOrder, queryClient]); + // 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'] }); + }); + 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); + } + + 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 ( - + {linksVisible && linkCollections.map((collection) => ( @@ -244,13 +393,16 @@ function Dashboard() { ); } - if (keys.length === 0) return null; + if (keys.length === 0 && !editMode) return null; return ( g.name === group)!.title} + title={title} gridColumn="1 / -1" key={group} + groupId={group} + editMode={editMode} + onDelete={isCustomGroup ? () => handleDeleteGroup(group) : undefined} > {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 ( @@ -276,7 +441,9 @@ function Dashboard() { )} - {GROUP_ORDER.map((g) => renderGroup(g.name))} + {groupOrder.map((g) => renderGroup(g.name, g.title))} + + + {activeId ? ( + + {(() => { + for (const defs of Object.values(widgetDefs)) { + const def = defs.find((d) => d.key === activeId); + if (def?.component) return def.component; + } + return null; + })()} + + ) : null} + {/* Edit mode toggle — bottom */} - + + {editMode && ( + <> + + + + )} + + + + + {/* Reset confirmation dialog */} + 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" + /> ); } diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts index 22bf5d9..e1d619f 100644 --- a/frontend/src/services/admin.ts +++ b/frontend/src/services/admin.ts @@ -30,5 +30,5 @@ export const adminApi = { getPingHistory: (serviceId: string) => api.get>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data), fdiskSyncLogs: () => api.get>('/api/admin/fdisk-sync/logs').then(r => r.data.data), fdiskSyncTrigger: (force = false) => api.post>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data), - deleteUserProfile: (userId: string) => api.delete>(`/api/admin/debug/user/${userId}/profile`).then(r => r.data), + purgeFdiskData: (userId: string) => api.delete>(`/api/admin/users/${userId}/fdisk-data`).then(r => r.data.data), };