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:
106
src/components/berths/berth-occupancy-chip.tsx
Normal file
106
src/components/berths/berth-occupancy-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user