Files
pn-new-crm/src/components/berths/berth-interests-tab.tsx
Matt 689a114aba fix(audit-wave-9): copy/terminology sweep (copy-auditor)
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:

**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
  page entirely. Privacy + optics: clients should never see "hot lead"
  in their own portal. `eoiStatus` was already wrapped in
  `portalSigningLabel`; only the categorical chip remained.

**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
  truth for the {draft, sent, partially_signed, completed, expired,
  cancelled} lifecycle: labels (CRM + portal variants), StatusPill
  variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
  interest-reservation-tab — they previously redefined identical
  STATUS_LABELS / ACTIVE_STATUSES blocks per-file.

**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
  surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
  Matches the project's UTF-8-elsewhere convention and reads
  correctly via screen-readers.

**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
  request pending"; "Void the signing envelope" → "Cancel the signing
  request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
  request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
  "leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
  vocabulary).

**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
  sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
  lead-category maps so the CRM trend (sentence case) is consistent.

**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
  berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
  `/deal-documents` to avoid breaking deep links; rename deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:12:40 +02:00

192 lines
7.1 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) => {
if (stage === 'active') return i.pipelineStage !== 'completed' && !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 className="px-3 py-2">Client</th>
<th className="px-3 py-2">Stage</th>
<th className="px-3 py-2">Category</th>
<th className="px-3 py-2">Source</th>
<th className="px-3 py-2">Last activity</th>
<th className="px-3 py-2 text-right" />
</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>
);
}