'use client'; import Link from 'next/link'; import { format } from 'date-fns'; import { MoreHorizontal, Pencil, Archive, Mail, MessageCircle, Phone } from 'lucide-react'; import type { ColumnDef } from '@tanstack/react-table'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Badge } from '@/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { getCountryName } from '@/lib/i18n/countries'; import { stageDotClass, stageLabel } from '@/lib/constants'; import { cn } from '@/lib/utils'; import type { ColumnPickerOption } from '@/components/shared/column-picker'; export interface ClientRow { id: string; fullName: string; nationalityIso: string | null; source: string | null; archivedAt: string | null; createdAt: string; primaryEmail?: string | null; primaryPhone?: string | null; /** E.164 (digits + leading +) — used to build wa.me / tel: links. */ primaryPhoneE164?: string | null; yachtCount?: number; companyCount?: number; interestCount?: number; latestInterest?: { stage: string; mooringNumber: string | null } | null; /** * Berths the client has interests in (active only) with the most-active * interest's stage attached. Sorted server-side: open deals first, most * progressed stage first, then mooring alphabetical. Each chip in the * list view links to the interest, not the berth — that's the action * sales reps want. */ linkedBerths?: Array<{ id: string; mooringNumber: string; interestId: string; stage: string; outcome: string | null; }>; tags?: Array<{ id: string; name: string; color: string }>; } /** * Picker manifest — drives the `` dropdown next to the * filter bar. Order here is the order shown in the menu. `alwaysVisible` * marks columns the user can't hide (otherwise the table is unusable). * * "Latest stage" used to be a default-on column, but each Berths chip * now carries its own per-interest stage (color dot + label), so the * standalone column was duplicating the same information. Kept in the * picker for users who want a single coarse "what's their most recent * stage" indicator regardless of berth. */ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [ { id: 'fullName', label: 'Name', alwaysVisible: true }, { id: 'email', label: 'Email' }, { id: 'phone', label: 'Phone' }, { id: 'country', label: 'Country' }, { id: 'source', label: 'Source' }, { id: 'berths', label: 'Berths' }, { id: 'latestStage', label: 'Latest stage (legacy)' }, { id: 'createdAt', label: 'Created' }, ]; /** * Default-hidden columns for a fresh user. The hook merges this with * the user's saved overrides — once they explicitly toggle a column, * their choice wins. New columns surface for existing users by default * (they're absent from the user's stored hidden list). */ export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage']; const SOURCE_LABELS: Record = { website: 'Website', manual: 'Manual', referral: 'Referral', broker: 'Broker', }; interface GetColumnsOptions { portSlug: string; onEdit: (client: ClientRow) => void; onArchive: (client: ClientRow) => void; } export function getClientColumns({ portSlug, onEdit, onArchive, }: GetColumnsOptions): ColumnDef[] { return [ { id: 'fullName', accessorKey: 'fullName', header: 'Name', cell: ({ row }) => ( e.stopPropagation()} > {row.original.fullName} ), }, { id: 'email', header: 'Email', enableSorting: false, cell: ({ row }) => { const value = row.original.primaryEmail; if (!value) return -; return ( e.stopPropagation()} className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline" title={`Email ${value}`} > {value} ); }, }, { id: 'phone', header: 'Phone', enableSorting: false, cell: ({ row }) => { const value = row.original.primaryPhone; const e164 = row.original.primaryPhoneE164; if (!value) return -; // wa.me requires the E.164 digits without the leading +; fall // back to a tel: link when the contact hasn't been normalized // yet (legacy rows imported before the i18n PhoneInput shipped). const waDigits = e164 ? e164.replace(/[^0-9]/g, '') : null; return ( e.stopPropagation()} className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline" title={`Call ${value}`} > {value} {waDigits && ( e.stopPropagation()} className="text-emerald-600 hover:text-emerald-700" title={`WhatsApp ${value}`} aria-label={`WhatsApp ${value}`} > )} ); }, }, { id: 'country', accessorKey: 'nationalityIso', header: 'Country', cell: ({ getValue }) => { const iso = getValue() as string | null; return ( {iso ? getCountryName(iso, 'en') : '-'} ); }, }, { id: 'source', accessorKey: 'source', header: 'Source', cell: ({ getValue }) => { const source = getValue() as string | null; if (!source) return -; return ( {SOURCE_LABELS[source] ?? source} ); }, }, { id: 'berths', header: 'Berths', enableSorting: false, cell: ({ row }) => { const list = row.original.linkedBerths ?? []; if (list.length === 0) return -; // Show the 2 most-actionable interests inline (sorted server- // side: open before closed, most-progressed stage first). The // remainder collapses behind a "+N" popover so the row stays // single-line even for clients with many historical interests. const VISIBLE = 2; const head = list.slice(0, VISIBLE); const overflow = list.slice(VISIBLE); return (
{head.map((b) => ( ))} {overflow.length > 0 && ( e.stopPropagation()} >
All linked berths
{list.map((b) => ( e.stopPropagation()} className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent" > {b.mooringNumber} {b.outcome ? `${stageLabel(b.stage)} · ${b.outcome.replace(/_/g, ' ')}` : stageLabel(b.stage)} ))}
)}
); }, }, { // Hidden by default — the per-berth stage is now carried by each // chip in the Berths column, so this standalone column is only // useful when a user has explicitly toggled it on. id: 'latestStage', header: 'Latest stage', enableSorting: false, cell: ({ row }) => { const latest = row.original.latestInterest; if (!latest) return -; return ( {stageLabel(latest.stage)} ); }, }, { id: 'createdAt', accessorKey: 'createdAt', header: 'Created', cell: ({ getValue }) => ( {format(new Date(getValue() as string), 'MMM d, yyyy')} ), }, { id: 'actions', header: '', enableSorting: false, size: 48, cell: ({ row }) => ( onEdit(row.original)}> Edit onArchive(row.original)}> Archive ), }, ]; } /** * Single berth-with-stage chip used in the inline (top-2) chip row of * the Berths column. Shows mooring + full stage label, with a colored * dot for stage reinforcement (decorative — the label carries the * meaning so color-blind / no-hover users don't lose anything). * * Click target is the *interest*, not the berth — the user almost * always wants to act on the deal, not look at the berth's static * specs. Outcome-set rows (won/lost/cancelled) get a muted dot so they * read as historical context rather than active work. */ function BerthInterestChip({ berth, portSlug, }: { berth: NonNullable[number]; portSlug: string; }) { const isClosed = berth.outcome !== null; const label = isClosed ? `${stageLabel(berth.stage)} · ${berth.outcome!.replace(/_/g, ' ')}` : stageLabel(berth.stage); return ( e.stopPropagation()} title={`Open interest · ${berth.mooringNumber} · ${label}`} className={cn( 'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs transition-colors', 'border-border bg-background hover:bg-accent', isClosed && 'opacity-60', )} > {berth.mooringNumber} · {label} ); }