Files
pn-new-crm/src/components/dashboard/customize-widgets-menu.tsx
Matt e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

- next-intl ripped out (zero useTranslations callers ever existed):
  package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
  the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
  Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
  border-r/rounded-l-/rounded-r-) inside JSX className literals.
  Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
  label enabled at warn; 4 empty <th>/<td> action placeholders gain
  sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
  new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
  CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
  to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
  lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
  "Payments - Not received yet" or "Payments - \$X received - N payments
  - Expand"; per-interest collapse state persists in localStorage; the
  RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
  text-muted-foreground/{60,70,80} hits dropped to plain
  text-muted-foreground for AA contrast on muted bg. Icon-only
  (aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
  across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
  rewritten with cumulative state through today. Items genuinely still
  open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
  pixel E2E verification, and website-cutover work parked here so
  they don't get lost in the CRM audit doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00

379 lines
13 KiB
TypeScript

'use client';
import { 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, LayoutGrid } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
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, WidgetGroup } from './widget-registry';
// The dashboard renders widgets in three independent visual regions at
// xl (1280+): charts (main column), rails (right aside), feed (full-
// width). Below xl, all three regions stack into one visual column -
// from the rep's eye it reads as a single ordered list, so the modal
// flattens its sortable in that tier. At xl it splits into three
// region-scoped sortables to match the actual side-by-side layout.
const GROUP_LABELS: Record<WidgetGroup, string> = {
chart: 'Charts',
rail: 'Side rail',
feed: 'Activity',
};
const GROUP_ORDER: readonly WidgetGroup[] = ['chart', 'rail', 'feed'];
/**
* Combined visibility + reorder picker for the dashboard header.
*
* The dashboard renders widgets in three independent visual regions -
* Charts (main column), Side rail (right aside), Activity (full-width
* feed). A drag across regions can't change the visual outcome, so the
* modal exposes one sortable list per region instead of a single flat
* list that silently fails on cross-region moves. Toggling a widget off
* moves it to the "Hidden" section; toggling on appends it to the
* bottom of its native region.
*
* Both visibility toggles and order changes commit optimistically via
* `useDashboardWidgets` so the dashboard reflows in the background and
* the rep can keep editing.
*/
export function CustomizeWidgetsMenu() {
const [open, setOpen] = useState(false);
const {
allWidgets,
visibleWidgets,
visibility,
isXlLayout,
setVisible,
setAll,
setOrder,
resetToDefaults,
isSaving,
} = useDashboardWidgets();
const visibleCount = Object.values(visibility).filter(Boolean).length;
const allVisible = visibleCount === allWidgets.length;
const allHidden = visibleCount === 0;
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 }),
);
// Visible widgets split per region. Empty regions render nothing so
// we don't show an "On dashboard / Side rail (0)" tease.
const visibleByGroup: Record<WidgetGroup, DashboardWidget[]> = {
chart: visibleWidgets.filter((w) => w.group === 'chart'),
rail: visibleWidgets.filter((w) => w.group === 'rail'),
feed: visibleWidgets.filter((w) => w.group === 'feed'),
};
// A drag inside group X only moves widgets within that group. Rebuild
// the flat order by walking `visibleWidgets` in its current sequence
// and replacing each group-X slot with the next id from the reordered
// group list. This preserves the relative position of every other
// widget - only the dragged group's internal order changes.
function reorderGroup(group: WidgetGroup, oldIndex: number, newIndex: number) {
const groupIds = visibleByGroup[group].map((w) => w.id);
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= groupIds.length ||
newIndex >= groupIds.length
) {
return;
}
const reordered = arrayMove(groupIds, oldIndex, newIndex);
let cursor = 0;
const nextOrder = visibleWidgets.map((w) =>
w.group === group ? (reordered[cursor++] ?? w.id) : w.id,
);
setOrder(nextOrder);
}
function makeDragEndHandler(group: WidgetGroup) {
return (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = visibleByGroup[group].map((w) => w.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
reorderGroup(group, oldIndex, newIndex);
};
}
// Flat reorder used by the stacked layout (< xl). One SortableContext
// over every visible widget; drops persist via setOrder, which the
// hook routes to the mobile order field.
function onFlatDragEnd(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<LayoutGrid className="h-4 w-4" aria-hidden />
Customize
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Customize dashboard</DialogTitle>
<DialogDescription>
{isXlLayout
? 'Editing the desktop layout - drag a widget to reorder it within its region.'
: 'Editing the stacked layout for this device - drag a widget to reorder. Your desktop arrangement is saved separately.'}{' '}
Toggle the switch to show or hide. Hidden widgets leave no empty space - the layout
reflows to fill the available width.
</DialogDescription>
</DialogHeader>
{/* Toggle + reorder list. Capped at ~60vh with internal scroll so
the modal doesn't push the action footer off-screen. The
layout matches what the rep is actually seeing: at xl the
dashboard renders charts | rails | feed as three independent
slots, so the picker exposes three region-scoped sortables.
Below xl everything stacks into one column visually, so the
picker collapses to a single flat sortable that reorders
across the whole list. */}
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
{isXlLayout ? (
GROUP_ORDER.map((group) => {
const widgets = visibleByGroup[group];
if (widgets.length === 0) return null;
return (
<Section key={group} title={`${GROUP_LABELS[group]} (${widgets.length})`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={makeDragEndHandler(group)}
>
<SortableContext
items={widgets.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-1">
{widgets.map((w, idx) => (
<SortableVisibleRow
key={w.id}
widget={w}
position={idx + 1}
disabled={isSaving}
onToggle={(checked) => setVisible(w.id, checked)}
/>
))}
</ul>
</SortableContext>
</DndContext>
</Section>
);
})
) : visibleWidgets.length > 0 ? (
<Section title={`On dashboard (${visibleWidgets.length})`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onFlatDragEnd}
>
<SortableContext
items={visibleWidgets.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-1">
{visibleWidgets.map((w, idx) => (
<SortableVisibleRow
key={w.id}
widget={w}
position={idx + 1}
disabled={isSaving}
onToggle={(checked) => setVisible(w.id, checked)}
/>
))}
</ul>
</SortableContext>
</DndContext>
</Section>
) : null}
{hidden.length > 0 ? (
<Section title={`Hidden (${hidden.length})`}>
<ul className="space-y-1">
{hidden.map((w) => (
<HiddenRow
key={w.id}
widget={w}
disabled={isSaving}
onToggle={(checked) => setVisible(w.id, checked)}
/>
))}
</ul>
</Section>
) : null}
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="text-xs text-muted-foreground sm:order-first">
{visibleCount} of {allWidgets.length} visible
</span>
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<Button
variant="ghost"
size="sm"
disabled={matchesDefaults || isSaving}
onClick={resetToDefaults}
>
Reset to defaults
</Button>
<Button
variant="outline"
size="sm"
disabled={allHidden || isSaving}
onClick={() => setAll(false)}
>
Hide all
</Button>
<Button
variant="outline"
size="sm"
disabled={allVisible || isSaving}
onClick={() => setAll(true)}
>
Show all
</Button>
<Button size="sm" onClick={() => setOpen(false)} className="w-full sm:w-auto">
Done
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="py-2 first:pt-1">
<div className="px-1 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</div>
{children}
</div>
);
}
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 (
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
'flex items-start gap-2 rounded-md border bg-background px-2 py-2.5',
isDragging && 'opacity-60 shadow-md',
)}
>
<button
type="button"
className="mt-0.5 inline-flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-accent active:cursor-grabbing"
aria-label={`Drag handle for ${widget.label}`}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" aria-hidden />
</button>
<span className="mt-1 w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{position}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{widget.label}</div>
<p className="text-xs text-muted-foreground">{widget.description}</p>
</div>
<Switch
aria-label={`Show ${widget.label}`}
checked
disabled={disabled}
onCheckedChange={onToggle}
className="mt-0.5 shrink-0"
/>
</li>
);
}
function HiddenRow({
widget,
disabled,
onToggle,
}: {
widget: DashboardWidget;
disabled: boolean;
onToggle: (checked: boolean) => void;
}) {
return (
<li className="flex items-start gap-3 rounded-md px-3 py-2.5 hover:bg-accent/40">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{widget.label}</div>
<p className="text-xs text-muted-foreground">{widget.description}</p>
</div>
<Switch
aria-label={`Show ${widget.label}`}
checked={false}
disabled={disabled}
onCheckedChange={onToggle}
className="mt-0.5 shrink-0"
/>
</li>
);
}