feat(uat-batch): Group N — dashboard upgrades

N44, N45, N46 from the 2026-05-21 plan.

Shipped:
  N44  Pipeline Value tile respects dashboard timeframe. Tile accepts
       optional `range` prop and threads it through
       /api/v1/dashboard/kpis?range=<slug> + /forecast?range=<slug>.
       Service functions accept optional {from,to} bounds and scope
       the pipeline-value SQL to interests created within the window.
       New parseRangeSlug helper inverts rangeToSlug. Widget registry
       forwards the active dashboard range to the tile.
  N45  Clients by country widget. New GET
       /api/v1/dashboard/clients-by-country groups non-archived
       clients by nationality_iso. <ClientsByCountryWidget> renders a
       compact ranked list with mini-bars; rows link to
       /clients?nationality=<ISO>. Registered as default-visible rail.
  N46  Drag-and-drop dashboard widgets. New
       preferences.dashboardWidgetOrder?: string[] on user_profiles;
       useDashboardWidgets sorts visibleWidgets by the order
       (unlisted ids fall through to registry order) and exposes
       setOrder(nextOrder) that PATCHes optimistically.
       DashboardShell wires @dnd-kit/core + sortable: Rearrange toggle
       turns on per-widget grip handles + sortable-context wraps each
       group (charts / rails / feed) so drops stay in-group.
       PointerSensor 8px activation distance, KeyboardSensor for a11y.
       New <SortableWidget> wraps the render — zero footprint when
       off.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:32:21 +02:00
parent 0ddaf462c7
commit a147cbcd93
11 changed files with 529 additions and 51 deletions

View File

@@ -0,0 +1,139 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Globe } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { getCountryName } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
interface ClientsByCountryRow {
country: string;
count: number;
}
interface ClientsByCountryResponse {
data: ClientsByCountryRow[];
total: number;
}
/**
* Compact ranked-list widget showing the per-country distribution of
* non-archived clients. Designed to fit the rail tile footprint (no
* external chart library); the mini-bar per row gives leadership an
* at-a-glance feel for whether the book is concentrated or diverse.
*
* Each row deep-links to `/clients?country=<ISO>` so the rep can drill
* into a specific market. Country names render via the existing
* locale-aware helper; unknown ISO codes fall back to the raw code.
*
* Variant (b) of the master-doc design — a true choropleth would need
* a heavier viz lib (react-simple-maps + topojson) and pushes us to
* the chart-library migration agenda. Variant (a) ships now; the
* world-map variant can land alongside the recharts→ECharts pass.
*/
export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
queryKey: ['dashboard', 'clients-by-country'],
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
staleTime: 60_000,
});
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Clients by country</CardTitle>
<CardDescription>Distribution of the active client book.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-6 w-full" aria-hidden />
))}
</CardContent>
</Card>
);
}
const rows = data?.data ?? [];
const visibleRows = rows.slice(0, limit);
const hiddenCount = Math.max(0, rows.length - limit);
const maxCount = visibleRows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
return (
<Card className="h-full flex flex-col">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Globe className="size-4 text-muted-foreground" aria-hidden />
Clients by country
</CardTitle>
<CardDescription>
{rows.length === 0
? 'No clients with a country recorded yet.'
: `${data?.total ?? rows.reduce((s, r) => s + r.count, 0)} client${rows.length === 1 ? '' : 's'} across ${rows.length} ${rows.length === 1 ? 'country' : 'countries'}.`}
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
{rows.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
Distribution will appear once clients capture a nationality.
</div>
) : (
<ol className="space-y-1.5">
{visibleRows.map((row) => {
const pct = (row.count / maxCount) * 100;
const name = getCountryName(row.country) || row.country;
return (
<li key={row.country}>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as any
}
className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 -mx-2 hover:bg-foreground/5"
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="w-8 shrink-0 text-xs font-mono uppercase text-muted-foreground">
{row.country}
</span>
<span className="truncate text-sm">{name}</span>
</div>
{/* Mini bar — same `BerthHeatWidget` idiom: a thin
background track with a coloured fill. The count
sits on the right so the eye can read both the
bar shape and the precise number. */}
<div className="flex shrink-0 items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full bg-brand-500 transition-[width]')}
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
<span className="w-7 text-right text-xs tabular-nums text-foreground">
{row.count}
</span>
</div>
</Link>
</li>
);
})}
{hiddenCount > 0 ? (
<li className="pt-1 text-xs text-muted-foreground">
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown.
</li>
) : null}
</ol>
)}
</CardContent>
</Card>
);
}

