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

@@ -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)