feat(dashboard,admin): widget group customization and FDISK data purge

This commit is contained in:
Matthias Hochmeister
2026-04-13 15:06:34 +02:00
parent f4690cf185
commit dd5cd71fd1
7 changed files with 491 additions and 156 deletions

View File

@@ -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(() => {
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 (
<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>
);
}