feat(uat-p4): inheritance polish - yacht dims, occupancy chip, map-flip flag

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 "↩ <value> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:48:19 +02:00
parent fe5f98db23
commit 2592e28578
10 changed files with 614 additions and 83 deletions

View File

@@ -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 (
<span className="inline-flex items-center text-xs text-muted-foreground">
No competing interest
</span>
);
}
// 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 (
<Link
href={`/${portSlug}/interests/${primary.interestId}` as never}
onClick={(e) => 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)})`}
>
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 text-xs',
stageBadgeClass(primary.pipelineStage),
)}
>
{stageLabel(primary.pipelineStage)}
</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null}
</Link>
);
}

View File

@@ -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<boolean | null>(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<string, string> };
}>;
},
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({
<div className="flex-1 overflow-hidden flex flex-col">
{step === 'select-file' && (
<FilePickerStep
onFileSelected={(f) => {
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}
/>
<>
<FilePickerStep
onFileSelected={(f) => {
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 ? (
<div className="mx-6 my-2 rounded-md border bg-muted/40 p-3 text-sm">
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
checked={publicFlagChecked}
onChange={(e) => setPublicFlagOverride(e.target.checked)}
className="mt-1 h-4 w-4 cursor-pointer"
/>
<span>
<span className="font-medium">
Mark berth{inBundleBerths.length > 1 ? 's' : ''} as Under Offer on the public
map
</span>
<span className="block text-xs text-muted-foreground">
{inheritsPublicFlag
? 'Default ON because at least one in-bundle berth is already flagged on the public map.'
: 'Default OFF - turn on to make the berth(s) visible as Under Offer on the public site.'}
</span>
</span>
</label>
</div>
) : null}
</>
)}
{step === 'configure-recipients' && (
<RecipientsStep

View File

@@ -28,6 +28,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip';
import { AddBerthToInterestDialog } from '@/components/interests/add-berth-to-interest-dialog';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
@@ -162,10 +163,14 @@ function formatDesired(
interface RecommendationCardProps {
rec: Recommendation;
portSlug: string;
/** Current interest the recommender is running for. Threaded down so
* the competing-interest chip can hide rows that point back at this
* same interest. */
interestId: string;
onAdd: (rec: Recommendation) => 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}
<span className="ml-2 font-medium text-foreground">Fit {rec.fitScore}</span>
</div>
{rec.status !== 'available' ? (
<div className="mt-1">
<BerthOccupancyChip
berthId={rec.berthId}
portSlug={portSlug}
excludeInterestId={interestId}
compact
/>
</div>
) : null}
</div>
{expanded ? (
<ChevronUp className="size-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -666,6 +681,7 @@ export function BerthRecommenderPanel({
key={rec.berthId}
rec={rec}
portSlug={portSlug}
interestId={interestId}
onAdd={setPendingBerth}
/>
))}

View File

@@ -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<boolean | null>(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"
/>
</div>
{inBundleBerths.length > 0 ? (
<div className="rounded-md border bg-muted/40 p-3 text-sm">
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
checked={publicFlagChecked}
onChange={(e) => setPublicFlagOverride(e.target.checked)}
className="mt-1 h-4 w-4 cursor-pointer"
/>
<span>
<span className="font-medium">
Mark berth{inBundleBerths.length > 1 ? 's' : ''} as Under Offer on the public
map
</span>
<span className="block text-xs text-muted-foreground">
{inheritsPublicFlag
? 'Default ON because at least one in-bundle berth is already flagged on the public map.'
: 'Default OFF - turn on to make the berth(s) visible as Under Offer on the public site.'}
</span>
</span>
</label>
</div>
) : null}
</div>
<DialogFooter>

View File

@@ -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.`}
</p>
<ul className="mt-1 space-y-0.5">
<ul className="mt-1 space-y-1">
{lines.map(({ berth, competing }) =>
competing ? (
<li key={berth.id} className="text-rose-900">
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/interests/${competing.interestId}` as any}
className="underline-offset-2 hover:underline"
>
{competing.clientName}
</Link>
<li key={berth.id} className="flex items-center gap-1.5 text-rose-900">
<span className="font-medium shrink-0">{berth.mooringNumber}:</span>
<BerthOccupancyChip
berthId={berth.id}
portSlug={portSlug}
excludeInterestId={interestId}
compact
/>
</li>
) : null,
)}

View File

@@ -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}`
: ''}
</p>
{rejectionReason ? (
<blockquote className="mt-1 rounded border-l-2 border-rose-400 bg-rose-50 px-2 py-1 text-rose-900 italic">
&ldquo;{rejectionReason}&rdquo;
</blockquote>
) : null}
<p className="mt-0.5 text-rose-800">
The document is no longer valid. Cancel and regenerate, or reach out to the signer
before re-sending.

View File

@@ -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 (
<button
type="button"
onClick={() =>
mutation.mutateAsync({
[primary]: v,
[counterpart]: toCounterpart(v),
})
}
title={`Inherit ${v}${unitLabel} from the linked yacht`}
className="ms-2 inline-flex items-center gap-1 rounded-md border border-sky-200 bg-sky-50 px-1.5 py-0.5 text-xs text-sky-800 hover:bg-sky-100"
>
<span aria-hidden></span>
<span>
{v}
{unitLabel} from yacht
</span>
</button>
);
};
return (
<dl>
<EditableRow label={`Desired length (${unitLabel})`}>
<InlineEditableField
value={
unitIsM
? (interest.desiredLengthM ?? null)
: (interest.desiredLengthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
)}
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
emptyText=" - "
/>
<div className="flex items-center">
<InlineEditableField
value={
unitIsM
? (interest.desiredLengthM ?? null)
: (interest.desiredLengthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
)}
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
emptyText=" - "
/>
{!(unitIsM ? interest.desiredLengthM : interest.desiredLengthFt)
? renderInheritedPill(
'length',
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
)
: null}
</div>
</EditableRow>
<EditableRow label={`Desired width (${unitLabel})`}>
<InlineEditableField
value={
unitIsM
? (interest.desiredWidthM ?? null)
: (interest.desiredWidthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)}
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
emptyText=" - "
/>
<div className="flex items-center">
<InlineEditableField
value={
unitIsM
? (interest.desiredWidthM ?? null)
: (interest.desiredWidthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)}
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
emptyText=" - "
/>
{!(unitIsM ? interest.desiredWidthM : interest.desiredWidthFt)
? renderInheritedPill(
'width',
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)
: null}
</div>
</EditableRow>
<EditableRow label={`Desired draft (${unitLabel})`}>
<InlineEditableField
value={
unitIsM
? (interest.desiredDraftM ?? null)
: (interest.desiredDraftFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
)}
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'}
emptyText=" - "
/>
<div className="flex items-center">
<InlineEditableField
value={
unitIsM
? (interest.desiredDraftM ?? null)
: (interest.desiredDraftFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
)}
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'}
emptyText=" - "
/>
{!(unitIsM ? interest.desiredDraftM : interest.desiredDraftFt)
? renderInheritedPill(
'draft',
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
)
: null}
</div>
</EditableRow>
</dl>
);

View File

@@ -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. */}
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
{row.status !== 'available' ? (
<BerthOccupancyChip
berthId={row.berthId}
portSlug={portSlug}
excludeInterestId={interestId}
compact
/>
) : null}
{row.isPrimary ? (
<span className="inline-flex items-center gap-1 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-800">
<Star className="size-3" aria-hidden />
@@ -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)}

View File

@@ -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';

View File

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