'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, 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 { 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; } 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): string { const parts: string[] = []; if (length !== null) parts.push(`${length}ft L`); if (width !== null) parts.push(`${width}ft W`); if (draft !== null) parts.push(`${draft}ft 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 (
{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) } />
); } export function BerthRecommenderPanel({ interestId, desiredLengthFt, desiredWidthFt, desiredDraftFt, }: 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); const hasDimensions = desiredLengthFt !== null; const queryKey = useMemo( () => ['berth-recommendations', interestId, amenityFilters, showAll] as const, [interestId, amenityFilters, showAll], ); const { data, isFetching, refetch } = useQuery({ queryKey, enabled: hasDimensions, queryFn: () => apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, { method: 'POST', body: { ...(showAll ? { topN: 999 } : {}), ...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}), }, }).then((r) => r.data), staleTime: 60_000, }); const recommendations = data ?? []; return (
Recommendations for {formatDesired(desiredLengthFt, desiredWidthFt, desiredDraftFt)} {!hasDimensions ? (

Set desired dimensions to see recommendations.

) : null}
{filtersOpen && hasDimensions ? ( ) : 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 ? (

No berths match the current dimensions and filters.

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