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:
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user