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>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user