From 15d4849030bcadeef0b17e621d209129e7d1984e Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 5 May 2026 03:05:22 +0200 Subject: [PATCH] feat(recommender): API endpoint + interest-detail panel + add-to-interest dialog --- src/app/api/v1/interests/[id]/berths/route.ts | 72 +++ .../interests/[id]/recommend-berths/route.ts | 44 ++ .../add-berth-to-interest-dialog.tsx | 150 ++++++ .../interests/berth-recommender-panel.tsx | 470 ++++++++++++++++++ src/components/interests/interest-detail.tsx | 5 + src/components/interests/interest-tabs.tsx | 21 + 6 files changed, 762 insertions(+) create mode 100644 src/app/api/v1/interests/[id]/berths/route.ts create mode 100644 src/app/api/v1/interests/[id]/recommend-berths/route.ts create mode 100644 src/components/interests/add-berth-to-interest-dialog.tsx create mode 100644 src/components/interests/berth-recommender-panel.tsx diff --git a/src/app/api/v1/interests/[id]/berths/route.ts b/src/app/api/v1/interests/[id]/berths/route.ts new file mode 100644 index 0000000..3357a0f --- /dev/null +++ b/src/app/api/v1/interests/[id]/berths/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { db } from '@/lib/db'; +import { interests } from '@/lib/db/schema/interests'; +import { berths } from '@/lib/db/schema/berths'; +import { upsertInterestBerth } from '@/lib/services/interest-berths.service'; +import { createAuditLog } from '@/lib/audit'; +import { emitToRoom } from '@/lib/socket/server'; + +const addBerthSchema = z.object({ + berthId: z.string().min(1), + /** Drives the public-map "Under Offer" sub-status. See plan §5.4. */ + isSpecificInterest: z.boolean(), +}); + +// POST /api/v1/interests/[id]/berths — link a berth (non-primary) to an interest. +export const POST = withAuth( + withPermission('interests', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, addBerthSchema); + const interestId = params.id!; + + // Tenant scope: interest must belong to this port. + const interest = await db.query.interests.findFirst({ + where: eq(interests.id, interestId), + }); + if (!interest || interest.portId !== ctx.portId) { + throw new NotFoundError('Interest'); + } + + // Tenant scope: berth must belong to this port (never trust a client- + // supplied id to cross port boundaries — plan §14.10). + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)), + }); + if (!berth) { + throw new ValidationError('berthId not found in this port'); + } + + const link = await upsertInterestBerth(interestId, body.berthId, { + isSpecificInterest: body.isSpecificInterest, + addedBy: ctx.userId, + }); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'interest', + entityId: interestId, + newValue: { berthId: body.berthId, isSpecificInterest: body.isSpecificInterest }, + metadata: { type: 'berth_added_to_interest' }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + emitToRoom(`port:${ctx.portId}`, 'interest:berthLinked', { + interestId, + berthId: body.berthId, + }); + + return NextResponse.json({ data: link }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/[id]/recommend-berths/route.ts b/src/app/api/v1/interests/[id]/recommend-berths/route.ts new file mode 100644 index 0000000..71d2397 --- /dev/null +++ b/src/app/api/v1/interests/[id]/recommend-berths/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { recommendBerths } from '@/lib/services/berth-recommender.service'; + +/** + * POST body — mirrors `RecommendBerthsArgs` minus the `interestId` (route + * param) and `portId` (resolved from the auth context — never trust a + * client-supplied port, plan §14.10). + */ +const recommendBerthsSchema = z.object({ + topN: z.number().int().min(1).max(999).optional(), + maxOversizePct: z.number().min(0).max(1000).optional(), + showLateStage: z.boolean().optional(), + amenityFilters: z + .object({ + minPowerCapacityKw: z.number().min(0).optional(), + requiredVoltage: z.number().int().min(0).optional(), + requiredAccess: z.string().min(1).optional(), + requiredMooringType: z.string().min(1).optional(), + requiredCleatCapacity: z.string().min(1).optional(), + }) + .optional(), +}); + +// POST /api/v1/interests/[id]/recommend-berths +export const POST = withAuth( + withPermission('interests', 'view', async (req, ctx, params) => { + try { + const body = await parseBody(req, recommendBerthsSchema); + const data = await recommendBerths({ + interestId: params.id!, + portId: ctx.portId, + ...body, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/interests/add-berth-to-interest-dialog.tsx b/src/components/interests/add-berth-to-interest-dialog.tsx new file mode 100644 index 0000000..bdc690c --- /dev/null +++ b/src/components/interests/add-berth-to-interest-dialog.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Eye, EyeOff, Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Label } from '@/components/ui/label'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +interface AddBerthToInterestDialogProps { + interestId: string; + berth: { berthId: string; mooringNumber: string }; + open: boolean; + onOpenChange: (open: boolean) => void; + onAdded?: () => void; +} + +type RoleChoice = 'specific' | 'exploring'; + +export function AddBerthToInterestDialog({ + interestId, + berth, + open, + onOpenChange, + onAdded, +}: AddBerthToInterestDialogProps) { + const queryClient = useQueryClient(); + const [choice, setChoice] = useState('specific'); + + const mutation = useMutation({ + mutationFn: async (isSpecificInterest: boolean) => + apiFetch(`/api/v1/interests/${interestId}/berths`, { + method: 'POST', + body: { berthId: berth.berthId, isSpecificInterest }, + }), + onSuccess: () => { + // Invalidate the recommender cache + linked-berths cache so both + // surfaces re-fetch immediately. (See plan §5.3 / §5.5.) + queryClient.invalidateQueries({ queryKey: ['berth-recommendations', interestId] }); + queryClient.invalidateQueries({ queryKey: ['interest-berths', interestId] }); + queryClient.invalidateQueries({ queryKey: ['interests', interestId] }); + onAdded?.(); + onOpenChange(false); + }, + }); + + const handleSubmit = () => { + mutation.mutate(choice === 'specific'); + }; + + return ( + + + + Add berth {berth.mooringNumber} to interest + + Choose how this berth relates to the deal. This drives whether it shows as “Under + Offer” on the public map. + + + + setChoice(v as RoleChoice)} + className="gap-3" + > + } + /> + } + /> + + + {mutation.isError ? ( +

