feat: move dashboard edit button to bottom with label, replace menu order arrows with drag-and-drop

This commit is contained in:
Matthias Hochmeister
2026-03-28 15:40:03 +01:00
parent 0a912e60b5
commit 443f3569bd
2 changed files with 92 additions and 57 deletions

View File

@@ -3,8 +3,7 @@ import {
Container, Container,
Box, Box,
Fade, Fade,
IconButton, Button,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import { Edit as EditIcon, Check as CheckIcon } from '@mui/icons-material'; import { Edit as EditIcon, Check as CheckIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -272,19 +271,6 @@ function Dashboard() {
<VikunjaOverdueNotifier /> <VikunjaOverdueNotifier />
<AtemschutzExpiryNotifier /> <AtemschutzExpiryNotifier />
{/* Edit mode toggle */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1, px: 1 }}>
<Tooltip title={editMode ? 'Bearbeitung beenden' : 'Widgets anordnen'}>
<IconButton
size="small"
onClick={() => setEditMode((prev) => !prev)}
color={editMode ? 'primary' : 'default'}
>
{editMode ? <CheckIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Container maxWidth={false} disableGutters> <Container maxWidth={false} disableGutters>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -319,6 +305,20 @@ function Dashboard() {
</Box> </Box>
</DndContext> </DndContext>
</Container> </Container>
{/* Edit mode toggle — bottom */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3, mb: 1 }}>
<Button
variant={editMode ? 'contained' : 'outlined'}
size="small"
startIcon={editMode ? <CheckIcon /> : <EditIcon />}
onClick={() => setEditMode((prev) => !prev)}
color={editMode ? 'primary' : 'inherit'}
sx={{ borderRadius: 4, px: 3 }}
>
{editMode ? 'Bearbeitung beenden' : 'Widgets anordnen'}
</Button>
</Box>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -20,9 +20,25 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
} from '@mui/material'; } from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew, ArrowUpward, ArrowDownward, Sort, Restore } from '@mui/icons-material'; import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew, Sort, Restore, DragIndicator } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useThemeMode } from '../contexts/ThemeContext'; import { useThemeMode } from '../contexts/ThemeContext';
import { preferencesApi } from '../services/settings'; import { preferencesApi } from '../services/settings';
@@ -48,9 +64,37 @@ const ORDERABLE_NAV_ITEMS = [
{ text: 'Wissen', path: '/wissen', permission: 'wissen:view' }, { text: 'Wissen', path: '/wissen', permission: 'wissen:view' },
{ text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' }, { text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' },
{ text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' }, { text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' },
{ text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' },
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' }, { text: 'Issues', path: '/issues', permission: 'issues:view_own' },
]; ];
function SortableNavItem({ id, text }: { id: string; text: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<ListItem
ref={setNodeRef}
disablePadding
sx={{
py: 0.25,
opacity: isDragging ? 0.4 : 1,
transform: CSS.Transform.toString(transform),
transition,
}}
>
<IconButton
size="small"
{...attributes}
{...listeners}
sx={{ cursor: 'grab', touchAction: 'none', mr: 0.5 }}
disableRipple
>
<DragIndicator fontSize="small" />
</IconButton>
<ListItemText primary={text} primaryTypographyProps={{ variant: 'body2' }} />
</ListItem>
);
}
function Settings() { function Settings() {
const { themeMode, setThemeMode } = useThemeMode(); const { themeMode, setThemeMode } = useThemeMode();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -96,13 +140,23 @@ function Settings() {
return 0; return 0;
}); });
const moveNavItem = (fromIdx: number, toIdx: number) => { const [localNavItems, setLocalNavItems] = useState(orderedNavItems);
if (toIdx < 0 || toIdx >= orderedNavItems.length) return; useEffect(() => { setLocalNavItems(orderedNavItems); }, [menuOrder.join(',')]);
const newOrder = [...orderedNavItems];
const [moved] = newOrder.splice(fromIdx, 1); const navSensors = useSensors(
newOrder.splice(toIdx, 0, moved); useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleNavDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = localNavItems.findIndex((i) => i.path === active.id);
const newIdx = localNavItems.findIndex((i) => i.path === over.id);
if (oldIdx === -1 || newIdx === -1) return;
const newItems = arrayMove(localNavItems, oldIdx, newIdx);
setLocalNavItems(newItems);
const current = preferences ?? {}; const current = preferences ?? {};
mutation.mutate({ ...current, menuOrder: newOrder.map((i) => i.path) }); mutation.mutate({ ...current, menuOrder: newItems.map((i) => i.path) });
}; };
const resetMenuOrder = () => { const resetMenuOrder = () => {
@@ -270,41 +324,22 @@ function Settings() {
<CircularProgress size={24} /> <CircularProgress size={24} />
</Box> </Box>
) : ( ) : (
<DndContext
sensors={navSensors}
collisionDetection={closestCenter}
onDragEnd={handleNavDragEnd}
>
<SortableContext
items={localNavItems.map((i) => i.path)}
strategy={verticalListSortingStrategy}
>
<List disablePadding> <List disablePadding>
{orderedNavItems.map((item, idx) => ( {localNavItems.map((item) => (
<ListItem <SortableNavItem key={item.path} id={item.path} text={item.text} />
key={item.path}
disablePadding
sx={{ py: 0.25 }}
secondaryAction={
<Box>
<IconButton
size="small"
onClick={() => moveNavItem(idx, idx - 1)}
disabled={idx === 0 || mutation.isPending}
aria-label="Nach oben"
>
<ArrowUpward fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => moveNavItem(idx, idx + 1)}
disabled={idx === orderedNavItems.length - 1 || mutation.isPending}
aria-label="Nach unten"
>
<ArrowDownward fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={item.text}
primaryTypographyProps={{ variant: 'body2' }}
sx={{ pr: 8 }}
/>
</ListItem>
))} ))}
</List> </List>
</SortableContext>
</DndContext>
)} )}
</CardContent> </CardContent>
</Card> </Card>