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:
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
))}
|
|
||||||
{clientTags.length > 3 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
+{clientTags.length - 3}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{latest.mooringNumber && (
|
||||||
|
<span className="text-muted-foreground">{latest.mooringNumber}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
10697
src/lib/db/migrations/meta/0026_snapshot.json
Normal file
10697
src/lib/db/migrations/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
10697
src/lib/db/migrations/meta/0027_snapshot.json
Normal file
10697
src/lib/db/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ 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
|
db
|
||||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||||
.from(yachts)
|
.from(yachts)
|
||||||
@@ -130,7 +131,24 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.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,
|
||||||
|
|||||||
Reference in New Issue
Block a user