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>
340 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|