feat: move dashboard edit button to bottom with label, replace menu order arrows with drag-and-drop
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
<List disablePadding>
|
<DndContext
|
||||||
{orderedNavItems.map((item, idx) => (
|
sensors={navSensors}
|
||||||
<ListItem
|
collisionDetection={closestCenter}
|
||||||
key={item.path}
|
onDragEnd={handleNavDragEnd}
|
||||||
disablePadding
|
>
|
||||||
sx={{ py: 0.25 }}
|
<SortableContext
|
||||||
secondaryAction={
|
items={localNavItems.map((i) => i.path)}
|
||||||
<Box>
|
strategy={verticalListSortingStrategy}
|
||||||
<IconButton
|
>
|
||||||
size="small"
|
<List disablePadding>
|
||||||
onClick={() => moveNavItem(idx, idx - 1)}
|
{localNavItems.map((item) => (
|
||||||
disabled={idx === 0 || mutation.isPending}
|
<SortableNavItem key={item.path} id={item.path} text={item.text} />
|
||||||
aria-label="Nach oben"
|
))}
|
||||||
>
|
</List>
|
||||||
<ArrowUpward fontSize="small" />
|
</SortableContext>
|
||||||
</IconButton>
|
</DndContext>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user