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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user