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, 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 * Build the prefill recipient list from the async query data. The
* dialog reads this on the "Next" button click in the file-picker * 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> }; 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( toast.success(
defaults?.data?.sendMode === 'auto' defaults?.data?.sendMode === 'auto'
? 'Document sent for signing - first signer has been invited.' ? '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] === 'documents' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'berth' });
if (onCreated && res?.data?.documentId) { if (onCreated && res?.data?.documentId) {
onCreated({ documentId: 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); clearDraft(draftScopeId, documentType);
setPublicFlagOverride(null);
onClose(); onClose();
}, },
onError: (err) => toastError(err, 'Upload failed'), onError: (err) => toastError(err, 'Upload failed'),
@@ -712,24 +768,44 @@ function DialogBody({
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{step === 'select-file' && ( {step === 'select-file' && (
<FilePickerStep <>
onFileSelected={(f) => { <FilePickerStep
setFile(f); onFileSelected={(f) => {
setTitle(f.name.replace(/\.pdf$/i, '')); setFile(f);
// Seed recipients from the prefill snapshot when the rep setTitle(f.name.replace(/\.pdf$/i, ''));
// first lands a file - only if they haven't already if (recipients.length === 0 && prefillRecipients.length > 0) {
// edited the list. This pattern keeps the prefill setRecipients(prefillRecipients);
// synchronization in user-event handlers (no setState- }
// in-effect lint trip). setStep('configure-recipients');
if (recipients.length === 0 && prefillRecipients.length > 0) { autoDetect.mutate(f);
setRecipients(prefillRecipients); }}
} title={title}
setStep('configure-recipients'); onTitleChange={setTitle}
autoDetect.mutate(f); />
}} {interestId && inBundleBerths.length > 0 ? (
title={title} <div className="mx-6 my-2 rounded-md border bg-muted/40 p-3 text-sm">
onTitleChange={setTitle} <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' && ( {step === 'configure-recipients' && (
<RecipientsStep <RecipientsStep

View File

@@ -28,6 +28,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; 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 { AddBerthToInterestDialog } from '@/components/interests/add-berth-to-interest-dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -162,10 +163,14 @@ function formatDesired(
interface RecommendationCardProps { interface RecommendationCardProps {
rec: Recommendation; rec: Recommendation;
portSlug: string; 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; onAdd: (rec: Recommendation) => void;
} }
function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { function RecommendationCard({ rec, portSlug, interestId, onAdd }: RecommendationCardProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const tier = TIER_LABELS[rec.tier]; const tier = TIER_LABELS[rec.tier];
const showHeat = rec.heat && rec.heat.total > 0; const showHeat = rec.heat && rec.heat.total > 0;
@@ -279,6 +284,16 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
) : null} ) : null}
<span className="ml-2 font-medium text-foreground">Fit {rec.fitScore}</span> <span className="ml-2 font-medium text-foreground">Fit {rec.fitScore}</span>
</div> </div>
{rec.status !== 'available' ? (
<div className="mt-1">
<BerthOccupancyChip
berthId={rec.berthId}
portSlug={portSlug}
excludeInterestId={interestId}
compact
/>
</div>
) : null}
</div> </div>
{expanded ? ( {expanded ? (
<ChevronUp className="size-4 shrink-0 text-muted-foreground" aria-hidden /> <ChevronUp className="size-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -666,6 +681,7 @@ export function BerthRecommenderPanel({
key={rec.berthId} key={rec.berthId}
rec={rec} rec={rec}
portSlug={portSlug} portSlug={portSlug}
interestId={interestId}
onAdd={setPendingBerth} onAdd={setPendingBerth}
/> />
))} ))}

View File

@@ -122,16 +122,45 @@ export function ExternalEoiUploadDialog({
}, },
]; ];
}, [signatoriesOverride, prefillSignatories, interestData]); }, [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'], queryKey: ['interests', interestId, 'berths'],
queryFn: () => queryFn: () =>
apiFetch<{ data: Array<{ mooringNumber: string | null }> }>( apiFetch<{
`/api/v1/interests/${interestId}/berths`, data: Array<{
), berthId: string;
mooringNumber: string | null;
isInEoiBundle: boolean;
isSpecificInterest: boolean;
}>;
}>(`/api/v1/interests/${interestId}/berths`),
enabled: open, enabled: open,
staleTime: 60_000, 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 // Detect a generated EOI in flight on this interest so the dialog can
// offer "Replace the generated envelope" instead of leaving two parallel // offer "Replace the generated envelope" instead of leaving two parallel
// EOIs on the deal. Only documents in non-terminal status count — already- // 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 } }; return (await res.json()) as { data?: { stageChanged?: boolean } };
}, },
onSuccess: (response) => { onSuccess: async (response) => {
const stageChanged = response?.data?.stageChanged === true; 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( toast.success(
stageChanged stageChanged
? 'External EOI uploaded. Stage advanced to EOI Signed.' ? 'External EOI uploaded. Stage advanced to EOI Signed.'
@@ -227,6 +280,7 @@ export function ExternalEoiUploadDialog({
setTitle(''); setTitle('');
setSignatoriesOverride(null); setSignatoriesOverride(null);
setNotes(''); setNotes('');
setPublicFlagOverride(null);
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
}, },
@@ -412,6 +466,29 @@ export function ExternalEoiUploadDialog({
className="mt-1" className="mt-1"
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>

View File

@@ -2,10 +2,10 @@
import { useQueries, useQuery } from '@tanstack/react-query'; import { useQueries, useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip';
interface BerthRow { interface BerthRow {
id: string; id: string;
@@ -61,13 +61,15 @@ export function InterestBerthStatusBanner({
// `/active-interests` endpoint shipped in 292a8b5. Filtered client-side // `/active-interests` endpoint shipped in 292a8b5. Filtered client-side
// to interests OTHER THAN this one so a deal looking at its own berth // to interests OTHER THAN this one so a deal looking at its own berth
// doesn't see itself in the banner. // 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({ const competingQueries = useQueries({
queries: conflicts.map((b) => ({ queries: conflicts.map((b) => ({
queryKey: ['berth-competing', b.id, interestId] as const, queryKey: ['berth', b.id, 'active-interests'] as const,
queryFn: () => queryFn: () =>
apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`).then( apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`),
(r) => r.data.filter((row) => row.interestId !== interestId),
),
enabled: conflicts.length > 0, enabled: conflicts.length > 0,
staleTime: 30_000, staleTime: 30_000,
})), })),
@@ -94,7 +96,10 @@ export function InterestBerthStatusBanner({
const lines = conflicts const lines = conflicts
.map((b, idx) => { .map((b, idx) => {
const q = competingQueries[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 }; return { berth: b, competing };
}) })
.filter((l) => l.competing !== null); .filter((l) => l.competing !== null);
@@ -115,18 +120,17 @@ export function InterestBerthStatusBanner({
} to another deal.` } to another deal.`
: `${lines.length} linked berths are no longer freely available.`} : `${lines.length} linked berths are no longer freely available.`}
</p> </p>
<ul className="mt-1 space-y-0.5"> <ul className="mt-1 space-y-1">
{lines.map(({ berth, competing }) => {lines.map(({ berth, competing }) =>
competing ? ( competing ? (
<li key={berth.id} className="text-rose-900"> <li key={berth.id} className="flex items-center gap-1.5 text-rose-900">
<span className="font-medium">{berth.mooringNumber}:</span>{' '} <span className="font-medium shrink-0">{berth.mooringNumber}:</span>
<Link <BerthOccupancyChip
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ berthId={berth.id}
href={`/${portSlug}/interests/${competing.interestId}` as any} portSlug={portSlug}
className="underline-offset-2 hover:underline" excludeInterestId={interestId}
> compact
{competing.clientName} />
</Link>
</li> </li>
) : null, ) : null,
)} )}

View File

@@ -352,6 +352,28 @@ function ActiveEoiCard({
? signers.find((s) => s.status === 'declined' || s.status === 'rejected') ? signers.find((s) => s.status === 'declined' || s.status === 'rejected')
: null; : 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({ const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }), mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => { onSuccess: () => {
@@ -419,6 +441,11 @@ function ActiveEoiCard({
? ` by ${rejectedSigner.signerName ?? rejectedSigner.signerEmail}` ? ` by ${rejectedSigner.signerName ?? rejectedSigner.signerEmail}`
: ''} : ''}
</p> </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"> <p className="mt-0.5 text-rose-800">
The document is no longer valid. Cancel and regenerate, or reach out to the signer The document is no longer valid. Cancel and regenerate, or reach out to the signer
before re-sending. before re-sending.

View File

@@ -6,6 +6,7 @@ import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react'; import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
import { toast } from 'sonner';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -116,6 +117,23 @@ interface InterestTabsOptions {
* recommender header's display so a metric-entered deal doesn't * recommender header's display so a metric-entered deal doesn't
* render as ft. The three columns share an entry unit in practice. */ * render as ft. The three columns share an entry unit in practice. */
desiredLengthUnit?: string | null; 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; leadCategory: string | null;
source: string | null; source: string | null;
eoiStatus: string | null; eoiStatus: string | null;
@@ -1307,6 +1325,28 @@ function OverviewTab({
if (!Number.isFinite(n)) return null; if (!Number.isFinite(n)) return null;
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); 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 = const onSavePair =
( (
primary: InterestPatchField, primary: InterestPatchField,
@@ -1317,54 +1357,166 @@ function OverviewTab({
[primary]: next, [primary]: next,
[counterpart]: toCounterpart(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'; 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 ( return (
<dl> <dl>
<EditableRow label={`Desired length (${unitLabel})`}> <EditableRow label={`Desired length (${unitLabel})`}>
<InlineEditableField <div className="flex items-center">
value={ <InlineEditableField
unitIsM value={
? (interest.desiredLengthM ?? null) unitIsM
: (interest.desiredLengthFt ?? null) ? (interest.desiredLengthM ?? null)
} : (interest.desiredLengthFt ?? null)
onSave={onSavePair( }
unitIsM ? 'desiredLengthM' : 'desiredLengthFt', onSave={onSavePair(
unitIsM ? 'desiredLengthFt' : 'desiredLengthM', unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
)} unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'} )}
emptyText=" - " 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>
<EditableRow label={`Desired width (${unitLabel})`}> <EditableRow label={`Desired width (${unitLabel})`}>
<InlineEditableField <div className="flex items-center">
value={ <InlineEditableField
unitIsM value={
? (interest.desiredWidthM ?? null) unitIsM
: (interest.desiredWidthFt ?? null) ? (interest.desiredWidthM ?? null)
} : (interest.desiredWidthFt ?? null)
onSave={onSavePair( }
unitIsM ? 'desiredWidthM' : 'desiredWidthFt', onSave={onSavePair(
unitIsM ? 'desiredWidthFt' : 'desiredWidthM', unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
)} unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'} )}
emptyText=" - " 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>
<EditableRow label={`Desired draft (${unitLabel})`}> <EditableRow label={`Desired draft (${unitLabel})`}>
<InlineEditableField <div className="flex items-center">
value={ <InlineEditableField
unitIsM value={
? (interest.desiredDraftM ?? null) unitIsM
: (interest.desiredDraftFt ?? null) ? (interest.desiredDraftM ?? null)
} : (interest.desiredDraftFt ?? null)
onSave={onSavePair( }
unitIsM ? 'desiredDraftM' : 'desiredDraftFt', onSave={onSavePair(
unitIsM ? 'desiredDraftFt' : 'desiredDraftM', unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
)} unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'} )}
emptyText=" - " 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> </EditableRow>
</dl> </dl>
); );

View File

@@ -42,6 +42,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -263,6 +264,10 @@ function BypassDialog({ row, open, onOpenChange, onSubmit, isPending }: BypassDi
interface RowProps { interface RowProps {
row: LinkedBerthRow; row: LinkedBerthRow;
portSlug: string; 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; eoiStatus: string | null;
onUpdate: (berthId: string, patch: PatchPayload) => void; onUpdate: (berthId: string, patch: PatchPayload) => void;
onRemove: (berthId: string) => void; onRemove: (berthId: string) => void;
@@ -274,6 +279,7 @@ interface RowProps {
function LinkedBerthRowItem({ function LinkedBerthRowItem({
row, row,
portSlug, portSlug,
interestId,
eoiStatus, eoiStatus,
onUpdate, onUpdate,
onRemove, onRemove,
@@ -305,6 +311,14 @@ function LinkedBerthRowItem({
leading character of the mooring number rendered above, so leading character of the mooring number rendered above, so
surfacing it again is pure noise. Hidden 2026-05-15. */} surfacing it again is pure noise. Hidden 2026-05-15. */}
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill> <StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
{row.status !== 'available' ? (
<BerthOccupancyChip
berthId={row.berthId}
portSlug={portSlug}
excludeInterestId={interestId}
compact
/>
) : null}
{row.isPrimary ? ( {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"> <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 /> <Star className="size-3" aria-hidden />
@@ -667,6 +681,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
key={row.id} key={row.id}
row={row} row={row}
portSlug={portSlug} portSlug={portSlug}
interestId={interestId}
eoiStatus={eoiStatus} eoiStatus={eoiStatus}
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })} onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
onRemove={(berthId) => removeMutation.mutate(berthId)} onRemove={(berthId) => removeMutation.mutate(berthId)}

View File

@@ -218,10 +218,28 @@ interface TierInputs {
activeInterestCount: number; activeInterestCount: number;
lostCount: number; lostCount: number;
maxActiveStage: 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 { 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 (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.activeInterestCount > 0) return 'C';
if (t.lostCount > 0) return 'B'; if (t.lostCount > 0) return 'B';
return 'A'; return 'A';

View File

@@ -665,6 +665,45 @@ export async function getInterestById(id: string, portId: string) {
: (berthResoldRaw.rows ?? []); : (berthResoldRaw.rows ?? []);
const dateBerthSoldToOther = berthResoldRows[0]?.at ?? null; 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 // 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). // to the raw ID is fine if the user record is missing (deleted/disabled).
let assignedToName: string | null = null; let assignedToName: string | null = null;
@@ -706,6 +745,7 @@ export async function getInterestById(id: string, portId: string) {
dateDocumentDeclined, dateDocumentDeclined,
dateReservationCancelled, dateReservationCancelled,
dateBerthSoldToOther, dateBerthSoldToOther,
yachtDimensions,
}; };
} }