Drain the long-tail audit queue captured in alpha-uat-master.md.
- next-intl ripped out (zero useTranslations callers ever existed):
package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
border-r/rounded-l-/rounded-r-) inside JSX className literals.
Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
label enabled at warn; 4 empty <th>/<td> action placeholders gain
sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
"Payments - Not received yet" or "Payments - \$X received - N payments
- Expand"; per-interest collapse state persists in localStorage; the
RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
text-muted-foreground/{60,70,80} hits dropped to plain
text-muted-foreground for AA contrast on muted bg. Icon-only
(aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
rewritten with cumulative state through today. Items genuinely still
open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
pixel E2E verification, and website-cutover work parked here so
they don't get lost in the CRM audit doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useParams } from 'next/navigation';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
|
import { EmptyState } from '@/components/shared/empty-state';
|
|
import { Bookmark } from 'lucide-react';
|
|
import { PIPELINE_STAGES, stageLabel, formatSource } from '@/lib/constants';
|
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
|
|
|
interface BerthInterestsTabProps {
|
|
berthId: string;
|
|
}
|
|
|
|
type StageFilter = 'all' | 'active' | 'lost';
|
|
type SortMode = 'newest' | 'stage' | 'category';
|
|
|
|
function stageRank(stage: string): number {
|
|
const idx = PIPELINE_STAGES.indexOf(stage as (typeof PIPELINE_STAGES)[number]);
|
|
return idx === -1 ? 99 : idx;
|
|
}
|
|
|
|
const CATEGORY_RANK: Record<string, number> = {
|
|
hot_lead: 0,
|
|
specific_qualified: 1,
|
|
general_interest: 2,
|
|
};
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
hot_lead: 'Hot lead',
|
|
specific_qualified: 'Specific qualified',
|
|
general_interest: 'General interest',
|
|
};
|
|
|
|
interface ListResponse {
|
|
data: InterestRow[];
|
|
total: number;
|
|
}
|
|
|
|
export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|
const params = useParams<{ portSlug: string }>();
|
|
const portSlug = params?.portSlug ?? '';
|
|
const [stage, setStage] = useState<StageFilter>('all');
|
|
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
|
|
|
const { data, isLoading } = useQuery<ListResponse>({
|
|
queryKey: ['interests', 'by-berth', berthId],
|
|
queryFn: () => apiFetch<ListResponse>(`/api/v1/interests?berthId=${berthId}&limit=200`),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
useRealtimeInvalidation({
|
|
'interest:created': [['interests', 'by-berth', berthId]],
|
|
'interest:updated': [['interests', 'by-berth', berthId]],
|
|
'interest:stageChanged': [['interests', 'by-berth', berthId]],
|
|
'interest:archived': [['interests', 'by-berth', berthId]],
|
|
'interest:berthLinked': [['interests', 'by-berth', berthId]],
|
|
'interest:berthUnlinked': [['interests', 'by-berth', berthId]],
|
|
});
|
|
|
|
const rows = useMemo<InterestRow[]>(() => {
|
|
const all = data?.data ?? [];
|
|
const filtered = all.filter((i) => {
|
|
// 2026-05-14 sentinel-stage cleanup: an interest is "active" when
|
|
// it has no terminal outcome and isn't archived. The legacy
|
|
// `pipelineStage !== 'completed'` check stopped working after the
|
|
// 7-stage refactor stopped writing 'completed' for terminal rows.
|
|
if (stage === 'active') return !i.outcome && !i.archivedAt;
|
|
if (stage === 'lost') return Boolean(i.archivedAt);
|
|
return true;
|
|
});
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
if (sortMode === 'stage') {
|
|
const sa = stageRank(a.pipelineStage);
|
|
const sb = stageRank(b.pipelineStage);
|
|
if (sa !== sb) return sb - sa; // furthest along first
|
|
}
|
|
if (sortMode === 'category') {
|
|
const ca = CATEGORY_RANK[a.leadCategory ?? ''] ?? 99;
|
|
const cb = CATEGORY_RANK[b.leadCategory ?? ''] ?? 99;
|
|
if (ca !== cb) return ca - cb; // hottest first
|
|
}
|
|
// Default + tiebreaker: newest first.
|
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
});
|
|
return sorted;
|
|
}, [data?.data, stage, sortMode]);
|
|
|
|
if (isLoading) return <TableSkeleton />;
|
|
|
|
if ((data?.data ?? []).length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={Bookmark}
|
|
title="No interests linked to this berth"
|
|
description="Interests will appear here when prospects express interest in this specific berth."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{rows.length} of {data?.total ?? 0} interest{(data?.total ?? 0) === 1 ? '' : 's'}
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Select value={stage} onValueChange={(v) => setStage(v as StageFilter)}>
|
|
<SelectTrigger className="h-8 w-[140px]" data-testid="berth-interests-filter">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All stages</SelectItem>
|
|
<SelectItem value="active">Active only</SelectItem>
|
|
<SelectItem value="lost">Lost / archived</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
|
|
<SelectTrigger className="h-8 w-[160px]" data-testid="berth-interests-sort">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="newest">Newest</SelectItem>
|
|
<SelectItem value="stage">Stage progress</SelectItem>
|
|
<SelectItem value="category">Lead category</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
<table className="w-full text-sm" data-testid="berth-interests-table">
|
|
<thead className="bg-muted/40 text-left text-xs font-medium text-muted-foreground">
|
|
<tr>
|
|
<th scope="col" className="px-3 py-2">
|
|
Client
|
|
</th>
|
|
<th scope="col" className="px-3 py-2">
|
|
Stage
|
|
</th>
|
|
<th scope="col" className="px-3 py-2">
|
|
Category
|
|
</th>
|
|
<th scope="col" className="px-3 py-2">
|
|
Source
|
|
</th>
|
|
<th scope="col" className="px-3 py-2">
|
|
Last activity
|
|
</th>
|
|
<th scope="col" className="px-3 py-2 text-right">
|
|
<span className="sr-only">Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((i) => (
|
|
<tr
|
|
key={i.id}
|
|
className="border-t border-border last:border-b-0 hover:bg-gradient-brand-soft/40"
|
|
>
|
|
<td className="px-3 py-2 font-medium text-foreground">
|
|
<Link
|
|
href={`/${portSlug}/interests/${i.id}` as never}
|
|
className="hover:text-brand"
|
|
>
|
|
{i.clientName ?? '-'}
|
|
</Link>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<Badge variant="secondary" className="font-normal">
|
|
{stageLabel(i.pipelineStage)}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 text-muted-foreground">
|
|
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
|
|
</td>
|
|
<td className="px-3 py-2 text-muted-foreground">{formatSource(i.source) ?? '-'}</td>
|
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
|
{new Date(i.createdAt).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
|
|
<Link href={`/${portSlug}/interests/${i.id}` as never}>Open</Link>
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|