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,
};
}