Files
pn-new-crm/src/components/clients/client-columns.tsx
Matt 6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00

372 lines
13 KiB
TypeScript

'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive, Mail, Phone } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
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, formatSource, formatOutcome } 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 `<ColumnPicker>` 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'];
interface GetColumnsOptions {
portSlug: string;
onEdit: (client: ClientRow) => void;
onArchive: (client: ClientRow) => void;
}
export function getClientColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<ClientRow, unknown>[] {
return [
{
id: 'fullName',
accessorKey: 'fullName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/clients/${row.original.id}`}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.fullName}
</Link>
),
},
{
id: 'email',
header: 'Email',
enableSorting: false,
cell: ({ row }) => {
const value = row.original.primaryEmail;
if (!value) return <span className="text-muted-foreground">-</span>;
return (
<a
href={`mailto:${value}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline"
title={`Email ${value}`}
>
<Mail className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate">{value}</span>
</a>
);
},
},
{
id: 'phone',
header: 'Phone',
enableSorting: false,
cell: ({ row }) => {
const value = row.original.primaryPhone;
const e164 = row.original.primaryPhoneE164;
if (!value) return <span className="text-muted-foreground">-</span>;
// 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 (
<span className="inline-flex items-center gap-1.5 text-sm">
<a
href={e164 ? `tel:${e164}` : `tel:${value}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline"
title={`Call ${value}`}
>
<Phone className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
<span>{value}</span>
</a>
{waDigits && (
<a
href={`https://wa.me/${waDigits}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-emerald-600 hover:text-emerald-700"
title={`WhatsApp ${value}`}
aria-label={`WhatsApp ${value}`}
>
<WhatsAppIcon className="h-3.5 w-3.5" />
</a>
)}
</span>
);
},
},
{
id: 'country',
accessorKey: 'nationalityIso',
header: 'Country',
cell: ({ getValue }) => {
const iso = getValue() as string | null;
return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
);
},
},
{
id: 'source',
accessorKey: 'source',
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
const label = formatSource(source);
if (!label) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{label}
</Badge>
);
},
},
{
id: 'berths',
header: 'Berths',
enableSorting: false,
cell: ({ row }) => {
const list = row.original.linkedBerths ?? [];
if (list.length === 0) return <span className="text-muted-foreground">-</span>;
// 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 (
<div className="flex flex-wrap items-center gap-1">
{head.map((b) => (
<BerthInterestChip key={b.id} berth={b} portSlug={portSlug} />
))}
{overflow.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
+{overflow.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-64 p-1"
onClick={(e) => e.stopPropagation()}
>
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
All linked berths
</div>
<div className="space-y-0.5">
{list.map((b) => (
<Link
key={b.id}
href={`/${portSlug}/interests/${b.interestId}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent"
>
<span
aria-hidden
className={cn(
'h-2 w-2 shrink-0 rounded-full',
b.outcome ? 'bg-muted-foreground/40' : stageDotClass(b.stage),
)}
/>
<span className="font-medium text-foreground">{b.mooringNumber}</span>
<span className="text-xs text-muted-foreground">
{b.outcome
? `${stageLabel(b.stage)} · ${formatOutcome(b.outcome) ?? b.outcome}`
: stageLabel(b.stage)}
</span>
</Link>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
},
},
{
// 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 <span className="text-muted-foreground">-</span>;
return (
<Badge variant="secondary" className="text-xs">
{stageLabel(latest.stage)}
</Badge>
);
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: 'Created',
cell: ({ getValue }) => (
<span className="text-muted-foreground text-sm">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" aria-hidden />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" aria-hidden />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}
/**
* 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<ClientRow['linkedBerths']>[number];
portSlug: string;
}) {
const isClosed = berth.outcome !== null;
const label = isClosed
? `${stageLabel(berth.stage)} · ${formatOutcome(berth.outcome) ?? berth.outcome}`
: stageLabel(berth.stage);
return (
<Link
href={`/${portSlug}/interests/${berth.interestId}`}
onClick={(e) => 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',
)}
>
<span
aria-hidden
className={cn(
'h-2 w-2 shrink-0 rounded-full',
isClosed ? 'bg-muted-foreground/40' : stageDotClass(berth.stage),
)}
/>
<span className="font-medium text-foreground">{berth.mooringNumber}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{label}</span>
</Link>
);
}