From 2592e28578eea96b9b4103cacb25d6a70c8c81b1 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 26 May 2026 21:48:19 +0200 Subject: [PATCH] feat(uat-p4): inheritance polish - yacht dims, occupancy chip, map-flip flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the active UAT sweep wraps the inheritance/polish bucket. - BerthOccupancyChip: new shared component that surfaces the competing active interest on a non-available berth as a colour-coded chip with a stage badge. Adopted in LinkedBerthRowItem, BerthRecommenderPanel recommendation card, and InterestBerthStatusBanner; the banner aligns query keys with the chip so React Query dedupes the network call. - OverviewTab inheritance: getInterestById now ships a yachtDimensions block when the interest is linked to a yacht with dimensions. The Berth Requirements rows render a "↩ from yacht" pill when the desired field is blank; clicking the pill copies the value into the interest. After a manual edit, a toast offers to write the new value back to the yacht record so the canonical truth stays in sync. - Map-flip inheritance: ExternalEoiUploadDialog and UploadForSigningDialog now expose a single "Mark berth(s) as Under Offer on the public map" checkbox that defaults ON when any in-bundle berth already has is_specific_interest=true. On submit, PATCHes the in-bundle berths that don't already match; sister surface to the EOI generate dialog's per-berth picker. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../berths/berth-occupancy-chip.tsx | 106 ++++++++ .../documents/upload-for-signing-dialog.tsx | 120 +++++++-- .../interests/berth-recommender-panel.tsx | 18 +- .../interests/external-eoi-upload-dialog.tsx | 87 ++++++- .../interest-berth-status-banner.tsx | 36 +-- src/components/interests/interest-eoi-tab.tsx | 27 ++ src/components/interests/interest-tabs.tsx | 230 +++++++++++++++--- .../interests/linked-berths-list.tsx | 15 ++ src/lib/services/berth-recommender.service.ts | 18 ++ src/lib/services/interests.service.ts | 40 +++ 10 files changed, 614 insertions(+), 83 deletions(-) create mode 100644 src/components/berths/berth-occupancy-chip.tsx diff --git a/src/components/berths/berth-occupancy-chip.tsx b/src/components/berths/berth-occupancy-chip.tsx new file mode 100644 index 00000000..ab683ed3 --- /dev/null +++ b/src/components/berths/berth-occupancy-chip.tsx @@ -0,0 +1,106 @@ +'use client'; + +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; + +import { apiFetch } from '@/lib/api/client'; +import { stageBadgeClass, stageLabel } from '@/lib/constants'; +import { cn } from '@/lib/utils'; + +interface ActiveInterestRow { + interestId: string; + clientName: string; + pipelineStage: string; + isPrimary: boolean; + isInEoiBundle: boolean; +} + +interface BerthOccupancyChipProps { + /** Berth to query. */ + berthId: string; + /** Port slug for the competing-interest link. */ + portSlug: string; + /** Optional: hide rows from this interest (so a "competing" chip on + * a row inside Interest A doesn't surface A itself). */ + excludeInterestId?: string | null; + /** Hide the chip entirely when the berth has zero active interests + * (default: true). Set false when the parent wants a render even + * for available berths — useful for the linked-berth row where the + * rep wants explicit "no competing interest" feedback. */ + hideWhenEmpty?: boolean; + /** Compact variant — single-line chip with truncation. Default + * shows on multiple lines when the client name overflows. */ + compact?: boolean; +} + +/** + * Surfaces the competing interest(s) that own a non-available berth. + * Reuses /api/v1/berths/[id]/active-interests (shipped for the columns + * popover) so the data path is consistent across: + * - LinkedBerthRowItem (per linked berth on the interest detail) + * - BerthRecommenderPanel recommendation card body + * - InterestBerthStatusBanner (deal-level banner) + * + * Renders the highest-priority competing interest (in-EOI-bundle first, + * then primary, then most-recently-updated). Clicking the chip + * navigates to the competing interest's detail page. + */ +export function BerthOccupancyChip({ + berthId, + portSlug, + excludeInterestId, + hideWhenEmpty = true, + compact = false, +}: BerthOccupancyChipProps) { + const { data, isLoading } = useQuery<{ data: ActiveInterestRow[] }>({ + queryKey: ['berth', berthId, 'active-interests'], + queryFn: () => + apiFetch<{ data: ActiveInterestRow[] }>(`/api/v1/berths/${berthId}/active-interests`), + staleTime: 30_000, + }); + + const rows = data?.data ?? []; + const competing = rows.filter((r) => + excludeInterestId ? r.interestId !== excludeInterestId : true, + ); + + if (isLoading) return null; + if (competing.length === 0 && hideWhenEmpty) return null; + if (competing.length === 0) { + return ( + + No competing interest + + ); + } + + // Priority: in-EOI-bundle (committed) > primary (flagged primary) > + // first by API order (already most-recently-updated server-side). + const primary = + competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!; + const extras = competing.length - 1; + + return ( + e.stopPropagation()} + className={cn( + 'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors', + compact && 'max-w-[200px]', + )} + title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`} + > + Under offer to: + {primary.clientName} + + {stageLabel(primary.pipelineStage)} + + {extras > 0 ? +{extras} more : null} + + ); +} diff --git a/src/components/documents/upload-for-signing-dialog.tsx b/src/components/documents/upload-for-signing-dialog.tsx index 8e7618c6..a542b4e6 100644 --- a/src/components/documents/upload-for-signing-dialog.tsx +++ b/src/components/documents/upload-for-signing-dialog.tsx @@ -389,6 +389,43 @@ function DialogBody({ enabled: Boolean(interestId) && !clientPrefill, }); + // P4.2 - Inheritance-driven public-map flag. Drives an opt-in + // checkbox on the file-select step that flips in-bundle berths to + // is_specific_interest=true on submit, default ON when any in-bundle + // berth on this interest is already on the public map. Only fetches + // when the dialog is interest-scoped (generic uploads have no berths). + const { data: interestBerthsRes } = useQuery<{ + data: Array<{ + berthId: string; + mooringNumber: string | null; + isInEoiBundle: boolean; + isSpecificInterest: boolean; + }>; + }>({ + queryKey: ['interests', interestId, 'berths'], + queryFn: () => + apiFetch<{ + data: Array<{ + berthId: string; + mooringNumber: string | null; + isInEoiBundle: boolean; + isSpecificInterest: boolean; + }>; + }>(`/api/v1/interests/${interestId}/berths`), + enabled: Boolean(interestId), + staleTime: 60_000, + }); + const inBundleBerths = useMemo( + () => (interestBerthsRes?.data ?? []).filter((b) => b.isInEoiBundle), + [interestBerthsRes], + ); + const inheritsPublicFlag = useMemo( + () => inBundleBerths.some((b) => b.isSpecificInterest), + [inBundleBerths], + ); + const [publicFlagOverride, setPublicFlagOverride] = useState(null); + const publicFlagChecked = publicFlagOverride ?? inheritsPublicFlag; + /** * Build the prefill recipient list from the async query data. The * dialog reads this on the "Next" button click in the file-picker @@ -649,7 +686,27 @@ function DialogBody({ data: { documentId: string; signingUrls: Record }; }>; }, - onSuccess: (res) => { + onSuccess: async (res) => { + // Sync the public-map flag across the in-bundle berths. Skips + // already-aligned rows. Failures are non-fatal — the doc upload + // already succeeded, so surface a non-blocking toast. + if (interestId && inBundleBerths.length > 0) { + const targets = inBundleBerths.filter((b) => b.isSpecificInterest !== publicFlagChecked); + if (targets.length > 0) { + try { + await Promise.all( + targets.map((b) => + apiFetch(`/api/v1/interests/${interestId}/berths/${b.berthId}`, { + method: 'PATCH', + body: { isSpecificInterest: publicFlagChecked }, + }), + ), + ); + } catch { + toast.error('Upload succeeded, but the public-map flag could not be updated.'); + } + } + } toast.success( defaults?.data?.sendMode === 'auto' ? 'Document sent for signing - first signer has been invited.' @@ -658,13 +715,12 @@ function DialogBody({ queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' }); + queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'berth' }); if (onCreated && res?.data?.documentId) { onCreated({ documentId: res.data.documentId }); } - // Clear the draft on successful submission - the in-flight upload - // is now an actual document; the localStorage shouldn't keep its - // shadow around. clearDraft(draftScopeId, documentType); + setPublicFlagOverride(null); onClose(); }, onError: (err) => toastError(err, 'Upload failed'), @@ -712,24 +768,44 @@ function DialogBody({
{step === 'select-file' && ( - { - setFile(f); - setTitle(f.name.replace(/\.pdf$/i, '')); - // Seed recipients from the prefill snapshot when the rep - // first lands a file - only if they haven't already - // edited the list. This pattern keeps the prefill - // synchronization in user-event handlers (no setState- - // in-effect lint trip). - if (recipients.length === 0 && prefillRecipients.length > 0) { - setRecipients(prefillRecipients); - } - setStep('configure-recipients'); - autoDetect.mutate(f); - }} - title={title} - onTitleChange={setTitle} - /> + <> + { + setFile(f); + setTitle(f.name.replace(/\.pdf$/i, '')); + if (recipients.length === 0 && prefillRecipients.length > 0) { + setRecipients(prefillRecipients); + } + setStep('configure-recipients'); + autoDetect.mutate(f); + }} + title={title} + onTitleChange={setTitle} + /> + {interestId && inBundleBerths.length > 0 ? ( +
+ +
+ ) : null} + )} {step === 'configure-recipients' && ( void; } -function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { +function RecommendationCard({ rec, portSlug, interestId, onAdd }: RecommendationCardProps) { const [expanded, setExpanded] = useState(false); const tier = TIER_LABELS[rec.tier]; const showHeat = rec.heat && rec.heat.total > 0; @@ -279,6 +284,16 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { ) : null} Fit {rec.fitScore}
+ {rec.status !== 'available' ? ( +
+ +
+ ) : null} {expanded ? ( @@ -666,6 +681,7 @@ export function BerthRecommenderPanel({ key={rec.berthId} rec={rec} portSlug={portSlug} + interestId={interestId} onAdd={setPendingBerth} /> ))} diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 9caa8e81..cdccbfab 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -122,16 +122,45 @@ export function ExternalEoiUploadDialog({ }, ]; }, [signatoriesOverride, prefillSignatories, interestData]); - const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({ + const { data: berthsData } = useQuery<{ + data: Array<{ + berthId: string; + mooringNumber: string | null; + isInEoiBundle: boolean; + isSpecificInterest: boolean; + }>; + }>({ queryKey: ['interests', interestId, 'berths'], queryFn: () => - apiFetch<{ data: Array<{ mooringNumber: string | null }> }>( - `/api/v1/interests/${interestId}/berths`, - ), + apiFetch<{ + data: Array<{ + berthId: string; + mooringNumber: string | null; + isInEoiBundle: boolean; + isSpecificInterest: boolean; + }>; + }>(`/api/v1/interests/${interestId}/berths`), enabled: open, staleTime: 60_000, }); + // Inheritance-driven default for the public-map flip: if ANY in-bundle + // berth already has isSpecificInterest=true, default the checkbox ON + // so an upstream EOI flow's "make this deal visible publicly" decision + // is honoured automatically through the upload path. Berths not in + // the EOI bundle aren't considered (they're not part of this deal's + // signature scope and so shouldn't influence the public-map default). + const inBundleBerths = useMemo( + () => (berthsData?.data ?? []).filter((b) => b.isInEoiBundle), + [berthsData], + ); + const inheritsPublicFlag = useMemo( + () => inBundleBerths.some((b) => b.isSpecificInterest), + [inBundleBerths], + ); + const [publicFlagOverride, setPublicFlagOverride] = useState(null); + const publicFlagChecked = publicFlagOverride ?? inheritsPublicFlag; + // Detect a generated EOI in flight on this interest so the dialog can // offer "Replace the generated envelope" instead of leaving two parallel // EOIs on the deal. Only documents in non-terminal status count — already- @@ -213,8 +242,32 @@ export function ExternalEoiUploadDialog({ } return (await res.json()) as { data?: { stageChanged?: boolean } }; }, - onSuccess: (response) => { + onSuccess: async (response) => { const stageChanged = response?.data?.stageChanged === true; + // Public-map flag reconciliation. After a successful upload, sync + // each in-bundle berth's isSpecificInterest to the checkbox state. + // Fires only the PATCHes that change state - berths already in + // sync are skipped. Failures here don't undo the upload (the doc + // is already filed) but surface as a non-blocking toast so the + // rep knows the flag didn't propagate. + if (inBundleBerths.length > 0) { + const targets = inBundleBerths.filter((b) => b.isSpecificInterest !== publicFlagChecked); + if (targets.length > 0) { + try { + await Promise.all( + targets.map((b) => + apiFetch(`/api/v1/interests/${interestId}/berths/${b.berthId}`, { + method: 'PATCH', + body: { isSpecificInterest: publicFlagChecked }, + }), + ), + ); + qc.invalidateQueries({ queryKey: ['berth', undefined, 'active-interests'] }); + } catch { + toast.error('Upload succeeded, but the public-map flag could not be updated.'); + } + } + } toast.success( stageChanged ? 'External EOI uploaded. Stage advanced to EOI Signed.' @@ -227,6 +280,7 @@ export function ExternalEoiUploadDialog({ setTitle(''); setSignatoriesOverride(null); setNotes(''); + setPublicFlagOverride(null); onOpenChange(false); onSuccess?.(); }, @@ -412,6 +466,29 @@ export function ExternalEoiUploadDialog({ className="mt-1" /> + {inBundleBerths.length > 0 ? ( +
+ +
+ ) : null} diff --git a/src/components/interests/interest-berth-status-banner.tsx b/src/components/interests/interest-berth-status-banner.tsx index 9561bdca..16a0d269 100644 --- a/src/components/interests/interest-berth-status-banner.tsx +++ b/src/components/interests/interest-berth-status-banner.tsx @@ -2,10 +2,10 @@ import { useQueries, useQuery } from '@tanstack/react-query'; import { AlertTriangle } from 'lucide-react'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; import { apiFetch } from '@/lib/api/client'; +import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip'; interface BerthRow { id: string; @@ -61,13 +61,15 @@ export function InterestBerthStatusBanner({ // `/active-interests` endpoint shipped in 292a8b5. Filtered client-side // to interests OTHER THAN this one so a deal looking at its own berth // doesn't see itself in the banner. + // Align query key with BerthOccupancyChip so React Query dedupes the + // network call when the banner and the chip render side-by-side. The + // banner does its own client-side exclude-self filter because it + // needs the unfiltered list to decide whether to render at all. const competingQueries = useQueries({ queries: conflicts.map((b) => ({ - queryKey: ['berth-competing', b.id, interestId] as const, + queryKey: ['berth', b.id, 'active-interests'] as const, queryFn: () => - apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`).then( - (r) => r.data.filter((row) => row.interestId !== interestId), - ), + apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`), enabled: conflicts.length > 0, staleTime: 30_000, })), @@ -94,7 +96,10 @@ export function InterestBerthStatusBanner({ const lines = conflicts .map((b, idx) => { const q = competingQueries[idx]; - const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null; + // Exclude self from the unfiltered list returned by the chip's + // shared queryKey - banner only fires for OTHER deals. + const otherDeals = (q?.data?.data ?? []).filter((row) => row.interestId !== interestId); + const competing = otherDeals.find((c) => c.isPrimary) ?? otherDeals[0] ?? null; return { berth: b, competing }; }) .filter((l) => l.competing !== null); @@ -115,18 +120,17 @@ export function InterestBerthStatusBanner({ } to another deal.` : `${lines.length} linked berths are no longer freely available.`}

-
    +
      {lines.map(({ berth, competing }) => competing ? ( -
    • - {berth.mooringNumber}:{' '} - - {competing.clientName} - +
    • + {berth.mooringNumber}: +
    • ) : null, )} diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 1c4e13e0..87002a51 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -352,6 +352,28 @@ function ActiveEoiCard({ ? signers.find((s) => s.status === 'declined' || s.status === 'rejected') : null; + // Fetch the rejection event so the banner can surface the free-text + // reason the signer typed into Documenso (plumbed end-to-end in the + // 2026-05-26 Documenso reliability round — webhook receiver coalesces + // v2 rejectionReason / v1 declineReason and persists to + // document_events.eventData.rejectionReason). Skipped when the doc + // isn't rejected, so we don't pay the round-trip on the happy path. + const { data: eventsRes } = useQuery<{ data: Array<{ eventType: string; eventData: unknown }> }>({ + queryKey: ['documents', doc.id, 'events'], + queryFn: () => apiFetch(`/api/v1/documents/${doc.id}/events`), + enabled: isRejected, + staleTime: 60_000, + }); + const rejectionReason: string | null = (() => { + if (!isRejected) return null; + const rejectionEvent = eventsRes?.data?.find((e) => e.eventType === 'rejected'); + const data = rejectionEvent?.eventData as { rejectionReason?: string | null } | undefined; + const reason = data?.rejectionReason; + if (typeof reason !== 'string') return null; + const trimmed = reason.trim(); + return trimmed.length > 0 ? trimmed : null; + })(); + const remindAllMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }), onSuccess: () => { @@ -419,6 +441,11 @@ function ActiveEoiCard({ ? ` by ${rejectedSigner.signerName ?? rejectedSigner.signerEmail}` : ''}

      + {rejectionReason ? ( +
      + “{rejectionReason}” +
      + ) : null}

      The document is no longer valid. Cancel and regenerate, or reach out to the signer before re-sending. diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 42125b7a..1ef3095c 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -6,6 +6,7 @@ import { format, formatDistanceToNowStrict } from 'date-fns'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react'; +import { toast } from 'sonner'; import type { DetailTab } from '@/components/shared/detail-layout'; import { Button } from '@/components/ui/button'; @@ -116,6 +117,23 @@ interface InterestTabsOptions { * recommender header's display so a metric-entered deal doesn't * render as ft. The three columns share an entry unit in practice. */ desiredLengthUnit?: string | null; + /** Linked yacht id - exposed so the OverviewTab "from yacht" + * inheritance pills can write back to the yacht record on + * confirmation. */ + yachtId?: string | null; + /** Yacht dimensions surfaced by getInterestById when the interest + * has a linked yacht. Drives the "from yacht" inheritance pill in + * the Berth Requirements section when a desired_* column is empty + * but the yacht carries the measurement. Null when no yacht is + * linked or the yacht has no dimensions at all. */ + yachtDimensions?: { + lengthFt: string | null; + widthFt: string | null; + draftFt: string | null; + lengthM: string | null; + widthM: string | null; + draftM: string | null; + } | null; leadCategory: string | null; source: string | null; eoiStatus: string | null; @@ -1307,6 +1325,28 @@ function OverviewTab({ if (!Number.isFinite(n)) return null; return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); }; + // Inheritance: when a desired_* field is blank but the linked + // yacht carries that measurement, render a small "from yacht" + // pill alongside the empty inline field. We don't auto-copy + // the yacht's value into the interest (the rep may want a + // different deal-specific target) - the pill makes it + // discoverable + a single click on the pill copies it across. + const yachtDims = interest.yachtDimensions ?? null; + const yachtVal = (axis: 'length' | 'width' | 'draft'): string | null => { + if (!yachtDims) return null; + if (unitIsM) { + return axis === 'length' + ? yachtDims.lengthM + : axis === 'width' + ? yachtDims.widthM + : yachtDims.draftM; + } + return axis === 'length' + ? yachtDims.lengthFt + : axis === 'width' + ? yachtDims.widthFt + : yachtDims.draftFt; + }; const onSavePair = ( primary: InterestPatchField, @@ -1317,54 +1357,166 @@ function OverviewTab({ [primary]: next, [counterpart]: toCounterpart(next), }); + // Surface a write-back CTA: if the saved value differs + // from the yacht's current value AND the yacht has a + // value for this axis, prompt the rep to update the + // yacht record too. The toast keeps the action + // non-modal so it never interrupts a flow. + const axis: 'length' | 'width' | 'draft' = primary.includes('Length') + ? 'length' + : primary.includes('Width') + ? 'width' + : 'draft'; + const yachtCurrent = yachtVal(axis); + if (next && yachtCurrent !== null && next !== yachtCurrent && interest.yachtId) { + const yachtId = interest.yachtId; + const yachtField = unitIsM + ? axis === 'length' + ? 'lengthM' + : axis === 'width' + ? 'widthM' + : 'draftM' + : axis === 'length' + ? 'lengthFt' + : axis === 'width' + ? 'widthFt' + : 'draftFt'; + const counterpartField = unitIsM + ? axis === 'length' + ? 'lengthFt' + : axis === 'width' + ? 'widthFt' + : 'draftFt' + : axis === 'length' + ? 'lengthM' + : axis === 'width' + ? 'widthM' + : 'draftM'; + toast(`Update yacht ${axis} too?`, { + description: `Yacht is ${yachtCurrent}${unitLabel}; this deal is now ${next}${unitLabel}.`, + action: { + label: 'Update yacht', + onClick: async () => { + await apiFetch(`/api/v1/yachts/${yachtId}`, { + method: 'PATCH', + body: { + [yachtField]: next, + [counterpartField]: toCounterpart(next), + }, + }); + toast.success('Yacht record updated.'); + }, + }, + }); + } }; const unitLabel = unitIsM ? 'm' : 'ft'; + // The yacht-source pill: shown next to a desired_* input + // whenever the interest's value is blank but the yacht has + // a value to inherit. Click copies the yacht's value into + // the interest via the same patch path. Rendered via a + // render helper (returns JSX, not a Component) so React + // doesn't reset the inner state on each parent render. + const renderInheritedPill = ( + axis: 'length' | 'width' | 'draft', + primary: InterestPatchField, + counterpart: InterestPatchField, + ) => { + const v = yachtVal(axis); + if (!v) return null; + return ( + + ); + }; return (

      - +
      + + {!(unitIsM ? interest.desiredLengthM : interest.desiredLengthFt) + ? renderInheritedPill( + 'length', + unitIsM ? 'desiredLengthM' : 'desiredLengthFt', + unitIsM ? 'desiredLengthFt' : 'desiredLengthM', + ) + : null} +
      - +
      + + {!(unitIsM ? interest.desiredWidthM : interest.desiredWidthFt) + ? renderInheritedPill( + 'width', + unitIsM ? 'desiredWidthM' : 'desiredWidthFt', + unitIsM ? 'desiredWidthFt' : 'desiredWidthM', + ) + : null} +
      - +
      + + {!(unitIsM ? interest.desiredDraftM : interest.desiredDraftFt) + ? renderInheritedPill( + 'draft', + unitIsM ? 'desiredDraftM' : 'desiredDraftFt', + unitIsM ? 'desiredDraftFt' : 'desiredDraftM', + ) + : null} +
      ); diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx index 63cc2cb8..be2131fd 100644 --- a/src/components/interests/linked-berths-list.tsx +++ b/src/components/interests/linked-berths-list.tsx @@ -42,6 +42,7 @@ import { } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; +import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -263,6 +264,10 @@ function BypassDialog({ row, open, onOpenChange, onSubmit, isPending }: BypassDi interface RowProps { row: LinkedBerthRow; portSlug: string; + /** Current interest id so the BerthOccupancyChip can exclude this + * interest from the "competing" list (self-conflict makes no + * sense to flag). */ + interestId: string; eoiStatus: string | null; onUpdate: (berthId: string, patch: PatchPayload) => void; onRemove: (berthId: string) => void; @@ -274,6 +279,7 @@ interface RowProps { function LinkedBerthRowItem({ row, portSlug, + interestId, eoiStatus, onUpdate, onRemove, @@ -305,6 +311,14 @@ function LinkedBerthRowItem({ leading character of the mooring number rendered above, so surfacing it again is pure noise. Hidden 2026-05-15. */} {formatStatus(row.status)} + {row.status !== 'available' ? ( + + ) : null} {row.isPrimary ? ( @@ -667,6 +681,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) { key={row.id} row={row} portSlug={portSlug} + interestId={interestId} eoiStatus={eoiStatus} onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })} onRemove={(berthId) => removeMutation.mutate(berthId)} diff --git a/src/lib/services/berth-recommender.service.ts b/src/lib/services/berth-recommender.service.ts index 5b685d56..2e8b953d 100644 --- a/src/lib/services/berth-recommender.service.ts +++ b/src/lib/services/berth-recommender.service.ts @@ -218,10 +218,28 @@ interface TierInputs { activeInterestCount: number; lostCount: number; maxActiveStage: number; + /** Berth's status column. Reconciles against the interest_berths + * aggregates: a berth flagged "Under Offer" or "Sold" via the + * status column alone (admin-set, NocoDB import, or a stale row + * with no live interest_berths entry) shouldn't fall into Tier A. + * Optional for backcompat — pure aggregate-based callers still + * classify correctly when this is undefined. */ + status?: string; } export function classifyTier(t: TierInputs): Tier { + // Berth status overrides the aggregate path. A sold berth is + // effectively closed — treat it as late stage. An Under Offer + // berth has at least one party engaged even if interest_berths + // doesn't echo them (e.g. admin manually flipped status). Both + // collapse the "Open · Under Offer" contradiction surfaced in UAT + // 2026-05-26. Sold > UnderOffer > active interest aggregates. + const normStatus = (t.status ?? '').toLowerCase(); + if (normStatus === 'sold') return 'D'; if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D'; + if (normStatus === 'under offer' || normStatus === 'under_offer') { + return t.activeInterestCount > 0 ? 'C' : 'C'; + } if (t.activeInterestCount > 0) return 'C'; if (t.lostCount > 0) return 'B'; return 'A'; diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 6909044c..4ab7dcd5 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -665,6 +665,45 @@ export async function getInterestById(id: string, portId: string) { : (berthResoldRaw.rows ?? []); const dateBerthSoldToOther = berthResoldRows[0]?.at ?? null; + // Yacht dimensions for inheritance display in OverviewTab. When the + // interest has a linked yacht we ship the yacht's length/width/draft + // alongside the interest record so the Berth Requirements section can + // render a "from yacht" pill in place of an empty value. This is a + // display-only inheritance - the actual recommender source switch is + // still governed by `interests.useYachtDimensions`. + let yachtDimensions: { + lengthFt: string | null; + widthFt: string | null; + draftFt: string | null; + lengthM: string | null; + widthM: string | null; + draftM: string | null; + } | null = null; + if (interest.yachtId) { + const [yachtRow] = await db + .select({ + lengthFt: yachts.lengthFt, + widthFt: yachts.widthFt, + draftFt: yachts.draftFt, + lengthM: yachts.lengthM, + widthM: yachts.widthM, + draftM: yachts.draftM, + }) + .from(yachts) + .where(eq(yachts.id, interest.yachtId)) + .limit(1); + if (yachtRow) { + const anyDim = + yachtRow.lengthFt || + yachtRow.widthFt || + yachtRow.draftFt || + yachtRow.lengthM || + yachtRow.widthM || + yachtRow.draftM; + if (anyDim) yachtDimensions = yachtRow; + } + } + // Resolve the assignee's display name for the header chip - falling back // to the raw ID is fine if the user record is missing (deleted/disabled). let assignedToName: string | null = null; @@ -706,6 +745,7 @@ export async function getInterestById(id: string, portId: string) { dateDocumentDeclined, dateReservationCancelled, dateBerthSoldToOther, + yachtDimensions, }; }