feat(dashboard,admin): widget group customization and FDISK data purge
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user