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

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