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:
52
src/app/api/v1/dashboard/clients-by-country/route.ts
Normal file
52
src/app/api/v1/dashboard/clients-by-country/route.ts
Normal file
@@ -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<number>`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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -2,10 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { getRevenueForecast } from '@/lib/services/dashboard.service';
|
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-<from>-<to>
|
||||||
|
*
|
||||||
|
* 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(
|
export const GET = withAuth(
|
||||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
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);
|
return NextResponse.json(result);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,24 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { getKpis } from '@/lib/services/dashboard.service';
|
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-<from>-<to>
|
||||||
|
*
|
||||||
|
* 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(
|
export const GET = withAuth(
|
||||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
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);
|
return NextResponse.json(result);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
139
src/components/dashboard/clients-by-country-widget.tsx
Normal file
139
src/components/dashboard/clients-by-country-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,32 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button';
|
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||||
import { DateRangePicker } from './date-range-picker';
|
import { DateRangePicker } from './date-range-picker';
|
||||||
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||||||
@@ -74,11 +94,24 @@ export function DashboardShell({
|
|||||||
initialWidgetVisibility,
|
initialWidgetVisibility,
|
||||||
}: DashboardShellProps = {}) {
|
}: DashboardShellProps = {}) {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
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,
|
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
|
// Bucket once so the JSX stays readable. Registry order is preserved
|
||||||
// inside each bucket, so reordering the registry reorders the render.
|
// inside each bucket, so reordering the registry reorders the render.
|
||||||
const charts = visibleWidgets.filter((w) => w.group === 'chart');
|
const charts = visibleWidgets.filter((w) => w.group === 'chart');
|
||||||
@@ -167,6 +200,16 @@ export function DashboardShell({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DateRangePicker value={range} onChange={setRange} />
|
<DateRangePicker value={range} onChange={setRange} />
|
||||||
<ExportDashboardPdfButton />
|
<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 />
|
<CustomizeWidgetsMenu />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -189,36 +232,78 @@ export function DashboardShell({
|
|||||||
the row; the rails-only grid uses a slightly tighter `280px`
|
the row; the rails-only grid uses a slightly tighter `280px`
|
||||||
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
||||||
instead of stretching to 600px+ each. */}
|
instead of stretching to 600px+ each. */}
|
||||||
|
<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 ? (
|
{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 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))]">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||||
{charts.map((w) => (
|
{charts.map((w) => (
|
||||||
<WidgetCell key={w.id} widget={w} range={range} />
|
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
<aside className="min-w-0 space-y-4">
|
<aside className="min-w-0 space-y-4">
|
||||||
|
<SortableContext
|
||||||
|
items={rails.map((w) => w.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
{rails.map((w) => (
|
{rails.map((w) => (
|
||||||
<WidgetCell key={w.id} widget={w} range={range} />
|
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
) : charts.length > 0 ? (
|
) : 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))]">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||||
{charts.map((w) => (
|
{charts.map((w) => (
|
||||||
<WidgetCell key={w.id} widget={w} range={range} />
|
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
) : rails.length > 0 ? (
|
) : 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))]">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||||||
{rails.map((w) => (
|
{rails.map((w) => (
|
||||||
<WidgetCell key={w.id} widget={w} range={range} />
|
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SortableContext items={feed.map((w) => w.id)} strategy={verticalListSortingStrategy}>
|
||||||
{feed.map((w) => (
|
{feed.map((w) => (
|
||||||
<WidgetCell key={w.id} widget={w} range={range} />
|
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
|
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
|
||||||
|
import { rangeToSlug, type DateRange } from '@/lib/analytics/range';
|
||||||
import { formatCurrency } from '@/lib/utils/currency';
|
import { formatCurrency } from '@/lib/utils/currency';
|
||||||
import { cn } from '@/lib/utils';
|
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
|
* and `/forecast` for the weighted breakdown. Both share cache entries
|
||||||
* with other widgets so this is mostly free.
|
* 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>({
|
const kpis = useQuery<KpiResponse>({
|
||||||
queryKey: ['dashboard', 'kpis'],
|
queryKey: ['dashboard', 'kpis', slug],
|
||||||
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
queryFn: () => apiFetch<KpiResponse>(`/api/v1/dashboard/kpis${qs}`),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
const forecast = useQuery<ForecastResponse>({
|
const forecast = useQuery<ForecastResponse>({
|
||||||
queryKey: ['dashboard', 'forecast'],
|
queryKey: ['dashboard', 'forecast', slug],
|
||||||
queryFn: () => apiFetch<ForecastResponse>('/api/v1/dashboard/forecast'),
|
queryFn: () => apiFetch<ForecastResponse>(`/api/v1/dashboard/forecast${qs}`),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import dynamic from 'next/dynamic';
|
|||||||
import { ActiveDealsTile } from './active-deals-tile';
|
import { ActiveDealsTile } from './active-deals-tile';
|
||||||
import { ActivityFeed } from './activity-feed';
|
import { ActivityFeed } from './activity-feed';
|
||||||
import { BerthHeatWidget } from './berth-heat-widget';
|
import { BerthHeatWidget } from './berth-heat-widget';
|
||||||
|
import { ClientsByCountryWidget } from './clients-by-country-widget';
|
||||||
import { HotDealsCard } from './hot-deals-card';
|
import { HotDealsCard } from './hot-deals-card';
|
||||||
import { PipelineValueTile } from './pipeline-value-tile';
|
import { PipelineValueTile } from './pipeline-value-tile';
|
||||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||||
@@ -121,7 +122,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
|||||||
label: 'Pipeline Value',
|
label: 'Pipeline Value',
|
||||||
description:
|
description:
|
||||||
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
|
'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
|
// Lives in the chart grid (not the narrow rail) so the per-stage
|
||||||
// breakdown rows have room to breathe alongside the headline numbers,
|
// breakdown rows have room to breathe alongside the headline numbers,
|
||||||
// and the rail stays reserved for reminders / alerts / glance tiles.
|
// and the rail stays reserved for reminders / alerts / glance tiles.
|
||||||
@@ -182,6 +183,18 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
|||||||
group: 'chart',
|
group: 'chart',
|
||||||
defaultVisible: true,
|
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',
|
id: 'website_analytics',
|
||||||
label: 'Website Analytics',
|
label: 'Website Analytics',
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import { useDashboardIntegrations } from '@/hooks/use-dashboard-integrations';
|
|||||||
interface PreferencesResponse {
|
interface PreferencesResponse {
|
||||||
data?: {
|
data?: {
|
||||||
dashboardWidgets?: Record<string, boolean>;
|
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
|
// Other fields exist (timezone, locale, …) but we don't need them
|
||||||
// here — the typed access is intentionally narrow.
|
// here — the typed access is intentionally narrow.
|
||||||
};
|
};
|
||||||
@@ -68,10 +75,28 @@ export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
|
|||||||
return merged;
|
return merged;
|
||||||
}, [data, availableWidgets]);
|
}, [data, availableWidgets]);
|
||||||
|
|
||||||
const visibleWidgets: DashboardWidget[] = useMemo(
|
// Order map: widgetId → rank. Unlisted widgets get +Infinity so they
|
||||||
() => availableWidgets.filter((w) => visibility[w.id]),
|
// fall after the explicitly-ordered ones in stable registry order.
|
||||||
[availableWidgets, visibility],
|
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
|
* Persists a single widget's visibility. Optimistically updates the
|
||||||
@@ -133,6 +158,43 @@ export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
|
|||||||
mutation.mutate(next);
|
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 {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
/**
|
/**
|
||||||
@@ -141,13 +203,17 @@ export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
|
|||||||
* AND for the dashboard render — both surfaces stay in sync.
|
* AND for the dashboard render — both surfaces stay in sync.
|
||||||
*/
|
*/
|
||||||
allWidgets: availableWidgets,
|
allWidgets: availableWidgets,
|
||||||
/** Visible widgets, in registry order. */
|
/** Visible widgets, sorted by the rep's `dashboardWidgetOrder` then
|
||||||
|
* by registry index. */
|
||||||
visibleWidgets,
|
visibleWidgets,
|
||||||
/** Map of widgetId → visible. Use for switch state binding. */
|
/** Map of widgetId → visible. Use for switch state binding. */
|
||||||
visibility,
|
visibility,
|
||||||
|
/** Current rank per widget id (for SortableContext keying). */
|
||||||
|
orderRank,
|
||||||
setVisible,
|
setVisible,
|
||||||
setAll,
|
setAll,
|
||||||
|
setOrder,
|
||||||
resetToDefaults,
|
resetToDefaults,
|
||||||
isSaving: mutation.isPending,
|
isSaving: mutation.isPending || orderMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ export function rangeToSlug(range: DateRange): string {
|
|||||||
return range;
|
return range;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of rangeToSlug — parses a `?range=<slug>` 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.
|
* Resolve any DateRange (preset or custom) to a concrete {from, to} pair.
|
||||||
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
|
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ export type UserPreferences = {
|
|||||||
* surfaces it for everyone without a migration. `false` hides it.
|
* surfaces it for everyone without a migration. `false` hides it.
|
||||||
*/
|
*/
|
||||||
dashboardWidgets?: Record<string, boolean>;
|
dashboardWidgets?: Record<string, boolean>;
|
||||||
|
/**
|
||||||
|
* 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;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
@@ -20,16 +20,32 @@ const DEFAULT_PIPELINE_WEIGHTS: Record<string, number> = STAGE_WEIGHTS;
|
|||||||
|
|
||||||
// ─── KPIs ─────────────────────────────────────────────────────────────────────
|
// ─── 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
|
const [totalClientsRow] = await db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(and(eq(clients.portId, portId), isNull(clients.archivedAt)));
|
.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
|
const [activeInterestsRow] = await db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(interests)
|
.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
|
// Pipeline value: SUM each berth's price ONCE regardless of how many
|
||||||
// active interests reference it. A berth with multiple interests would
|
// 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)),
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||||
)
|
)
|
||||||
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||||
.where(activeInterestsWhere(portId));
|
.where(
|
||||||
|
rangeClause ? and(activeInterestsWhere(portId), rangeClause) : activeInterestsWhere(portId),
|
||||||
|
);
|
||||||
|
|
||||||
let pipelineValue = 0;
|
let pipelineValue = 0;
|
||||||
for (const row of pipelineRows) {
|
for (const row of pipelineRows) {
|
||||||
@@ -128,7 +146,7 @@ export async function getPipelineCounts(portId: string) {
|
|||||||
|
|
||||||
// ─── Revenue Forecast ─────────────────────────────────────────────────────────
|
// ─── Revenue Forecast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getRevenueForecast(portId: string) {
|
export async function getRevenueForecast(portId: string, range?: { from: Date; to: Date } | null) {
|
||||||
// Load weights from systemSettings
|
// Load weights from systemSettings
|
||||||
let weights: Record<string, number> = DEFAULT_PIPELINE_WEIGHTS;
|
let weights: Record<string, number> = DEFAULT_PIPELINE_WEIGHTS;
|
||||||
let weightsSource: 'db' | 'default' = 'default';
|
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
|
// Forecast excludes lost/cancelled - only currently-active or won-out
|
||||||
// interests should affect the weighted pipeline value. Reads the
|
// interests should affect the weighted pipeline value. Reads the
|
||||||
// primary-berth link via interest_berths (plan §3.4).
|
// 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
|
const interestRows = await db
|
||||||
.select({
|
.select({
|
||||||
id: interests.id,
|
id: interests.id,
|
||||||
@@ -164,7 +185,11 @@ export async function getRevenueForecast(portId: string) {
|
|||||||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||||
)
|
)
|
||||||
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
.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,
|
// Build stageBreakdown — gross value, weighted value, per-stage weight,
|
||||||
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
||||||
|
|||||||
Reference in New Issue
Block a user