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

@@ -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);
}
}),
);

View File

@@ -2,10 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
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(
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);
}),
);

View File

@@ -2,10 +2,24 @@ import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
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(
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);
}),
);