'use client'; import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiFetch } from '@/lib/api/client'; import { DASHBOARD_WIDGETS, type DashboardWidget } from '@/components/dashboard/widget-registry'; import { useDashboardIntegrations } from '@/hooks/use-dashboard-integrations'; interface PreferencesResponse { data?: { dashboardWidgets?: Record; // Other fields exist (timezone, locale, …) but we don't need them // here — the typed access is intentionally narrow. }; } /** * Returns the dashboard widget list filtered by the user's visibility * preferences and exposes a toggle. Single source of truth for "what's * showing on the dashboard right now" — used by both `DashboardShell` * and the settings UI. * * Stored shape: `preferences.dashboardWidgets: { [widgetId]: boolean }`. * Missing keys fall back to the registry's `defaultVisible`, so a newly * added widget surfaces for everyone without a migration. */ export function useDashboardWidgets() { const queryClient = useQueryClient(); const integrations = useDashboardIntegrations(); const { data, isLoading } = useQuery({ queryKey: ['me', 'preferences', 'dashboard-widgets'], queryFn: () => apiFetch('/api/v1/users/me/preferences'), staleTime: 60_000, }); // The registry is the universe of declared widgets. `availableWidgets` // is the universe filtered down to what actually CAN render right now // (i.e. its required integration is connected). The picker iterates // this list, and the visible-widgets render path filters off the same // list so flipping on a widget whose service isn't wired up does // nothing silently — the toggle simply isn't shown. const availableWidgets: DashboardWidget[] = useMemo( () => DASHBOARD_WIDGETS.filter((w) => !w.requires || integrations.available[w.requires]), [integrations], ); const visibility: Record = useMemo(() => { const stored = data?.data?.dashboardWidgets ?? {}; const merged: Record = {}; for (const w of availableWidgets) { merged[w.id] = stored[w.id] ?? w.defaultVisible; } return merged; }, [data, availableWidgets]); const visibleWidgets: DashboardWidget[] = useMemo( () => availableWidgets.filter((w) => visibility[w.id]), [availableWidgets, visibility], ); /** * Persists a single widget's visibility. Optimistically updates the * cache so the dashboard reflows instantly; the server PATCH races in * the background. On failure the cache invalidates and re-reads the * authoritative value. */ const mutation = useMutation({ mutationFn: async (next: Record) => apiFetch('/api/v1/users/me/preferences', { method: 'PATCH', body: { dashboardWidgets: next }, }), onMutate: async (next) => { await queryClient.cancelQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] }); const previous = queryClient.getQueryData([ 'me', 'preferences', 'dashboard-widgets', ]); queryClient.setQueryData( ['me', 'preferences', 'dashboard-widgets'], (old) => ({ data: { ...(old?.data ?? {}), dashboardWidgets: next }, }), ); return { previous }; }, onError: (_err, _next, ctx) => { if (ctx?.previous) { queryClient.setQueryData(['me', 'preferences', 'dashboard-widgets'], ctx.previous); } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] }); }, }); function setVisible(id: string, visible: boolean) { mutation.mutate({ ...visibility, [id]: visible }); } function setAll(visible: boolean) { const next: Record = {}; for (const w of availableWidgets) next[w.id] = visible; mutation.mutate(next); } /** * Restores each widget's visibility to its registry `defaultVisible`. * Different from `setAll(true)` — it keeps the "off by default" widgets * (KPI tiles, Berth Status donut, Source Conversion, Hot Deals) off so * reps end up with the original out-of-the-box dashboard. Scoped to * `availableWidgets` so disconnected integrations don't sneak in. */ function resetToDefaults() { const next: Record = {}; for (const w of availableWidgets) next[w.id] = w.defaultVisible; mutation.mutate(next); } return { isLoading, /** * Widgets that can render right now (registry minus those whose * required integration isn't connected). Use this for the picker * AND for the dashboard render — both surfaces stay in sync. */ allWidgets: availableWidgets, /** Visible widgets, in registry order. */ visibleWidgets, /** Map of widgetId → visible. Use for switch state binding. */ visibility, setVisible, setAll, resetToDefaults, isSaving: mutation.isPending, }; }