Files
pn-new-crm/src/components/residential/residential-clients-list.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

340 lines
12 KiB
TypeScript

'use client';
import { useState } from 'react';
import Link from 'next/link';
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 { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { PageHeader } from '@/components/shared/page-header';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
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';
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[];
pagination: { total: number; page: number; pageSize: number };
}
const STATUS_LABELS: Record<string, string> = {
prospect: 'Prospect',
active: 'Active',
inactive: 'Inactive',
};
export function ResidentialClientsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['residential-clients', { search }],
queryFn: () => {
const qs = new URLSearchParams({ search, limit: '50' });
return apiFetch(`/api/v1/residential/clients?${qs.toString()}`);
},
});
useRealtimeInvalidation({
'residential_client:created': [['residential-clients']],
'residential_client:updated': [['residential-clients']],
'residential_client:archived': [['residential-clients']],
'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
title="Residential Clients"
description="Inquiries and clients for the residential side"
actions={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
New
</Button>
}
/>
<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>
<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>
}
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,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const qc = useQueryClient();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
const [nationalityIso, setNationalityIso] = useState<CountryCode | null>(null);
const [timezone, setTimezone] = useState<string | null>(null);
const [placeOfResidence, setPlaceOfResidence] = useState('');
const [residenceCountry, setResidenceCountry] = useState<CountryCode | null>(null);
const [residenceSubdivision, setResidenceSubdivision] = useState<string | null>(null);
const [notes, setNotes] = useState('');
function reset() {
setFullName('');
setEmail('');
setPhone(null);
setNationalityIso(null);
setTimezone(null);
setPlaceOfResidence('');
setResidenceCountry(null);
setResidenceSubdivision(null);
setNotes('');
}
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/clients', {
method: 'POST',
body: {
fullName,
email: email || undefined,
phone: phone?.e164 ?? undefined,
phoneE164: phone?.e164 ?? undefined,
phoneCountry: phone?.country ?? undefined,
nationalityIso: nationalityIso ?? undefined,
timezone: timezone ?? undefined,
placeOfResidence: placeOfResidence || undefined,
placeOfResidenceCountryIso: residenceCountry ?? undefined,
subdivisionIso: residenceSubdivision ?? undefined,
notes: notes || undefined,
source: 'manual',
},
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-clients'] });
onOpenChange(false);
reset();
toast.success('Residential client added');
},
onError: (err) => {
toastError(err);
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>New residential client</SheetTitle>
</SheetHeader>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="rc-name">Full name *</Label>
<Input
id="rc-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-email">Email</Label>
<Input
id="rc-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-phone">Phone</Label>
<PhoneInput id="rc-phone" value={phone} onChange={setPhone} data-testid="rc-phone" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="rc-nationality">Nationality</Label>
<CountryCombobox
id="rc-nationality"
value={nationalityIso}
onChange={setNationalityIso}
data-testid="rc-nationality"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-timezone">Timezone</Label>
<TimezoneCombobox
id="rc-timezone"
value={timezone}
onChange={setTimezone}
countryHint={nationalityIso ?? undefined}
data-testid="rc-timezone"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence">Place of residence</Label>
<Input
id="rc-residence"
value={placeOfResidence}
onChange={(e) => setPlaceOfResidence(e.target.value)}
placeholder="City or area"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="rc-residence-country">Country</Label>
<CountryCombobox
id="rc-residence-country"
value={residenceCountry}
onChange={(iso) => {
setResidenceCountry(iso);
// Wipe subdivision when country flips - codes are scoped per country.
setResidenceSubdivision(null);
}}
data-testid="rc-residence-country"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence-subdivision">Region</Label>
<SubdivisionCombobox
id="rc-residence-subdivision"
value={residenceSubdivision}
onChange={setResidenceSubdivision}
country={residenceCountry}
data-testid="rc-residence-subdivision"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-notes">Notes</Label>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={create.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={!fullName.trim() || create.isPending}>
{create.isPending ? 'Saving…' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}