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) {
|
||||
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 sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
|
||||
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" />
|
||||
</div>
|
||||
|
||||
{primary ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{primary.value}</p>
|
||||
{primaryContactValue ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{primaryContactValue}</p>
|
||||
) : null}
|
||||
|
||||
{meta.length > 0 ? (
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
|
||||
export interface ClientRow {
|
||||
@@ -23,14 +22,27 @@ export interface ClientRow {
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
primaryEmail?: string | null;
|
||||
primaryPhone?: string | null;
|
||||
yachtCount?: number;
|
||||
companyCount?: number;
|
||||
interestCount?: number;
|
||||
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||
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> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
@@ -65,24 +77,29 @@ export function getClientColumns({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'primaryContact',
|
||||
header: 'Primary Contact',
|
||||
id: 'email',
|
||||
header: 'Email',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
||||
if (!primary) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
||||
{primary.value}
|
||||
</span>
|
||||
);
|
||||
const value = row.original.primaryEmail;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{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',
|
||||
header: 'Nationality',
|
||||
header: 'Country',
|
||||
cell: ({ getValue }) => {
|
||||
const iso = getValue() as string | null;
|
||||
return (
|
||||
@@ -105,51 +122,20 @@ export function getClientColumns({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'yachtCount',
|
||||
header: 'Yachts',
|
||||
id: 'latestStage',
|
||||
header: 'Latest stage',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.yachtCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<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>;
|
||||
const latest = row.original.latestInterest;
|
||||
if (!latest) return <span className="text-muted-foreground">-</span>;
|
||||
const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clientTags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{clientTags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{clientTags.length - 3}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{stageLabel}
|
||||
</Badge>
|
||||
{latest.mooringNumber && (
|
||||
<span className="text-muted-foreground">{latest.mooringNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user