+ {(mutation.error as Error)?.message ?? 'Failed to add berth.'} +

+ ) : null} + + + + + +
+
+ ); +} + +interface RoleCardProps { + value: RoleChoice; + checked: boolean; + title: string; + description: string; + consequence: string; + icon: React.ReactNode; +} + +function RoleCard({ value, checked, title, description, consequence, icon }: RoleCardProps) { + return ( + + ); +} diff --git a/src/components/interests/berth-recommender-panel.tsx b/src/components/interests/berth-recommender-panel.tsx new file mode 100644 index 0000000..de4c590 --- /dev/null +++ b/src/components/interests/berth-recommender-panel.tsx @@ -0,0 +1,470 @@ +'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} + + ); +} diff --git a/src/components/interests/interest-detail.tsx b/src/components/interests/interest-detail.tsx index db8f4cf..5e9bcd4 100644 --- a/src/components/interests/interest-detail.tsx +++ b/src/components/interests/interest-detail.tsx @@ -41,6 +41,11 @@ interface InterestData { } | null; berthId: string | null; berthMooringNumber: string | null; + /** Yacht-fit dimensions (numeric strings from postgres). Drive the + * recommender panel guard ("Set desired dimensions to see recommendations"). */ + desiredLengthFt: string | null; + desiredWidthFt: string | null; + desiredDraftFt: string | null; pipelineStage: string; leadCategory: string | null; source: string | null; diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index e3fcb64..61b8fff 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -12,6 +12,7 @@ import { NotesList } from '@/components/shared/notes-list'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { RecommendationList } from '@/components/interests/recommendation-list'; +import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel'; import { InterestTimeline } from '@/components/interests/interest-timeline'; import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab'; import { InterestFilesTab } from '@/components/interests/interest-files-tab'; @@ -37,6 +38,10 @@ interface InterestTabsOptions { currentUserId?: string; interest: { pipelineStage: string; + /** Drives the recommender panel mounted on the Overview tab. */ + desiredLengthFt?: string | null; + desiredWidthFt?: string | null; + desiredDraftFt?: string | null; leadCategory: string | null; source: string | null; eoiStatus: string | null; @@ -306,6 +311,12 @@ function OverviewTab({ activeMilestone = 'contract'; } + const toNum = (v: string | null | undefined): number | null => { + if (v === null || v === undefined) return null; + const n = parseFloat(v); + return Number.isFinite(n) ? n : null; + }; + return (
{/* Sales-process milestones - the heart of the system. Each section is a @@ -498,6 +509,16 @@ function OverviewTab({ />
+ + {/* Berth recommender (plan §5.3) - always-mounted card driven by the + interest's desired dimensions. Renders an inline guidance message + when dimensions aren't set yet. */} + ); }