refactor(clients): rebuild detail tabs + columns for new data model

- ClientData in client-detail.tsx now reflects the stripped shape from
  Task 8.2 (drop companyName/isProxy/proxy*/yacht*/berthSizeDesired) and
  gains yachts / companies / activeReservations arrays.
- client-tabs.tsx: Overview trimmed (personal, contacts, source, tags);
  three new count-badged tabs (Yachts, Companies, Reservations).
- New client-yachts-tab.tsx renders owned yachts + Add yacht CTA (TODO:
  YachtForm preset-owner wiring for v2).
- New client-companies-tab.tsx renders memberships with Primary badge and
  since-date; management still lives on the company detail page.
- New client-reservations-tab.tsx maps activeReservations into ReservationRow
  shape and delegates to <ReservationList showBerth />.
- client-columns.tsx drops companyName column (TODO: add Yachts count +
  Primary company once list endpoint joins those).
- client-filters.tsx drops isProxy filter.
- Wire realtime invalidations for yacht:ownership_transferred,
  company_membership:added/ended, and berth_reservation:*.
This commit is contained in:
Matt Ciaccio
2026-04-24 14:36:34 +02:00
parent 4c171848fc
commit b75834ab7e
7 changed files with 374 additions and 113 deletions

View File

@@ -18,7 +18,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
export interface ClientRow { export interface ClientRow {
id: string; id: string;
fullName: string; fullName: string;
companyName: string | null; nationality: string | null;
source: string | null; source: string | null;
archivedAt: string | null; archivedAt: string | null;
createdAt: string; createdAt: string;
@@ -39,6 +39,10 @@ interface GetColumnsOptions {
onArchive: (client: ClientRow) => void; onArchive: (client: ClientRow) => void;
} }
// TODO: Add "Yachts" (count) and "Primary company" columns once the
// GET /api/v1/clients list endpoint joins owned-yachts and primary-company
// data into the row shape. Until then, the columns are omitted rather than
// shown as empty placeholders.
export function getClientColumns({ export function getClientColumns({
portSlug, portSlug,
onEdit, onEdit,
@@ -59,14 +63,6 @@ export function getClientColumns({
</Link> </Link>
), ),
}, },
{
id: 'companyName',
accessorKey: 'companyName',
header: 'Company',
cell: ({ getValue }) => (
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
),
},
{ {
id: 'primaryContact', id: 'primaryContact',
header: 'Primary Contact', header: 'Primary Contact',
@@ -82,6 +78,14 @@ export function getClientColumns({
); );
}, },
}, },
{
id: 'nationality',
accessorKey: 'nationality',
header: 'Nationality',
cell: ({ getValue }) => (
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
),
},
{ {
id: 'source', id: 'source',
accessorKey: 'source', accessorKey: 'source',
@@ -149,10 +153,7 @@ export function getClientColumns({
<Pencil className="mr-2 h-3.5 w-3.5" /> <Pencil className="mr-2 h-3.5 w-3.5" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<Archive className="mr-2 h-3.5 w-3.5" /> <Archive className="mr-2 h-3.5 w-3.5" />
Archive Archive
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
import {
Table,
TableHeader,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/shared/empty-state';
interface ClientCompaniesTabProps {
clientId: string;
companies: Array<{
membershipId: string;
role: string;
isPrimary: boolean;
startDate: string | Date;
company: {
id: string;
name: string;
legalName: string | null;
status: string;
};
}>;
}
function formatSince(startDate: string | Date): string {
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
if (Number.isNaN(d.getTime())) return '—';
return format(d, 'MMM d, yyyy');
}
export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCompaniesTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
if (companies.length === 0) {
return (
<EmptyState
title="No company memberships"
description="This client is not affiliated with any companies yet. Add a membership from a company's detail page."
/>
);
}
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">Company affiliations</h3>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Role</TableHead>
<TableHead>Primary</TableHead>
<TableHead>Since</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((m) => (
<TableRow key={m.membershipId}>
<TableCell>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/companies/${m.company.id}` as any}
className="text-primary hover:underline"
>
{m.company.name}
</Link>
{m.company.legalName && (
<span className="ml-2 text-xs text-muted-foreground">
({m.company.legalName})
</span>
)}
</TableCell>
<TableCell className="capitalize">{m.role.replace('_', ' ')}</TableCell>
<TableCell>
{m.isPrimary ? (
<Badge variant="secondary" className="text-xs">
Primary
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatSince(m.startDate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -12,19 +12,7 @@ interface ClientData {
id: string; id: string;
portId: string; portId: string;
fullName: string; fullName: string;
companyName: string | null;
nationality: string | null; nationality: string | null;
isProxy: boolean;
proxyType: string | null;
actualOwnerName: string | null;
yachtName: string | null;
yachtLengthFt: string | null;
yachtWidthFt: string | null;
yachtDraftFt: string | null;
yachtLengthM: string | null;
yachtWidthM: string | null;
yachtDraftM: string | null;
berthSizeDesired: string | null;
preferredContactMethod: string | null; preferredContactMethod: string | null;
preferredLanguage: string | null; preferredLanguage: string | null;
timezone: string | null; timezone: string | null;
@@ -46,6 +34,35 @@ interface ClientData {
name: string; name: string;
color: string; color: string;
}>; }>;
yachts: Array<{
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
lengthFt: string | null;
widthFt: string | null;
status: string;
}>;
companies: Array<{
membershipId: string;
role: string;
isPrimary: boolean;
startDate: string | Date;
company: {
id: string;
name: string;
legalName: string | null;
status: string;
};
}>;
activeReservations: Array<{
id: string;
berthId: string;
yachtId: string;
startDate: string | Date;
tenureType: string;
status: string;
}>;
} }
interface ClientDetailProps { interface ClientDetailProps {
@@ -64,11 +81,15 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
'client:updated': [['clients', clientId]], 'client:updated': [['clients', clientId]],
'client:archived': [['clients', clientId]], 'client:archived': [['clients', clientId]],
'client:restored': [['clients', clientId]], 'client:restored': [['clients', clientId]],
'yacht:ownership_transferred': [['clients', clientId]],
'company_membership:added': [['clients', clientId]],
'company_membership:ended': [['clients', clientId]],
'berth_reservation:activated': [['clients', clientId]],
'berth_reservation:ended': [['clients', clientId]],
'berth_reservation:cancelled': [['clients', clientId]],
}); });
const tabs = data const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : [];
? getClientTabs({ clientId, currentUserId, client: data })
: [];
return ( return (
<DetailLayout <DetailLayout

View File

@@ -24,11 +24,6 @@ export const clientFilterDefinitions: FilterDefinition[] = [
type: 'text', type: 'text',
placeholder: 'Filter by nationality...', placeholder: 'Filter by nationality...',
}, },
{
key: 'isProxy',
label: 'Proxy Client',
type: 'boolean',
},
{ {
key: 'includeArchived', key: 'includeArchived',
label: 'Include Archived', label: 'Include Archived',

View File

@@ -0,0 +1,51 @@
'use client';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
interface ClientReservationsTabProps {
clientId: string;
activeReservations: Array<{
id: string;
berthId: string;
yachtId: string;
startDate: string | Date;
tenureType: string;
status: string;
}>;
}
export function ClientReservationsTab({
clientId,
activeReservations,
}: ClientReservationsTabProps) {
const rows: ReservationRow[] = activeReservations.map((r) => ({
id: r.id,
berthId: r.berthId,
portId: '', // not rendered by ReservationList
clientId,
yachtId: r.yachtId,
status: r.status as ReservationRow['status'],
startDate: typeof r.startDate === 'string' ? r.startDate : r.startDate.toISOString(),
endDate: null,
tenureType: r.tenureType,
contractFileId: null,
notes: null,
createdAt: '',
}));
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Active reservations</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Showing currently active reservations. History is coming soon.
</p>
</div>
<ReservationList
reservations={rows}
showBerth
emptyMessage="This client has no active reservations."
/>
</div>
);
}

View File

@@ -2,22 +2,16 @@
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
interface ClientTabsOptions { interface ClientTabsOptions {
clientId: string; clientId: string;
currentUserId?: string; currentUserId?: string;
client: { client: {
fullName: string; fullName: string;
companyName?: string | null;
nationality?: string | null; nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
yachtLengthFt?: string | null;
yachtWidthFt?: string | null;
yachtDraftFt?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null; preferredContactMethod?: string | null;
preferredLanguage?: string | null; preferredLanguage?: string | null;
timezone?: string | null; timezone?: string | null;
@@ -30,6 +24,36 @@ interface ClientTabsOptions {
label?: string | null; label?: string | null;
isPrimary: boolean; isPrimary: boolean;
}>; }>;
yachts: Array<{
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
lengthFt: string | null;
widthFt: string | null;
status: string;
}>;
companies: Array<{
membershipId: string;
role: string;
isPrimary: boolean;
startDate: string | Date;
company: {
id: string;
name: string;
legalName: string | null;
status: string;
};
}>;
activeReservations: Array<{
id: string;
berthId: string;
yachtId: string;
startDate: string | Date;
tenureType: string;
status: string;
}>;
tags?: Array<{ id: string; name: string; color: string }>;
}; };
} }
@@ -51,14 +75,10 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
<h3 className="text-sm font-medium mb-2">Personal Information</h3> <h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl> <dl>
<InfoRow label="Full Name" value={client.fullName} /> <InfoRow label="Full Name" value={client.fullName} />
<InfoRow label="Company" value={client.companyName} />
<InfoRow label="Nationality" value={client.nationality} /> <InfoRow label="Nationality" value={client.nationality} />
<InfoRow label="Preferred Language" value={client.preferredLanguage} /> <InfoRow label="Preferred Language" value={client.preferredLanguage} />
<InfoRow label="Timezone" value={client.timezone} /> <InfoRow label="Timezone" value={client.timezone} />
<InfoRow <InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
label="Preferred Contact"
value={client.preferredContactMethod}
/>
</dl> </dl>
</div> </div>
@@ -72,18 +92,12 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
key={c.id} key={c.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm" className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
> >
<span className="capitalize text-muted-foreground w-20 shrink-0"> <span className="capitalize text-muted-foreground w-20 shrink-0">{c.channel}</span>
{c.channel}
</span>
<span className="flex-1">{c.value}</span> <span className="flex-1">{c.value}</span>
{c.label && ( {c.label && (
<span className="text-xs text-muted-foreground capitalize"> <span className="text-xs text-muted-foreground capitalize">{c.label}</span>
{c.label}
</span>
)}
{c.isPrimary && (
<span className="text-xs font-medium text-primary">Primary</span>
)} )}
{c.isPrimary && <span className="text-xs font-medium text-primary">Primary</span>}
</div> </div>
))} ))}
</div> </div>
@@ -92,41 +106,6 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
)} )}
</div> </div>
{/* Yacht Details */}
{(client.yachtName ||
client.yachtLengthFt ||
client.berthSizeDesired) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Yacht Details</h3>
<dl>
<InfoRow label="Yacht Name" value={client.yachtName} />
<InfoRow
label="Length"
value={
client.yachtLengthFt
? `${client.yachtLengthFt} ft`
: undefined
}
/>
<InfoRow
label="Width"
value={
client.yachtWidthFt ? `${client.yachtWidthFt} ft` : undefined
}
/>
<InfoRow
label="Draft"
value={
client.yachtDraftFt
? `${client.yachtDraftFt} ft`
: undefined
}
/>
<InfoRow label="Berth Size Desired" value={client.berthSizeDesired} />
</dl>
</div>
)}
{/* Source */} {/* Source */}
{(client.source || client.sourceDetails) && ( {(client.source || client.sourceDetails) && (
<div className="space-y-1"> <div className="space-y-1">
@@ -138,34 +117,54 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
</div> </div>
)} )}
{/* Proxy Info */} {/* Tags */}
{client.isProxy && ( {client.tags && client.tags.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Proxy Information</h3> <h3 className="text-sm font-medium mb-2">Tags</h3>
<dl> <div className="flex flex-wrap gap-1">
<InfoRow {client.tags.map((tag) => (
label="Proxy Type" <span
value={client.proxyType?.replace('_', ' ')} key={tag.id}
/> className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
<InfoRow label="Actual Owner" value={client.actualOwnerName} /> style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
</dl> >
{tag.name}
</span>
))}
</div>
</div> </div>
)} )}
</div> </div>
); );
} }
export function getClientTabs({ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOptions): DetailTab[] {
clientId,
currentUserId,
client,
}: ClientTabsOptions): DetailTab[] {
return [ return [
{ {
id: 'overview', id: 'overview',
label: 'Overview', label: 'Overview',
content: <OverviewTab client={client} />, content: <OverviewTab client={client} />,
}, },
{
id: 'yachts',
label: 'Yachts',
badge: client.yachts.length,
content: <ClientYachtsTab clientId={clientId} yachts={client.yachts} />,
},
{
id: 'companies',
label: 'Companies',
badge: client.companies.length,
content: <ClientCompaniesTab clientId={clientId} companies={client.companies} />,
},
{
id: 'reservations',
label: 'Reservations',
badge: client.activeReservations.length,
content: (
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
),
},
{ {
id: 'interests', id: 'interests',
label: 'Interests', label: 'Interests',
@@ -178,13 +177,7 @@ export function getClientTabs({
{ {
id: 'notes', id: 'notes',
label: 'Notes', label: 'Notes',
content: ( content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
/>
),
}, },
{ {
id: 'files', id: 'files',

View File

@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableHeader,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
interface ClientYachtsTabProps {
clientId: string;
yachts: Array<{
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
lengthFt: string | null;
widthFt: string | null;
status: string;
}>;
}
export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Client-owned yachts</h3>
<PermissionGate resource="yachts" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add yacht
</Button>
</PermissionGate>
</div>
{yachts.length === 0 ? (
<EmptyState title="No yachts" description="No yachts owned by this client yet." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Dimensions</TableHead>
<TableHead>Hull Number</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{yachts.map((y) => (
<TableRow key={y.id}>
<TableCell>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${y.id}` as any}
className="text-primary hover:underline"
>
{y.name}
</Link>
</TableCell>
<TableCell>
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
</TableCell>
<TableCell>{y.hullNumber ?? '—'}</TableCell>
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/*
TODO: YachtForm (Task 5.2) does not yet accept a preset owner prop.
When opened here, the user must manually pick this client in the owner
picker. Wire an `initialOwner` prop into YachtForm in a follow-up so
we can pre-select `{ type: 'client', id: clientId }`.
*/}
{createOpen && <YachtForm open={createOpen} onOpenChange={setCreateOpen} />}
</div>
);
}