Files
pn-new-crm/src/components/interests/berth-recommender-panel.tsx
Matt db511063df feat(uat-batch-10): copy polish, TTL trim, and a11y discrete fixes
- 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>
2026-05-21 18:01:17 +02:00

657 lines
23 KiB
TypeScript

'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import {
ChevronDown,
ChevronUp,
Filter,
Flame,
HelpCircle,
Plus,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { AddBerthToInterestDialog } from '@/components/interests/add-berth-to-interest-dialog';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
// ─── Types (mirror the recommender service Recommendation shape) ───────────
type Tier = 'A' | 'B' | 'C' | 'D';
interface HeatBreakdown {
recency: number;
furthestStage: number;
interestCount: number;
eoiCount: number;
total: number;
}
export interface Recommendation {
berthId: string;
mooringNumber: string;
area: string | null;
tier: Tier;
fitScore: number;
sizeBufferPct: number | null;
heat: HeatBreakdown | null;
reasons: {
dimensional: string;
pipeline: string;
amenities?: string;
heat?: string;
};
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
status: string;
amenities: {
powerCapacity: number | null;
voltage: number | null;
access: string | null;
mooringType: string | null;
cleatCapacity: string | null;
};
}
interface AmenityFilters {
minPowerCapacityKw?: number;
requiredVoltage?: number;
requiredAccess?: string;
requiredMooringType?: string;
requiredCleatCapacity?: string;
}
interface BerthRecommenderPanelProps {
interestId: string;
/** Display label for the dimensions in the header. */
desiredLengthFt: number | null;
desiredWidthFt: number | null;
desiredDraftFt: number | null;
/**
* Unit the rep originally entered the dimensions in. Drives header
* display so a metric-entered deal doesn't render its dims as ft.
* Falls back to 'ft' when missing.
*/
desiredUnit?: 'ft' | 'm' | null;
/**
* Number of berths already linked to the interest. When ≥ 1 the panel
* defaults to collapsed (header-only) so the LinkedBerthsList card above
* dominates the rep's attention. They can expand to browse more options
* (multi-berth deals, swap recommendations). Zero / undefined keeps the
* panel expanded so reps see options immediately.
*/
linkedBerthCount?: number;
}
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
A: { label: 'Open', tone: 'border-emerald-200 bg-emerald-50 text-emerald-800' },
B: { label: 'Fall-through', tone: 'border-amber-200 bg-amber-50 text-amber-800' },
C: { label: 'Active interest', tone: 'border-sky-200 bg-sky-50 text-sky-800' },
D: { label: 'Late stage', tone: 'border-slate-300 bg-slate-100 text-slate-700' },
};
function statusToPill(status: string): StatusPillStatus {
switch (status) {
case 'available':
return 'active';
case 'under_offer':
return 'sent';
case 'sold':
return 'completed';
case 'reserved':
return 'partial';
default:
return 'pending';
}
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
}
function formatDimensions(
length: number | null,
width: number | null,
draft: number | null,
): string {
const parts: string[] = [];
if (length !== null) parts.push(`${length.toFixed(1)}ft L`);
if (width !== null) parts.push(`${width.toFixed(1)}ft W`);
if (draft !== null) parts.push(`${draft.toFixed(1)}ft D`);
return parts.join(' · ');
}
function formatDesired(
length: number | null,
width: number | null,
draft: number | null,
unit: 'ft' | 'm' = 'ft',
): string {
// Storage is canonical-ft (the recommender's SQL ranks against
// berths.length_ft etc.). For display we convert back to whatever the rep
// entered. 0.3048 m/ft exactly.
const toDisplay = (ft: number): string => {
const v = unit === 'm' ? ft * 0.3048 : ft;
return v.toFixed(2).replace(/\.?0+$/, '');
};
const parts: string[] = [];
if (length !== null) parts.push(`${toDisplay(length)}${unit} L`);
if (width !== null) parts.push(`${toDisplay(width)}${unit} W`);
if (draft !== null) parts.push(`${toDisplay(draft)}${unit} D`);
return parts.length > 0 ? parts.join(' · ') : 'no dimensions set';
}
interface RecommendationCardProps {
rec: Recommendation;
portSlug: string;
onAdd: (rec: Recommendation) => void;
}
function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
const [expanded, setExpanded] = useState(false);
const tier = TIER_LABELS[rec.tier];
const showHeat = rec.heat && rec.heat.total > 0;
return (
<div className="rounded-lg border bg-card text-sm">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{rec.mooringNumber}</span>
{rec.area ? <span className="text-xs text-muted-foreground">{rec.area}</span> : null}
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
tier.tone,
)}
aria-label={`Recommender state: ${tier.label}`}
>
{tier.label}
<HelpCircle className="size-3 opacity-60" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
<p className="font-medium text-foreground">Recommender state</p>
<ul className="mt-2 space-y-1.5 text-muted-foreground">
<li>
<span className="font-medium text-emerald-700">Open</span>: never had an
interest, ready for new prospects.
</li>
<li>
<span className="font-medium text-amber-700">Fall-through</span>: a prior
interest didn&apos;t close; warm and worth pitching again.
</li>
<li>
<span className="font-medium text-sky-700">Active interest</span>: another deal
is in play. Coordinate before pitching.
</li>
<li>
<span className="font-medium text-slate-700">Late stage</span>: another deal is
near-sold; treat as backup only.
</li>
</ul>
</PopoverContent>
</Popover>
{showHeat ? (
<span className="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800">
<Flame className="size-3" aria-hidden />
Heat {Math.round(rec.heat!.total)}
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{formatDimensions(rec.lengthFt, rec.widthFt, rec.draftFt)}
{rec.sizeBufferPct !== null ? (
<span>
{' '}
· {rec.sizeBufferPct >= 0 ? '+' : ''}
{rec.sizeBufferPct}% vs desired
</span>
) : null}
<span className="ml-2 font-medium text-foreground">Fit {rec.fitScore}</span>
</div>
</div>
{expanded ? (
<ChevronUp className="size-4 shrink-0 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
)}
</button>
{expanded ? (
<div className="space-y-3 border-t bg-muted/20 p-3">
<dl className="space-y-1 text-xs">
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Dimensional</dt>
<dd>{rec.reasons.dimensional}</dd>
</div>
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Pipeline</dt>
<dd>{rec.reasons.pipeline}</dd>
</div>
{rec.reasons.amenities ? (
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Amenities</dt>
<dd>{rec.reasons.amenities}</dd>
</div>
) : null}
{rec.reasons.heat ? (
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Heat</dt>
<dd>{rec.reasons.heat}</dd>
</div>
) : null}
</dl>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAdd(rec);
}}
>
<Plus className="mr-1.5 size-3.5" aria-hidden />
Add to interest
</Button>
<Button asChild size="sm" variant="outline">
<Link href={`/${portSlug}/berths/${rec.berthId}`}>View berth</Link>
</Button>
</div>
</div>
) : null}
</div>
);
}
interface AmenityFilterFormProps {
filters: AmenityFilters;
onChange: (next: AmenityFilters) => void;
}
function AmenityFilterForm({ filters, onChange }: AmenityFilterFormProps) {
const update = <K extends keyof AmenityFilters>(key: K, value: AmenityFilters[K]) => {
const next = { ...filters };
if (value === undefined || value === '' || (typeof value === 'number' && Number.isNaN(value))) {
delete next[key];
} else {
next[key] = value;
}
onChange(next);
};
return (
<div className="grid grid-cols-1 gap-3 rounded-lg border bg-muted/20 p-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<Label htmlFor="filter-power" className="text-xs">
Min power (kW)
</Label>
<Input
id="filter-power"
type="number"
min={0}
step="0.1"
value={filters.minPowerCapacityKw ?? ''}
onChange={(e) =>
update('minPowerCapacityKw', e.target.value ? parseFloat(e.target.value) : undefined)
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="filter-voltage" className="text-xs">
Voltage
</Label>
<Input
id="filter-voltage"
type="number"
min={0}
step="1"
value={filters.requiredVoltage ?? ''}
onChange={(e) =>
update('requiredVoltage', e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="filter-mooring" className="text-xs">
Mooring type
</Label>
<Select
value={filters.requiredMooringType ?? ''}
onValueChange={(v) => update('requiredMooringType', v || undefined)}
>
<SelectTrigger id="filter-mooring">
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="stern_to">Stern-to</SelectItem>
<SelectItem value="alongside">Alongside</SelectItem>
<SelectItem value="bow_to">Bow-to</SelectItem>
<SelectItem value="swing">Swing mooring</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="filter-cleat" className="text-xs">
Cleat capacity
</Label>
<Select
value={filters.requiredCleatCapacity ?? ''}
onValueChange={(v) => update('requiredCleatCapacity', v || undefined)}
>
<SelectTrigger id="filter-cleat">
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="heavy">Heavy</SelectItem>
<SelectItem value="superyacht">Superyacht</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="filter-access" className="text-xs">
Access
</Label>
<Select
value={filters.requiredAccess ?? ''}
onValueChange={(v) => update('requiredAccess', v || undefined)}
>
<SelectTrigger id="filter-access">
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="land">Land access</SelectItem>
<SelectItem value="dinghy">Dinghy only</SelectItem>
<SelectItem value="walk_on">Walk-on</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// destructure includes `desiredUnit` so the header formatter pivots on the
// rep's entered unit. Falls back to 'ft' (the legacy default) when missing.
export function BerthRecommenderPanel({
interestId,
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit,
linkedBerthCount,
}: BerthRecommenderPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [filtersOpen, setFiltersOpen] = useState(false);
const [amenityFilters, setAmenityFilters] = useState<AmenityFilters>({});
const [showAll, setShowAll] = useState(false);
const [pendingBerth, setPendingBerth] = useState<Recommendation | null>(null);
// Area-letter filter — chips above the list let reps narrow to a
// single pier (e.g. "show me only A-row matches"). Client-side over
// the already-fetched result set; no service change required.
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
// Collapse state — defaults to collapsed when the deal already has at
// least one linked berth (recommender becomes a "browse more options"
// tool rather than the primary surface). Reps can manually expand any
// time. Header click toggles.
const [collapsed, setCollapsed] = useState<boolean>((linkedBerthCount ?? 0) > 0);
const hasDimensions = desiredLengthFt !== null;
const queryKey = useMemo(
() => ['berth-recommendations', interestId, amenityFilters, showAll] as const,
[interestId, amenityFilters, showAll],
);
const { data, isFetching, refetch } = useQuery({
queryKey,
// Skip the network call when collapsed — no point fetching options
// the rep won't see. Re-fires automatically on expand.
enabled: hasDimensions && !collapsed,
queryFn: () =>
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
method: 'POST',
body: {
// `showAll` opens the floodgates: bumps `topN` AND raises the
// oversize-cap so berths well beyond the strict feasibility window
// surface. Without that second bump the user could end up staring
// at "no berths match" when the test data only had oversized rows
// — exactly the case in our seeded demo port.
...(showAll ? { topN: 999, maxOversizePct: 1000 } : {}),
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
},
}).then((r) => r.data),
staleTime: 60_000,
});
const allRecommendations = data ?? [];
// Build the set of dock-letter chips from whatever came back, then
// filter the visible recommendations by the active selection. Empty
// selection = show everything (default).
const areaChips = useMemo(() => {
const set = new Set<string>();
for (const r of allRecommendations) {
const m = r.mooringNumber.match(/^([A-Z]+)/);
if (m?.[1]) set.add(m[1]);
}
return Array.from(set).sort();
}, [allRecommendations]);
const recommendations =
selectedAreas.length === 0
? allRecommendations
: allRecommendations.filter((r) => {
const m = r.mooringNumber.match(/^([A-Z]+)/);
return m?.[1] ? selectedAreas.includes(m[1]) : false;
});
return (
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="size-4 text-brand-600" aria-hidden />
Recommendations for{' '}
{formatDesired(
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit === 'm' ? 'm' : 'ft',
)}
</CardTitle>
{!hasDimensions ? (
<p className="text-xs text-muted-foreground">
Set desired dimensions to see recommendations.
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{!collapsed ? (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFiltersOpen((v) => !v)}
disabled={!hasDimensions}
aria-expanded={filtersOpen}
aria-controls="recommender-filters-body"
>
<Filter className="mr-1.5 size-3.5" aria-hidden />
{filtersOpen ? 'Hide filters' : 'Add filters'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={!hasDimensions || isFetching}
>
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
Refresh
</Button>
</>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
aria-controls={`recommender-body-${interestId}`}
>
{collapsed ? (
<>
<ChevronDown className="mr-1.5 size-3.5" aria-hidden />
Show recommendations
</>
) : (
<>
<ChevronUp className="mr-1.5 size-3.5" aria-hidden />
Hide
</>
)}
</Button>
</div>
</div>
{!collapsed && filtersOpen && hasDimensions ? (
<div id="recommender-filters-body">
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
</div>
) : null}
{!collapsed && hasDimensions && areaChips.length > 1 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-muted-foreground">Area:</span>
{areaChips.map((letter) => {
const active = selectedAreas.includes(letter);
return (
<button
key={letter}
type="button"
onClick={() =>
setSelectedAreas((prev) =>
prev.includes(letter) ? prev.filter((l) => l !== letter) : [...prev, letter],
)
}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background text-foreground hover:bg-muted',
)}
aria-pressed={active}
>
{letter}
</button>
);
})}
{selectedAreas.length > 0 ? (
<button
type="button"
onClick={() => setSelectedAreas([])}
className="text-xs text-muted-foreground underline ml-1"
>
Clear
</button>
) : null}
</div>
) : null}
</CardHeader>
{collapsed ? null : (
<CardContent className="space-y-3" id={`recommender-body-${interestId}`}>
{!hasDimensions ? (
<p className="text-sm text-muted-foreground">
Once length, width, and draft are set on this interest, the recommender will surface
berths that fit. Edit the desired dimensions on the{' '}
<Link href="?tab=overview" className="text-primary underline">
Overview tab
</Link>
.
</p>
) : isFetching && recommendations.length === 0 ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : recommendations.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
<RecommendationCard
key={rec.berthId}
rec={rec}
portSlug={portSlug}
onAdd={setPendingBerth}
/>
))}
</div>
)}
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
</div>
) : null}
</CardContent>
)}
{pendingBerth ? (
<AddBerthToInterestDialog
interestId={interestId}
berth={{
berthId: pendingBerth.berthId,
mooringNumber: pendingBerth.mooringNumber,
}}
open={pendingBerth !== null}
onOpenChange={(open) => {
if (!open) setPendingBerth(null);
}}
/>
) : null}
</Card>
);
}