Files
pn-new-crm/src/components/clients/clients-by-country-page.tsx
Matt 05950ae0b6
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s
feat(uat): file preview/download fix, clients-by-country page, residential column picker
Batch #4 UAT items.

1. Documents — clicking any file dumped raw presigned-URL JSON. Was
   systemic: 6 surfaces linked a browser directly at the JSON-returning
   /files/[id]/{download,preview} routes. Those routes now 302-redirect
   when called with ?redirect=1 (default stays JSON for the dialog +
   interest-eoi-tab programmatic consumers); the six <Link> sites use it.
   The documents-hub file row now opens the inline FilePreviewDialog +
   has a per-row Download button, and the preview dialog header gained a
   persistent Download button for all file types.

2. Clients-by-country — the widget's "+N more" dead text is now a
   "Show all" link to a new /clients/by-country page rendering the full
   ranked country breakdown (each row drills into the filtered list).

3. Residential clients list — moved off its bespoke table onto the
   shared DataTable + ColumnPicker (same UX as clients/interests). Adds
   a "Date added" column, default-hides the empty "Residence" column,
   preserves the mobile card view, persists per-user column choices.

tsc clean, eslint clean, 1584/1584 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:34:47 +02:00

120 lines
4.6 KiB
TypeScript

'use client';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Globe } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
interface ClientsByCountryRow {
country: string;
count: number;
}
interface ClientsByCountryResponse {
data: ClientsByCountryRow[];
total: number;
}
/**
* Full per-country breakdown of the active (non-archived) client book — the
* "Show all" destination for the dashboard `ClientsByCountryWidget`, which
* only shows the top N. Same endpoint (it already returns every row); this
* page just renders the complete ranked list. Each row deep-links into the
* clients list filtered by that nationality.
*/
export function ClientsByCountryPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
queryKey: ['dashboard', 'clients-by-country', 'all'],
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
staleTime: 60_000,
});
const rows = data?.data ?? [];
const total = data?.total ?? rows.reduce((s, r) => s + r.count, 0);
const maxCount = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
return (
<div className="space-y-6">
<PageHeader
eyebrow="Clients"
title="Clients by country"
description="Every country represented in the active client book, ranked by client count. Select a row to view those clients."
kpiLine={
rows.length > 0
? `${total} client${total === 1 ? '' : 's'} across ${rows.length} ${
rows.length === 1 ? 'country' : 'countries'
}.`
: undefined
}
/>
<Card>
<CardContent className="p-4 sm:p-6">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" aria-hidden />
))}
</div>
) : rows.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<Globe className="size-6" aria-hidden />
<p>No clients with a country recorded yet.</p>
</div>
) : (
<ol className="space-y-1">
{rows.map((row, i) => {
const pct = (row.count / maxCount) * 100;
const name = getCountryName(row.country) || row.country;
return (
<li key={row.country}>
<Link
href={
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as Route
}
className="group flex items-center justify-between gap-3 rounded-md px-2 py-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-6 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
<span className="truncate text-sm font-medium">{name}</span>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full bg-brand-500')}
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
<span className="w-10 text-end text-sm tabular-nums text-foreground">
{row.count}
</span>
</div>
</Link>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
</div>
);
}