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

@@ -45,6 +45,20 @@ export function rangeToSlug(range: DateRange): string {
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.
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.

View File

@@ -202,6 +202,16 @@ export type UserPreferences = {
* surfaces it for everyone without a migration. `false` hides it.
*/
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;
};

View File

@@ -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<string, number> = 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<string, number> = 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)