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>
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useParams } from 'next/navigation';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Globe } from 'lucide-react';
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { getCountryName } from '@/lib/i18n/countries';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ClientsByCountryRow {
|
|
country: string;
|
|
count: number;
|
|
}
|
|
|
|
interface ClientsByCountryResponse {
|
|
data: ClientsByCountryRow[];
|
|
total: number;
|
|
}
|
|
|
|
/**
|
|
* Compact ranked-list widget showing the per-country distribution of
|
|
* non-archived clients. Designed to fit the rail tile footprint (no
|
|
* external chart library); the mini-bar per row gives leadership an
|
|
* at-a-glance feel for whether the book is concentrated or diverse.
|
|
*
|
|
* Each row deep-links to `/clients?country=<ISO>` so the rep can drill
|
|
* into a specific market. Country names render via the existing
|
|
* locale-aware helper; unknown ISO codes fall back to the raw code.
|
|
*
|
|
* Variant (b) of the master-doc design — a true choropleth would need
|
|
* a heavier viz lib (react-simple-maps + topojson) and pushes us to
|
|
* the chart-library migration agenda. Variant (a) ships now; the
|
|
* world-map variant can land alongside the recharts→ECharts pass.
|
|
*/
|
|
export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
|
|
const params = useParams<{ portSlug: string }>();
|
|
const portSlug = params?.portSlug ?? '';
|
|
|
|
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
|
|
queryKey: ['dashboard', 'clients-by-country'],
|
|
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Clients by country</CardTitle>
|
|
<CardDescription>Distribution of the active client book.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-6 w-full" aria-hidden />
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const rows = data?.data ?? [];
|
|
const visibleRows = rows.slice(0, limit);
|
|
const hiddenCount = Math.max(0, rows.length - limit);
|
|
const maxCount = visibleRows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
|
|
|
|
return (
|
|
<Card className="h-full flex flex-col">
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Globe className="size-4 text-muted-foreground" aria-hidden />
|
|
Clients by country
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{rows.length === 0
|
|
? 'No clients with a country recorded yet.'
|
|
: `${data?.total ?? rows.reduce((s, r) => s + r.count, 0)} client${rows.length === 1 ? '' : 's'} across ${rows.length} ${rows.length === 1 ? 'country' : 'countries'}.`}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex-1">
|
|
{rows.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
|
Distribution will appear once clients capture a nationality.
|
|
</div>
|
|
) : (
|
|
<ol className="space-y-1.5">
|
|
{visibleRows.map((row) => {
|
|
const pct = (row.count / maxCount) * 100;
|
|
const name = getCountryName(row.country) || row.country;
|
|
return (
|
|
<li key={row.country}>
|
|
<Link
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
href={
|
|
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as any
|
|
}
|
|
className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 -mx-2 hover:bg-foreground/5"
|
|
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<span className="w-8 shrink-0 text-xs font-mono uppercase text-muted-foreground">
|
|
{row.country}
|
|
</span>
|
|
<span className="truncate text-sm">{name}</span>
|
|
</div>
|
|
{/* Mini bar — same `BerthHeatWidget` idiom: a thin
|
|
background track with a coloured fill. The count
|
|
sits on the right so the eye can read both the
|
|
bar shape and the precise number. */}
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className={cn('h-full rounded-full bg-brand-500 transition-[width]')}
|
|
style={{ width: `${pct}%` }}
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
<span className="w-7 text-right text-xs tabular-nums text-foreground">
|
|
{row.count}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
{hiddenCount > 0 ? (
|
|
<li className="pt-1 text-xs text-muted-foreground">
|
|
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown.
|
|
</li>
|
|
) : null}
|
|
</ol>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|