fix(clients): list contacts join + nationality backfill + col redesign

Wire primary email + primary phone into the /clients list service so
the redesigned columns (Name · Email · Phone · Country · Source ·
Latest stage · Created) actually have data. Picks the row marked
is_primary=true; falls back to most-recent created_at when the flag
is unset.

- 0026 schema migration: unique partial index
  idx_cc_one_primary_per_channel on (client_id, channel) WHERE
  is_primary=true. Prevents the §14.2 "multiple primaries" ambiguity.
- 0027 data migration: backfill clients.nationality_iso from the
  primary phone's value_country. 218 -> 36 missing on dev. Idempotent.
- listClients: add a fifth parallel query for client_contacts; build
  primaryEmailMap / primaryPhoneMap in-memory from the pre-sorted
  result.
- client-columns: drop Yachts/Companies/Tags from the default view
  per §5.1; add Email/Phone/Country/Latest-stage columns; rename
  "Nationality" -> "Country" since phone country is a proxy (§14.2).
- client-card: prefer email, fall back to phone, for the line under
  the name; replaces the old `contacts.find(isPrimary)` lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 02:15:03 +02:00
parent a2588f2c4a
commit 3017ce4b3a
9 changed files with 21563 additions and 106 deletions

View File

@@ -35,7 +35,8 @@ interface ClientCardProps {
} }
export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) { export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) {
const primary = client.contacts?.find((c) => c.isPrimary); // Card display: prefer email, fall back to phone.
const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null;
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null; const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
const tags = client.tags ?? []; const tags = client.tags ?? [];
@@ -93,8 +94,8 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{primary ? ( {primaryContactValue ? (
<p className="truncate text-sm text-muted-foreground">{primary.value}</p> <p className="truncate text-sm text-muted-foreground">{primaryContactValue}</p>
) : null} ) : null}
{meta.length > 0 ? ( {meta.length > 0 ? (

View File

@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { getCountryName } from '@/lib/i18n/countries'; import { getCountryName } from '@/lib/i18n/countries';
export interface ClientRow { export interface ClientRow {
@@ -23,14 +22,27 @@ export interface ClientRow {
source: string | null; source: string | null;
archivedAt: string | null; archivedAt: string | null;
createdAt: string; createdAt: string;
primaryEmail?: string | null;
primaryPhone?: string | null;
yachtCount?: number; yachtCount?: number;
companyCount?: number; companyCount?: number;
interestCount?: number; interestCount?: number;
latestInterest?: { stage: string; mooringNumber: string | null } | null; latestInterest?: { stage: string; mooringNumber: string | null } | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
} }
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
qualified: 'Qualified',
eoi_sent: 'EOI sent',
eoi_signed: 'EOI signed',
deposit: 'Deposit',
contract: 'Contract',
signed: 'Signed',
closed_won: 'Won',
closed_lost: 'Lost',
};
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
website: 'Website', website: 'Website',
manual: 'Manual', manual: 'Manual',
@@ -65,24 +77,29 @@ export function getClientColumns({
), ),
}, },
{ {
id: 'primaryContact', id: 'email',
header: 'Primary Contact', header: 'Email',
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const primary = row.original.contacts?.find((c) => c.isPrimary); const value = row.original.primaryEmail;
if (!primary) return <span className="text-muted-foreground">-</span>; if (!value) return <span className="text-muted-foreground">-</span>;
return ( return <span className="text-sm">{value}</span>;
<span className="text-sm">
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
{primary.value}
</span>
);
}, },
}, },
{ {
id: 'nationality', id: 'phone',
header: 'Phone',
enableSorting: false,
cell: ({ row }) => {
const value = row.original.primaryPhone;
if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>;
},
},
{
id: 'country',
accessorKey: 'nationalityIso', accessorKey: 'nationalityIso',
header: 'Nationality', header: 'Country',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const iso = getValue() as string | null; const iso = getValue() as string | null;
return ( return (
@@ -105,51 +122,20 @@ export function getClientColumns({
}, },
}, },
{ {
id: 'yachtCount', id: 'latestStage',
header: 'Yachts', header: 'Latest stage',
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.yachtCount ?? 0; const latest = row.original.latestInterest;
return c === 0 ? ( if (!latest) return <span className="text-muted-foreground">-</span>;
<span className="text-muted-foreground">-</span> const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage;
) : (
<Badge variant="secondary" className="text-xs">
{c}
</Badge>
);
},
},
{
id: 'companyCount',
header: 'Companies',
enableSorting: false,
cell: ({ row }) => {
const c = row.original.companyCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground">-</span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
</Badge>
);
},
},
{
id: 'tags',
header: 'Tags',
enableSorting: false,
cell: ({ row }) => {
const clientTags = row.original.tags ?? [];
if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex items-center gap-2 text-sm">
{clientTags.slice(0, 3).map((tag) => ( <Badge variant="secondary" className="text-xs capitalize">
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> {stageLabel}
))} </Badge>
{clientTags.length > 3 && ( {latest.mooringNumber && (
<Badge variant="secondary" className="text-xs"> <span className="text-muted-foreground">{latest.mooringNumber}</span>
+{clientTags.length - 3}
</Badge>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX "idx_cc_one_primary_per_channel" ON "client_contacts" USING btree ("client_id","channel") WHERE "client_contacts"."is_primary" = true;

View File

@@ -0,0 +1,24 @@
-- Backfill clients.nationality_iso from the primary phone's parsed
-- value_country. Idempotent (only runs on rows where nationality_iso
-- is null), safe to re-execute. Phone country is a *proxy* for
-- nationality - the client-list UI labels the column "Country" rather
-- than "Nationality" to avoid implying it's authoritative (see §14.2).
--
-- Pattern: prefer the row marked `is_primary=true`; fall back to the
-- most recently created phone contact when no row is flagged primary.
WITH primary_phone AS (
SELECT DISTINCT ON (cc.client_id)
cc.client_id,
cc.value_country
FROM client_contacts cc
WHERE cc.channel = 'phone'
AND cc.value_country IS NOT NULL
ORDER BY cc.client_id,
cc.is_primary DESC,
cc.created_at DESC
)
UPDATE clients c
SET nationality_iso = primary_phone.value_country
FROM primary_phone
WHERE c.nationality_iso IS NULL
AND c.id = primary_phone.client_id;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,20 @@
"when": 1777939212954, "when": 1777939212954,
"tag": "0025_berth_pricing_columns", "tag": "0025_berth_pricing_columns",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777939906731,
"tag": "0026_client_contacts_one_primary_per_channel",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1777939914252,
"tag": "0027_backfill_nationality_iso_from_phone",
"breakpoints": true
} }
] ]
} }

View File

@@ -77,6 +77,11 @@ export const clientContacts = pgTable(
index('idx_cc_phone') index('idx_cc_phone')
.on(table.channel, table.value) .on(table.channel, table.value)
.where(sql`${table.channel} = 'phone'`), .where(sql`${table.channel} = 'phone'`),
// At most one is_primary=true per (client_id, channel). Prevents
// ambiguity when the /clients list pulls "the" primary phone/email.
uniqueIndex('idx_cc_one_primary_per_channel')
.on(table.clientId, table.channel)
.where(sql`${table.isPrimary} = true`),
], ],
); );

