'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 = { 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 (

Recommender state

  • Open: never had an interest, ready for new prospects.
  • Fall-through: a prior interest didn't close; warm and worth pitching again.
  • Active interest: another deal is in play. Coordinate before pitching.
  • Late stage: another deal is near-sold; treat as backup only.
{showHeat ? ( Heat {Math.round(rec.heat!.total)} ) : null}
{formatDimensions(rec.lengthFt, rec.widthFt, rec.draftFt)} {rec.sizeBufferPct !== null ? ( {' '} · {rec.sizeBufferPct >= 0 ? '+' : ''} {rec.sizeBufferPct}% vs desired ) : null} Fit {rec.fitScore}
{expanded ? ( ) : ( )} {expanded ? (
Dimensional
{rec.reasons.dimensional}
Pipeline
{rec.reasons.pipeline}
{rec.reasons.amenities ? (
Amenities
{rec.reasons.amenities}
) : null} {rec.reasons.heat ? (
Heat
{rec.reasons.heat}
) : null}
) : null} ); } interface AmenityFilterFormProps { filters: AmenityFilters; onChange: (next: AmenityFilters) => void; } function AmenityFilterForm({ filters, onChange }: AmenityFilterFormProps) { const update = (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 (
update('minPowerCapacityKw', e.target.value ? parseFloat(e.target.value) : undefined) } />
update('requiredVoltage', e.target.value ? parseInt(e.target.value, 10) : undefined) } />
); } // 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({}); const [showAll, setShowAll] = useState(false); const [pendingBerth, setPendingBerth] = useState(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([]); // 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((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(); 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 (
Recommendations for{' '} {formatDesired( desiredLengthFt, desiredWidthFt, desiredDraftFt, desiredUnit === 'm' ? 'm' : 'ft', )} {!hasDimensions ? (

Set desired dimensions to see recommendations.

) : null}
{!collapsed ? ( <> ) : null}
{!collapsed && filtersOpen && hasDimensions ? (
) : null} {!collapsed && hasDimensions && areaChips.length > 1 ? (
Area: {areaChips.map((letter) => { const active = selectedAreas.includes(letter); return ( ); })} {selectedAreas.length > 0 ? ( ) : null}
) : null}
{collapsed ? null : ( {!hasDimensions ? (

Once length, width, and draft are set on this interest, the recommender will surface berths that fit. Edit the desired dimensions on the{' '} Overview tab .

) : isFetching && recommendations.length === 0 ? (
{[0, 1, 2].map((i) => (
))}
) : recommendations.length === 0 ? (

{showAll ? 'No berths in the port match these dimensions and filters.' : 'No berths fit inside the strict oversize tolerance.'}

{!showAll && ( )}
) : (
{recommendations.map((rec) => ( ))}
)} {hasDimensions && recommendations.length > 0 ? (
) : null} )} {pendingBerth ? ( { if (!open) setPendingBerth(null); }} /> ) : null} ); }