From a147cbcd939884db11375f283cbf6eba22b0edd4 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 23:32:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Group=20N=20=E2=80=94=20dash?= =?UTF-8?q?board=20upgrades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= + /forecast?range=. 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. renders a compact ranked list with mini-bars; rows link to /clients?nationality=. 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 wraps the render — zero footprint when off. Verified: tsc clean, vitest 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/dashboard/clients-by-country/route.ts | 52 +++++ src/app/api/v1/dashboard/forecast/route.ts | 15 +- src/app/api/v1/dashboard/kpis/route.ts | 16 +- .../dashboard/clients-by-country-widget.tsx | 139 +++++++++++++ src/components/dashboard/dashboard-shell.tsx | 187 +++++++++++++++--- .../dashboard/pipeline-value-tile.tsx | 17 +- src/components/dashboard/widget-registry.tsx | 15 +- src/hooks/use-dashboard-widgets.ts | 78 +++++++- src/lib/analytics/range.ts | 14 ++ src/lib/db/schema/users.ts | 10 + src/lib/services/dashboard.service.ts | 37 +++- 11 files changed, 529 insertions(+), 51 deletions(-) create mode 100644 src/app/api/v1/dashboard/clients-by-country/route.ts create mode 100644 src/components/dashboard/clients-by-country-widget.tsx diff --git a/src/app/api/v1/dashboard/clients-by-country/route.ts b/src/app/api/v1/dashboard/clients-by-country/route.ts new file mode 100644 index 00000000..1387c42a --- /dev/null +++ b/src/app/api/v1/dashboard/clients-by-country/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { and, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { clients } from '@/lib/db/schema/clients'; +import { errorResponse } from '@/lib/errors'; + +/** + * GET /api/v1/dashboard/clients-by-country + * + * Returns a per-country breakdown of non-archived clients in the port, + * keyed by ISO-3166-1 alpha-2 country code. Powers the "Clients by + * country" dashboard widget. + * + * Skips rows with no nationality recorded. Sorted by count desc; ties + * break alphabetically by country code so the widget stays stable + * across refreshes. + */ +export const GET = withAuth( + withPermission('clients', 'view', async (_req, ctx) => { + try { + const rows = await db + .select({ + country: clients.nationalityIso, + count: sql`COUNT(*)::int`, + }) + .from(clients) + .where( + and( + eq(clients.portId, ctx.portId), + isNull(clients.archivedAt), + isNotNull(clients.nationalityIso), + ), + ) + .groupBy(clients.nationalityIso) + .orderBy(desc(sql`COUNT(*)`), clients.nationalityIso); + + const total = rows.reduce((sum, r) => sum + Number(r.count ?? 0), 0); + + return NextResponse.json({ + data: rows.map((r) => ({ + country: r.country, + count: Number(r.count ?? 0), + })), + total, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/dashboard/forecast/route.ts b/src/app/api/v1/dashboard/forecast/route.ts index 1b84ad70..98b2b362 100644 --- a/src/app/api/v1/dashboard/forecast/route.ts +++ b/src/app/api/v1/dashboard/forecast/route.ts @@ -2,10 +2,23 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { getRevenueForecast } from '@/lib/services/dashboard.service'; +import { parseRangeSlug, rangeToBounds } from '@/lib/analytics/range'; +/** + * GET /api/v1/dashboard/forecast + * GET /api/v1/dashboard/forecast?range=7d|30d|90d|today|custom-- + * + * Same range semantics as /kpis — the weighted forecast scopes to + * interests whose createdAt falls inside the window when range is set, + * or all-time when not. + */ export const GET = withAuth( withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { - const result = await getRevenueForecast(ctx.portId); + const url = new URL(req.url); + const rangeSlug = url.searchParams.get('range'); + const range = rangeSlug ? parseRangeSlug(rangeSlug) : null; + const bounds = range ? rangeToBounds(range) : null; + const result = await getRevenueForecast(ctx.portId, bounds); return NextResponse.json(result); }), ); diff --git a/src/app/api/v1/dashboard/kpis/route.ts b/src/app/api/v1/dashboard/kpis/route.ts index 0e764981..d58ea8a4 100644 --- a/src/app/api/v1/dashboard/kpis/route.ts +++ b/src/app/api/v1/dashboard/kpis/route.ts @@ -2,10 +2,24 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { getKpis } from '@/lib/services/dashboard.service'; +import { parseRangeSlug, rangeToBounds } from '@/lib/analytics/range'; +/** + * GET /api/v1/dashboard/kpis + * GET /api/v1/dashboard/kpis?range=7d|30d|90d|today|custom-- + * + * Without `range`: returns the all-time pipeline snapshot. With + * `range`: scopes the pipeline-value calculation to interests created + * inside the window so the headline reflects "what was added to the + * pipeline this period" rather than the cumulative book. + */ export const GET = withAuth( withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { - const result = await getKpis(ctx.portId); + const url = new URL(req.url); + const rangeSlug = url.searchParams.get('range'); + const range = rangeSlug ? parseRangeSlug(rangeSlug) : null; + const bounds = range ? rangeToBounds(range) : null; + const result = await getKpis(ctx.portId, bounds); return NextResponse.json(result); }), ); diff --git a/src/components/dashboard/clients-by-country-widget.tsx b/src/components/dashboard/clients-by-country-widget.tsx new file mode 100644 index 00000000..b6f9bafe --- /dev/null +++ b/src/components/dashboard/clients-by-country-widget.tsx @@ -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=` 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({ + queryKey: ['dashboard', 'clients-by-country'], + queryFn: () => apiFetch('/api/v1/dashboard/clients-by-country'), + staleTime: 60_000, + }); + + if (isLoading) { + return ( + + + Clients by country + Distribution of the active client book. + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + ); + } + + 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 ( + + + + + Clients by country + + + {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'}.`} + + + + {rows.length === 0 ? ( +
+ Distribution will appear once clients capture a nationality. +
+ ) : ( +
    + {visibleRows.map((row) => { + const pct = (row.count / maxCount) * 100; + const name = getCountryName(row.country) || row.country; + return ( +
  1. + +
    + + {row.country} + + {name} +
    + {/* 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. */} +
    +
    +
    +
    + + {row.count} + +
    + +
  2. + ); + })} + {hiddenCount > 0 ? ( +
  3. + + {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown. +
  4. + ) : null} +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 831def6a..be030630 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -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('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({
+
} @@ -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 ? ( -
-
- {charts.map((w) => ( - - ))} + { + 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 ? ( +
+ w.id)} strategy={rectSortingStrategy}> +
+ {charts.map((w) => ( + + ))} +
+
+
- -
- ) : charts.length > 0 ? ( -
- {charts.map((w) => ( - - ))} -
- ) : rails.length > 0 ? ( -
- {rails.map((w) => ( - - ))} -
- ) : null} + ) : charts.length > 0 ? ( + w.id)} strategy={rectSortingStrategy}> +
+ {charts.map((w) => ( + + ))} +
+
+ ) : rails.length > 0 ? ( + w.id)} strategy={rectSortingStrategy}> +
+ {rails.map((w) => ( + + ))} +
+
+ ) : null} - {feed.map((w) => ( - - ))} + w.id)} strategy={verticalListSortingStrategy}> + {feed.map((w) => ( + + ))} + + {visibleWidgets.length === 0 ? : null}
@@ -243,6 +328,46 @@ function EmptyDashboardHint() { ); } -function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) { - return {widget.render(range)}; +/** + * 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 ( +
+ {showHandle ? ( + + ) : null} + {widget.render(range)} +
+ ); } diff --git a/src/components/dashboard/pipeline-value-tile.tsx b/src/components/dashboard/pipeline-value-tile.tsx index 5e090abf..ef371f0e 100644 --- a/src/components/dashboard/pipeline-value-tile.tsx +++ b/src/components/dashboard/pipeline-value-tile.tsx @@ -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 = { * 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({ - queryKey: ['dashboard', 'kpis'], - queryFn: () => apiFetch('/api/v1/dashboard/kpis'), + queryKey: ['dashboard', 'kpis', slug], + queryFn: () => apiFetch(`/api/v1/dashboard/kpis${qs}`), staleTime: 60_000, }); const forecast = useQuery({ - queryKey: ['dashboard', 'forecast'], - queryFn: () => apiFetch('/api/v1/dashboard/forecast'), + queryKey: ['dashboard', 'forecast', slug], + queryFn: () => apiFetch(`/api/v1/dashboard/forecast${qs}`), staleTime: 60_000, }); diff --git a/src/components/dashboard/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx index cfd0354e..d4848903 100644 --- a/src/components/dashboard/widget-registry.tsx +++ b/src/components/dashboard/widget-registry.tsx @@ -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: () => , + render: (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: () => , + // 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', diff --git a/src/hooks/use-dashboard-widgets.ts b/src/hooks/use-dashboard-widgets.ts index c8e9d3af..5ffe2ff6 100644 --- a/src/hooks/use-dashboard-widgets.ts +++ b/src/hooks/use-dashboard-widgets.ts @@ -10,6 +10,13 @@ import { useDashboardIntegrations } from '@/hooks/use-dashboard-integrations'; interface PreferencesResponse { data?: { dashboardWidgets?: Record; + /** + * 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 = useMemo(() => { + const order = data?.data?.dashboardWidgetOrder ?? []; + const map: Record = {}; + 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('/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([ + 'me', + 'preferences', + 'dashboard-widgets', + ]); + queryClient.setQueryData( + ['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, }; } diff --git a/src/lib/analytics/range.ts b/src/lib/analytics/range.ts index 4e403d14..9cd863cc 100644 --- a/src/lib/analytics/range.ts +++ b/src/lib/analytics/range.ts @@ -45,6 +45,20 @@ export function rangeToSlug(range: DateRange): string { return range; } +/** + * Inverse of rangeToSlug — parses a `?range=` query-string value + * back into a typed DateRange. Returns null on garbage input so callers + * can fall through to their "no range" default rather than 400ing. + */ +export function parseRangeSlug(slug: string): DateRange | null { + if (ALL_RANGES.includes(slug as PresetDateRange)) return slug as PresetDateRange; + // Custom: `YYYY-MM-DD_YYYY-MM-DD`. Both halves must look like ISO dates; + // anything else is malformed. + const m = /^(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2})$/.exec(slug); + if (!m) return null; + return { kind: 'custom', from: m[1]!, to: m[2]! }; +} + /** * Resolve any DateRange (preset or custom) to a concrete {from, to} pair. * - Preset ranges anchor `to` at "now" and `from` at `now - N days`. diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index 8f46c02e..0ef853a2 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -202,6 +202,16 @@ export type UserPreferences = { * surfaces it for everyone without a migration. `false` hides it. */ dashboardWidgets?: Record; + /** + * Ordered list of widget ids — drives the dashboard render order so a + * rep can drag tiles around and have the layout persist. Missing + * widgets (ids not in the array) render after the listed ones in + * registry order, so adding a new widget always surfaces it without + * a migration. Order is scoped per widget group implicitly — the + * shell groups by `widget.group` first (chart / rail / feed) then + * sorts within the group by this array. + */ + dashboardWidgetOrder?: string[]; [key: string]: unknown; }; diff --git a/src/lib/services/dashboard.service.ts b/src/lib/services/dashboard.service.ts index 46ac0ecc..d14ed62a 100644 --- a/src/lib/services/dashboard.service.ts +++ b/src/lib/services/dashboard.service.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { and, count, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; @@ -20,16 +20,32 @@ const DEFAULT_PIPELINE_WEIGHTS: Record = STAGE_WEIGHTS; // ─── KPIs ───────────────────────────────────────────────────────────────────── -export async function getKpis(portId: string) { +/** + * Pipeline KPIs. When `range` is supplied the pipeline-value calculation + * is scoped to interests whose `createdAt` falls inside the range — lets + * leadership see "what was added to the pipeline this period" rather + * than the all-time snapshot. Active-interests count + occupancy are + * always all-active (no temporal sense for "active right now"). + */ +export async function getKpis(portId: string, range?: { from: Date; to: Date } | null) { const [totalClientsRow] = await db .select({ value: count() }) .from(clients) .where(and(eq(clients.portId, portId), isNull(clients.archivedAt))); + // Range filter — clamp to the interest's createdAt. Returns undefined + // when no range is provided so the existing all-time queries stay + // unaffected. + const rangeClause = range + ? and(gte(interests.createdAt, range.from), lte(interests.createdAt, range.to)) + : undefined; + const [activeInterestsRow] = await db .select({ value: count() }) .from(interests) - .where(activeInterestsWhere(portId)); + .where( + rangeClause ? and(activeInterestsWhere(portId), rangeClause) : activeInterestsWhere(portId), + ); // Pipeline value: SUM each berth's price ONCE regardless of how many // active interests reference it. A berth with multiple interests would @@ -59,7 +75,9 @@ export async function getKpis(portId: string) { and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), ) .innerJoin(berths, eq(interestBerths.berthId, berths.id)) - .where(activeInterestsWhere(portId)); + .where( + rangeClause ? and(activeInterestsWhere(portId), rangeClause) : activeInterestsWhere(portId), + ); let pipelineValue = 0; for (const row of pipelineRows) { @@ -128,7 +146,7 @@ export async function getPipelineCounts(portId: string) { // ─── Revenue Forecast ───────────────────────────────────────────────────────── -export async function getRevenueForecast(portId: string) { +export async function getRevenueForecast(portId: string, range?: { from: Date; to: Date } | null) { // Load weights from systemSettings let weights: Record = DEFAULT_PIPELINE_WEIGHTS; let weightsSource: 'db' | 'default' = 'default'; @@ -152,6 +170,9 @@ export async function getRevenueForecast(portId: string) { // Forecast excludes lost/cancelled - only currently-active or won-out // interests should affect the weighted pipeline value. Reads the // primary-berth link via interest_berths (plan §3.4). + const forecastRangeClause = range + ? and(gte(interests.createdAt, range.from), lte(interests.createdAt, range.to)) + : undefined; const interestRows = await db .select({ id: interests.id, @@ -164,7 +185,11 @@ export async function getRevenueForecast(portId: string) { and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), ) .innerJoin(berths, eq(interestBerths.berthId, berths.id)) - .where(activeInterestsWhere(portId)); + .where( + forecastRangeClause + ? and(activeInterestsWhere(portId), forecastRangeClause) + : activeInterestsWhere(portId), + ); // Build stageBreakdown — gross value, weighted value, per-stage weight, // and `dealsMissingPrice` (deals whose primary berth has no/zero price)