View File

@@ -84,53 +84,71 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id); const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([ const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = await Promise.all(
db [
.select({ ownerId: yachts.currentOwnerId, count: count() }) db
.from(yachts) .select({ ownerId: yachts.currentOwnerId, count: count() })
.where( .from(yachts)
and( .where(
eq(yachts.portId, portId), and(
eq(yachts.currentOwnerType, 'client'), eq(yachts.portId, portId),
inArray(yachts.currentOwnerId, ids), eq(yachts.currentOwnerType, 'client'),
isNull(yachts.archivedAt), inArray(yachts.currentOwnerId, ids),
), isNull(yachts.archivedAt),
) ),
.groupBy(yachts.currentOwnerId), )
db .groupBy(yachts.currentOwnerId),
.select({ clientId: companyMemberships.clientId, count: count() }) db
.from(companyMemberships) .select({ clientId: companyMemberships.clientId, count: count() })
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate))) .from(companyMemberships)
.groupBy(companyMemberships.clientId), .where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
db .groupBy(companyMemberships.clientId),
.select({ db
clientId: interests.clientId, .select({
pipelineStage: interests.pipelineStage, clientId: interests.clientId,
updatedAt: interests.updatedAt, pipelineStage: interests.pipelineStage,
mooringNumber: berths.mooringNumber, updatedAt: interests.updatedAt,
}) mooringNumber: berths.mooringNumber,
.from(interests) })
.leftJoin(berths, eq(berths.id, interests.berthId)) .from(interests)
.where( .leftJoin(berths, eq(berths.id, interests.berthId))
and( .where(
eq(interests.portId, portId), and(
inArray(interests.clientId, ids), eq(interests.portId, portId),
isNull(interests.archivedAt), inArray(interests.clientId, ids),
), isNull(interests.archivedAt),
) ),
.orderBy(desc(interests.updatedAt)), )
db .orderBy(desc(interests.updatedAt)),
.select({ clientId: interests.clientId, count: count() }) db
.from(interests) .select({ clientId: interests.clientId, count: count() })
.where( .from(interests)
and( .where(
eq(interests.portId, portId), and(
inArray(interests.clientId, ids), eq(interests.portId, portId),
isNull(interests.archivedAt), inArray(interests.clientId, ids),
), isNull(interests.archivedAt),
) ),
.groupBy(interests.clientId), )
]); .groupBy(interests.clientId),
// Pull every contact row for the page; the per-client primary
// resolution happens in the post-fetch loop below. Cheaper than
// running a DISTINCT-ON query per channel and keeps the picker
// logic (is_primary desc, then most recent created_at) in one
// place.
db
.select({
clientId: clientContacts.clientId,
channel: clientContacts.channel,
value: clientContacts.value,
isPrimary: clientContacts.isPrimary,
createdAt: clientContacts.createdAt,
})
.from(clientContacts)
.where(inArray(clientContacts.clientId, ids))
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.createdAt)),
],
);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count])); const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count])); const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
@@ -146,6 +164,18 @@ export async function listClients(portId: string, query: ListClientsInput) {
} }
} }
// Pick the per-client primary (or, failing that, most-recent) email
// and phone. contactRows is pre-sorted is_primary desc, created_at desc.
const primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>();
for (const c of contactRows) {
if (c.channel === 'email' && !primaryEmailMap.has(c.clientId)) {
primaryEmailMap.set(c.clientId, c.value);
} else if (c.channel === 'phone' && !primaryPhoneMap.has(c.clientId)) {
primaryPhoneMap.set(c.clientId, c.value);
}
}
return { return {
...result, ...result,
data: result.data.map((row) => { data: result.data.map((row) => {
@@ -155,6 +185,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
yachtCount: yachtCountMap.get(row.id) ?? 0, yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0, companyCount: companyCountMap.get(row.id) ?? 0,
interestCount: interestCountMap.get(row.id) ?? 0, interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
latestInterest: latest latestInterest: latest
? { ? {
stage: latest.stage, stage: latest.stage,