View File

@@ -2,12 +2,32 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, 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 { TimezoneDriftBanner } from './timezone-drift-banner';
@@ -74,11 +94,24 @@ export function DashboardShell({
initialWidgetVisibility,
}: DashboardShellProps = {}) {
const [range, setRange] = useState<DateRange>('30d');
// Rearrange mode — flipped via the Move button in the actions row.
// While on, every WidgetCell renders a drag handle and dragging
// reorders within the group (chart / rail / feed).
const [rearranging, setRearranging] = useState(false);
const { visibleWidgets } = useDashboardWidgets({
const { visibleWidgets, setOrder } = useDashboardWidgets({
initialVisibility: initialWidgetVisibility ?? null,
});
// dnd-kit sensors. Pointer covers mouse + touch + pen via Pointer Events;
// keyboard sensor wires arrow keys for accessibility. activationConstraint
// requires an 8px drag distance before activating so a click on a child
// doesn't accidentally start a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Bucket once so the JSX stays readable. Registry order is preserved
// inside each bucket, so reordering the registry reorders the render.
const charts = visibleWidgets.filter((w) => w.group === 'chart');
@@ -167,6 +200,16 @@ export function DashboardShell({
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<ExportDashboardPdfButton />
<Button
type="button"
size="sm"
variant={rearranging ? 'default' : 'outline'}
onClick={() => setRearranging((r) => !r)}
title={rearranging ? 'Finish rearranging' : 'Rearrange widgets'}
>
<Move className="mr-1.5 h-4 w-4" aria-hidden />
{rearranging ? 'Done' : 'Rearrange'}
</Button>
<CustomizeWidgetsMenu />
</div>
}
@@ -189,36 +232,78 @@ export function DashboardShell({
the row; the rails-only grid uses a slightly tighter `280px`
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
instead of stretching to 600px+ each. */}
{charts.length > 0 && rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
// Determine which group this drag is inside (charts / rails /
// feed) by matching the active id against the bucket lists.
// dnd-kit only triggers onDragEnd when the drop lands inside
// the same SortableContext, so it's safe to assume both ids
// share a bucket.
for (const bucket of [charts, rails, feed]) {
const oldIndex = bucket.findIndex((w) => w.id === active.id);
const newIndex = bucket.findIndex((w) => w.id === over.id);
if (oldIndex === -1 || newIndex === -1) continue;
// Build the new full-list order from the reordered bucket
// plus the other buckets (preserving their order). Persist.
const reordered = arrayMove(bucket, oldIndex, newIndex);
const otherBuckets = [charts, rails, feed].filter((b) => b !== bucket);
const nextOrder = [
...reordered.map((w) => w.id),
...otherBuckets.flatMap((b) => b.map((w) => w.id)),
];
setOrder(nextOrder);
break;
}
}}
>
{charts.length > 0 && rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<SortableContext items={charts.map((w) => w.id)} strategy={rectSortingStrategy}>
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
))}
</div>
</SortableContext>
<aside className="min-w-0 space-y-4">
<SortableContext
items={rails.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
{rails.map((w) => (
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
))}
</SortableContext>
</aside>
</div>
<aside className="min-w-0 space-y-4">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</aside>
</div>
) : charts.length > 0 ? (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
) : rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
) : null}
) : charts.length > 0 ? (
<SortableContext items={charts.map((w) => w.id)} strategy={rectSortingStrategy}>
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
))}
</div>
</SortableContext>
) : rails.length > 0 ? (
<SortableContext items={rails.map((w) => w.id)} strategy={rectSortingStrategy}>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{rails.map((w) => (
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
))}
</div>
</SortableContext>
) : null}
{feed.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
<SortableContext items={feed.map((w) => w.id)} strategy={verticalListSortingStrategy}>
{feed.map((w) => (
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
))}
</SortableContext>
</DndContext>
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
</div>
@@ -243,6 +328,46 @@ function EmptyDashboardHint() {
);
}
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
/**
* Sortable wrapper around the widget render. Renders the widget as-is when
* rearrange mode is off (zero footprint); when on, attaches the dnd-kit
* sortable hooks and exposes a grip handle in the top-right corner.
* The handle is the only drag activator so a rep can still click inside
* the widget without accidentally starting a drag.
*/
function SortableWidget({
widget,
range,
showHandle,
}: {
widget: DashboardWidget;
range: DateRange;
showHandle: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: widget.id,
disabled: !showHandle,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
opacity: isDragging ? 0.6 : undefined,
};
return (
<div ref={setNodeRef} style={style} className="relative">
{showHandle ? (
<button
type="button"
{...attributes}
{...listeners}
aria-label={`Drag to reorder ${widget.label}`}
className="absolute right-2 top-2 z-10 inline-flex h-7 w-7 cursor-grab items-center justify-center rounded-md border bg-background/80 text-muted-foreground shadow-sm backdrop-blur hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" aria-hidden />
</button>
) : null}
<WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Skeleton } from '@/components/ui/skeleton';
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
import { rangeToSlug, type DateRange } from '@/lib/analytics/range';
import { formatCurrency } from '@/lib/utils/currency';
import { cn } from '@/lib/utils';
@@ -55,15 +56,21 @@ const STAGE_BAR_CLASS: Record<string, string> = {
* and `/forecast` for the weighted breakdown. Both share cache entries
* with other widgets so this is mostly free.
*/
export function PipelineValueTile() {
export function PipelineValueTile({ range }: { range?: DateRange } = {}) {
// Range query-string is keyed on the slug ('7d' / 'custom-2026-01-01...').
// When range is undefined, the tile falls back to the "all active deals"
// snapshot — preserves the old behaviour for callers that don't yet
// thread range through.
const slug = range ? rangeToSlug(range) : null;
const qs = slug ? `?range=${encodeURIComponent(slug)}` : '';
const kpis = useQuery<KpiResponse>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
queryKey: ['dashboard', 'kpis', slug],
queryFn: () => apiFetch<KpiResponse>(`/api/v1/dashboard/kpis${qs}`),
staleTime: 60_000,
});
const forecast = useQuery<ForecastResponse>({
queryKey: ['dashboard', 'forecast'],
queryFn: () => apiFetch<ForecastResponse>('/api/v1/dashboard/forecast'),
queryKey: ['dashboard', 'forecast', slug],
queryFn: () => apiFetch<ForecastResponse>(`/api/v1/dashboard/forecast${qs}`),
staleTime: 60_000,
});

View File

@@ -16,6 +16,7 @@ import dynamic from 'next/dynamic';
import { ActiveDealsTile } from './active-deals-tile';
import { ActivityFeed } from './activity-feed';
import { BerthHeatWidget } from './berth-heat-widget';
import { ClientsByCountryWidget } from './clients-by-country-widget';
import { HotDealsCard } from './hot-deals-card';
import { PipelineValueTile } from './pipeline-value-tile';
import { WebsiteGlanceTile } from './website-glance-tile';
@@ -121,7 +122,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
label: 'Pipeline Value',
description:
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
render: () => <PipelineValueTile />,
render: (range) => <PipelineValueTile range={range} />,
// Lives in the chart grid (not the narrow rail) so the per-stage
// breakdown rows have room to breathe alongside the headline numbers,
// and the rail stays reserved for reminders / alerts / glance tiles.
@@ -182,6 +183,18 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
group: 'chart',
defaultVisible: true,
},
{
id: 'clients_by_country',
label: 'Clients by country',
description:
'Per-country distribution of the active client book. Click a row to filter the clients list by country.',
render: () => <ClientsByCountryWidget />,
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard — compact
// ranked list with mini-bars. Variant (a) per the master-doc design;
// the world-map variant lands alongside the recharts→ECharts pass.
group: 'rail',
defaultVisible: true,
},
{
id: 'website_analytics',
label: 'Website Analytics',