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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user