feat(uat): file preview/download fix, clients-by-country page, residential column picker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s

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>
This commit is contained in:
2026-06-03 22:34:47 +02:00
parent eff57af571
commit 05950ae0b6
15 changed files with 506 additions and 170 deletions

View File

@@ -2,10 +2,10 @@
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import type { Route } from 'next';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -22,17 +22,15 @@ import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialClientRow {
id: string;
fullName: string;
email: string | null;
phone: string | null;
placeOfResidence: string | null;
status: string;
source: string | null;
updatedAt: string;
}
import { DataTable } from '@/components/shared/data-table';
import { ColumnPicker } from '@/components/shared/column-picker';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import {
getResidentialClientColumns,
RESIDENTIAL_CLIENT_COLUMN_OPTIONS,
RESIDENTIAL_CLIENT_DEFAULT_HIDDEN,
type ResidentialClientRow,
} from '@/components/residential/residential-client-columns';
interface ListResponse {
data: ResidentialClientRow[];
@@ -48,6 +46,7 @@ const STATUS_LABELS: Record<string, string> = {
export function ResidentialClientsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState('');
@@ -66,6 +65,17 @@ export function ResidentialClientsList() {
'residential_client:restored': [['residential-clients']],
});
const columns = getResidentialClientColumns({ portSlug });
// Per-user column visibility, persisted via /api/v1/me — same hook + UX as
// the marina clients/interests lists. "Residence" is hidden by default
// (it's empty for nearly every residential client); "Date added" is shown.
const { hidden, setHidden } = useTablePreferences(
'residential-clients',
RESIDENTIAL_CLIENT_DEFAULT_HIDDEN,
);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const rows = data?.data ?? [];
return (
<div className="space-y-4">
<PageHeader
@@ -79,156 +89,82 @@ export function ResidentialClientsList() {
}
/>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, email, phone, residence…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<ColumnPicker
columns={RESIDENTIAL_CLIENT_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
/>
</div>
{/* Desktop: table layout. Hidden below lg because the 6 columns clip
off the viewport at phone widths. */}
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-2">Name</th>
<th className="text-left font-medium px-3 py-2">Email</th>
<th className="text-left font-medium px-3 py-2">Phone</th>
<th className="text-left font-medium px-3 py-2">Residence</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Source</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
Loading
</td>
</tr>
)}
{!isLoading && data?.data.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
No residential clients yet.
</td>
</tr>
)}
{data?.data.map((c) => (
<tr
key={c.id}
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="font-medium hover:underline"
>
{c.fullName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">
{c.email ? (
<a
href={`mailto:${c.email}`}
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{c.email}
</a>
) : (
'-'
)}
</td>
<td className="px-3 py-2 text-muted-foreground">
{c.phone ? (
<span className="inline-flex items-center gap-1.5">
<a
href={`tel:${c.phone}`}
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{c.phone}
</a>
<a
href={`https://wa.me/${c.phone.replace(/[^\d+]/g, '')}`}
target="_blank"
rel="noreferrer"
title="WhatsApp"
aria-label="Message on WhatsApp"
className="text-emerald-600 hover:text-emerald-700"
onClick={(e) => e.stopPropagation()}
>
<WhatsAppIcon className="h-3.5 w-3.5" />
</a>
</span>
) : (
'-'
)}
</td>
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '-'}</td>
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: card list. Each card mirrors the table row data with
name + status pill on top, then meta line(s) below. */}
<div className="md:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={rows}
isLoading={isLoading}
getRowId={(r) => r.id}
onRowClick={(r) => router.push(`/${portSlug}/residential/clients/${r.id}` as Route)}
emptyState={
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
No residential clients yet.
</div>
)}
{data?.data.map((c) => (
<Link
key={c.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm truncate">{c.fullName}</p>
<span
className={cn(
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
c.status === 'active'
? 'bg-emerald-100 text-emerald-800'
: c.status === 'inactive'
? 'bg-muted text-muted-foreground'
: 'bg-blue-100 text-blue-800',
)}
>
{STATUS_LABELS[c.status] ?? c.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{c.email ? <span className="truncate">{c.email}</span> : null}
{c.phone ? <span>{c.phone}</span> : null}
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
{c.source ? <span className="capitalize">· {c.source}</span> : null}
</div>
</Link>
))}
</div>
}
cardRender={(row) => <ResidentialClientCard portSlug={portSlug} client={row.original} />}
/>
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}
/**
* Mobile card for a residential client — DataTable swaps to this below the
* md breakpoint. Self-navigating `<Link>` (DataTable's onRowClick only wires
* the desktop table rows). Mirrors the marina-side card density.
*/
function ResidentialClientCard({
portSlug,
client,
}: {
portSlug: string;
client: ResidentialClientRow;
}) {
return (
<Link
href={`/${portSlug}/residential/clients/${client.id}` as Route}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="truncate text-sm font-medium">{client.fullName}</p>
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
client.status === 'active'
? 'bg-emerald-100 text-emerald-800'
: client.status === 'inactive'
? 'bg-muted text-muted-foreground'
: 'bg-blue-100 text-blue-800',
)}
>
{STATUS_LABELS[client.status] ?? client.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{client.email ? <span className="truncate">{client.email}</span> : null}
{client.phone ? <span>{client.phone}</span> : null}
{client.placeOfResidence ? <span>{client.placeOfResidence}</span> : null}
{client.source ? <span className="capitalize">· {client.source}</span> : null}
</div>
</Link>
);
}
function NewResidentialClientSheet({
open,
onOpenChange,