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