- Supplemental-info link TTL trimmed from 30 → 14 days (single constant in supplemental-forms.service). - LinkedBerthsList toggle renamed "Mark in EOI bundle" → "Include in EOI"; tooltip aria-label updated to match. - Icon-only row-action triggers on the interest / client / berth list tables gain aria-label (Row actions for <name>) so SR users hear the row context. - Table / Board view toggle on interest list gains aria-label + aria-pressed on each variant; wrapper gets role="group". - Upcoming-milestones disclosure on interest-tabs gains aria-expanded + aria-controls; recommender Hide/Add filters button matches. - BrandedAuthShell logo alt no longer defaults to "Sign in" — uses the configured `appName` when known, empty string otherwise so screen readers don't announce "Sign in" on password-reset / set-password pages. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
13 KiB
TypeScript
373 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"
|
|
aria-label={`Row actions for ${row.original.fullName ?? 'client'}`}
|
|
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>
|
|
);
|
|
}
|