diff --git a/src/components/dashboard/customize-widgets-menu.tsx b/src/components/dashboard/customize-widgets-menu.tsx
index ae7bf57a..eb656310 100644
--- a/src/components/dashboard/customize-widgets-menu.tsx
+++ b/src/components/dashboard/customize-widgets-menu.tsx
@@ -1,7 +1,24 @@
'use client';
import { useState } from 'react';
-import { LayoutGrid } from 'lucide-react';
+import {
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ arrayMove,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { GripVertical, LayoutGrid } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -14,29 +31,64 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
+import { cn } from '@/lib/utils';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
+import type { DashboardWidget } from './widget-registry';
/**
- * Modal widget picker for the dashboard header. Replaced the original
- * dropdown menu because 13 widgets + 3 footer buttons made the dropdown
- * cramped and hid the descriptions reps need to know what each card
- * actually shows.
+ * Combined visibility + reorder picker for the dashboard header. Two
+ * sections in one modal:
*
- * Backed by the same `useDashboardWidgets` hook that drives the
- * Settings card — toggles update both surfaces optimistically.
+ * 1. "On dashboard" — visible widgets, each row with a drag handle
+ * (reorder via dnd-kit single SortableContext, no buckets); flipping
+ * a switch off moves the row to section 2.
+ * 2. "Hidden" — widgets currently off; flipping a switch on appends to
+ * the bottom of section 1.
+ *
+ * Both visibility toggles and order changes commit optimistically via
+ * `useDashboardWidgets` so the dashboard reflows in the background and
+ * the rep can keep editing. The "Rearrange" button on the header is
+ * gone — order lives here too now, keeping all dashboard layout
+ * controls in one place.
*/
export function CustomizeWidgetsMenu() {
const [open, setOpen] = useState(false);
- const { allWidgets, visibility, setVisible, setAll, resetToDefaults, isSaving } =
- useDashboardWidgets();
+ const {
+ allWidgets,
+ visibleWidgets,
+ visibility,
+ setVisible,
+ setAll,
+ setOrder,
+ resetToDefaults,
+ isSaving,
+ } = useDashboardWidgets();
const visibleCount = Object.values(visibility).filter(Boolean).length;
const allVisible = visibleCount === allWidgets.length;
const allHidden = visibleCount === 0;
- // Reset is a no-op when state already matches the registry defaults —
- // disable in that case to avoid pointless API round-trips.
const matchesDefaults = allWidgets.every((w) => (visibility[w.id] ?? false) === w.defaultVisible);
+ // Hidden = everything not currently rendered. Sorted by registry order
+ // so it reads predictably (newly-added widgets appear at the bottom
+ // until the rep explicitly enables them).
+ const hidden = allWidgets.filter((w) => !visibility[w.id]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ function onDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const ids = visibleWidgets.map((w) => w.id);
+ const oldIndex = ids.indexOf(String(active.id));
+ const newIndex = ids.indexOf(String(over.id));
+ if (oldIndex === -1 || newIndex === -1) return;
+ setOrder(arrayMove(ids, oldIndex, newIndex));
+ }
+
return (
);
}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function SortableVisibleRow({
+ widget,
+ position,
+ disabled,
+ onToggle,
+}: {
+ widget: DashboardWidget;
+ position: number;
+ disabled: boolean;
+ onToggle: (checked: boolean) => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: widget.id,
+ });
+ return (
+
+
+
+ {position}
+
+
+
{widget.label}
+
{widget.description}
+
+
+
+ );
+}
+
+function HiddenRow({
+ widget,
+ disabled,
+ onToggle,
+}: {
+ widget: DashboardWidget;
+ disabled: boolean;
+ onToggle: (checked: boolean) => void;
+}) {
+ return (
+
+
+
{widget.label}
+
{widget.description}
+
+
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx
index 1e36390c..bded6d70 100644
--- a/src/components/dashboard/dashboard-shell.tsx
+++ b/src/components/dashboard/dashboard-shell.tsx
@@ -2,17 +2,14 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
-import { Move } from 'lucide-react';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
import { apiFetch } from '@/lib/api/client';
import { PageHeader } from '@/components/shared/page-header';
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button';
-import { Button } from '@/components/ui/button';
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
import { DateRangePicker } from './date-range-picker';
-import { RearrangeWidgetsDialog } from './rearrange-widgets-dialog';
import { TimezoneDriftBanner } from './timezone-drift-banner';
import { WidgetErrorBoundary } from './widget-error-boundary';
import type { DashboardWidget } from './widget-registry';
@@ -77,12 +74,8 @@ export function DashboardShell({
initialWidgetVisibility,
}: DashboardShellProps = {}) {
const [range, setRange] = useState('30d');
- // Rearrange dialog — the in-place drag scaffolding was retired in
- // favour of a modal sortable list (single SortableContext, no bucket
- // constraints, keyboard-accessible). See rearrange-widgets-dialog.tsx.
- const [rearrangeOpen, setRearrangeOpen] = useState(false);
- const { visibleWidgets, setOrder } = useDashboardWidgets({
+ const { visibleWidgets } = useDashboardWidgets({
initialVisibility: initialWidgetVisibility ?? null,
});
@@ -178,17 +171,6 @@ export function DashboardShell({
-
}
@@ -243,13 +225,6 @@ export function DashboardShell({
))}
{visibleWidgets.length === 0 ? : null}
-
- setOrder(orderedIds)}
- />
);
}
diff --git a/src/components/dashboard/rearrange-widgets-dialog.tsx b/src/components/dashboard/rearrange-widgets-dialog.tsx
deleted file mode 100644
index 4d67a808..00000000
--- a/src/components/dashboard/rearrange-widgets-dialog.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import {
- DndContext,
- KeyboardSensor,
- PointerSensor,
- closestCenter,
- useSensor,
- useSensors,
- type DragEndEvent,
-} from '@dnd-kit/core';
-import {
- SortableContext,
- arrayMove,
- sortableKeyboardCoordinates,
- useSortable,
- verticalListSortingStrategy,
-} from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
-import { GripVertical } from 'lucide-react';
-
-import { Button } from '@/components/ui/button';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { cn } from '@/lib/utils';
-import type { DashboardWidget } from './widget-registry';
-
-interface Props {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- widgets: readonly DashboardWidget[];
- onSave: (orderedIds: string[]) => void;
-}
-
-/**
- * Single-flat-list reorder dialog. Replaces the in-place drag-on-canvas
- * that struggled with cross-bucket drops + dnd-kit's collision-detection
- * losing the drop target on long drags. Here the list is one
- * SortableContext, vertically arranged, with a drag handle per row —
- * predictable behaviour, keyboard-accessible (arrow keys with focus on
- * a handle), no bucket constraints to learn.
- */
-export function RearrangeWidgetsDialog({ open, onOpenChange, widgets, onSave }: Props) {
- // Local draft so the rep can cancel without committing. Seeded each
- // time the dialog opens so a re-open after an external order change
- // (e.g. another tab) doesn't show stale data.
- const [ids, setIds] = useState(() => widgets.map((w) => w.id));
-
- useEffect(() => {
- if (open) {
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setIds(widgets.map((w) => w.id));
- }
- }, [open, widgets]);
-
- const sensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
- );
-
- function onDragEnd(event: DragEndEvent) {
- const { active, over } = event;
- if (!over || active.id === over.id) return;
- const oldIndex = ids.indexOf(String(active.id));
- const newIndex = ids.indexOf(String(over.id));
- if (oldIndex === -1 || newIndex === -1) return;
- setIds((prev) => arrayMove(prev, oldIndex, newIndex));
- }
-
- function handleSave() {
- onSave(ids);
- onOpenChange(false);
- }
-
- // Lookup label by id so the row renders the same label as the widget
- // registry exposes — keeps the dialog in sync if labels change.
- const widgetById = new Map(widgets.map((w) => [w.id, w]));
-
- return (
-
- );
-}
-
-function Row({ id, index, label }: { id: string; index: number; label: string }) {
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
- id,
- });
- return (
-
-
-
- {index + 1}
-
- {label}
-
- );
-}