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

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