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

@@ -10,6 +10,13 @@ import { useDashboardIntegrations } from '@/hooks/use-dashboard-integrations';
interface PreferencesResponse {
data?: {
dashboardWidgets?: Record<string, boolean>;
/**
* Ordered widget ids. When present, the visible-widgets list is
* sorted by this order first; unlisted widgets fall through to the
* registry's declared order. Per group only — the dashboard shell
* groups by widget.group (chart / rail / feed) before sorting.
*/
dashboardWidgetOrder?: string[];
// Other fields exist (timezone, locale, …) but we don't need them
// here — the typed access is intentionally narrow.
};
@@ -68,10 +75,28 @@ export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
return merged;
}, [data, availableWidgets]);
const visibleWidgets: DashboardWidget[] = useMemo(
() => availableWidgets.filter((w) => visibility[w.id]),
[availableWidgets, visibility],
);
// Order map: widgetId → rank. Unlisted widgets get +Infinity so they
// fall after the explicitly-ordered ones in stable registry order.
const orderRank: Record<string, number> = useMemo(() => {
const order = data?.data?.dashboardWidgetOrder ?? [];
const map: Record<string, number> = {};
order.forEach((id, idx) => {
map[id] = idx;
});
return map;
}, [data]);
const visibleWidgets: DashboardWidget[] = useMemo(() => {
const visible = availableWidgets.filter((w) => visibility[w.id]);
return visible.sort((a, b) => {
const ra = orderRank[a.id] ?? Number.POSITIVE_INFINITY;
const rb = orderRank[b.id] ?? Number.POSITIVE_INFINITY;
if (ra !== rb) return ra - rb;
// Tie-break by registry index so the original order surfaces for
// widgets the rep hasn't explicitly placed.
return availableWidgets.indexOf(a) - availableWidgets.indexOf(b);
});
}, [availableWidgets, visibility, orderRank]);
/**
* Persists a single widget's visibility. Optimistically updates the
@@ -133,6 +158,43 @@ export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
mutation.mutate(next);
}
// Persist the order list. Optimistic so the dashboard reflows on
// drop; the PATCH races behind. Falls back to invalidating on error.
const orderMutation = useMutation({
mutationFn: async (nextOrder: string[]) =>
apiFetch<PreferencesResponse>('/api/v1/users/me/preferences', {
method: 'PATCH',
body: { dashboardWidgetOrder: nextOrder },
}),
onMutate: async (nextOrder) => {
await queryClient.cancelQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] });
const previous = queryClient.getQueryData<PreferencesResponse>([
'me',
'preferences',
'dashboard-widgets',
]);
queryClient.setQueryData<PreferencesResponse>(
['me', 'preferences', 'dashboard-widgets'],
(old) => ({
data: { ...(old?.data ?? {}), dashboardWidgetOrder: nextOrder },
}),
);
return { previous };
},
onError: (_err, _next, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(['me', 'preferences', 'dashboard-widgets'], ctx.previous);
}
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] });
},
});
function setOrder(nextOrder: string[]) {
orderMutation.mutate(nextOrder);
}
return {
isLoading,
/**
@@ -141,13 +203,17 @@ export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
* AND for the dashboard render — both surfaces stay in sync.
*/
allWidgets: availableWidgets,
/** Visible widgets, in registry order. */
/** Visible widgets, sorted by the rep's `dashboardWidgetOrder` then
* by registry index. */
visibleWidgets,
/** Map of widgetId → visible. Use for switch state binding. */
visibility,
/** Current rank per widget id (for SortableContext keying). */
orderRank,
setVisible,
setAll,
setOrder,
resetToDefaults,
isSaving: mutation.isPending,
isSaving: mutation.isPending || orderMutation.isPending,
};
}