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:
52
src/app/api/v1/dashboard/clients-by-country/route.ts
Normal file
52
src/app/api/v1/dashboard/clients-by-country/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user