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>
|
||||
);
|
||||
}
|
||||
@@ -389,6 +389,43 @@ function DialogBody({
|
||||
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
|
||||
* 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> };
|
||||
}>;
|
||||
},
|
||||
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(
|
||||
defaults?.data?.sendMode === 'auto'
|
||||
? '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] === 'interest' });
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'berth' });
|
||||
if (onCreated && 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);
|
||||
setPublicFlagOverride(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => toastError(err, 'Upload failed'),
|
||||
@@ -712,24 +768,44 @@ function DialogBody({
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{step === 'select-file' && (
|
||||
<FilePickerStep
|
||||
onFileSelected={(f) => {
|
||||
setFile(f);
|
||||
setTitle(f.name.replace(/\.pdf$/i, ''));
|
||||
// Seed recipients from the prefill snapshot when the rep
|
||||
// first lands a file - only if they haven't already
|
||||
// edited the list. This pattern keeps the prefill
|
||||
// synchronization in user-event handlers (no setState-
|
||||
// in-effect lint trip).
|
||||
if (recipients.length === 0 && prefillRecipients.length > 0) {
|
||||
setRecipients(prefillRecipients);
|
||||
}
|
||||
setStep('configure-recipients');
|
||||
autoDetect.mutate(f);
|
||||
}}
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
/>
|
||||
<>
|
||||
<FilePickerStep
|
||||
onFileSelected={(f) => {
|
||||
setFile(f);
|
||||
setTitle(f.name.replace(/\.pdf$/i, ''));
|
||||
if (recipients.length === 0 && prefillRecipients.length > 0) {
|
||||
setRecipients(prefillRecipients);
|
||||
}
|
||||
setStep('configure-recipients');
|
||||
autoDetect.mutate(f);
|
||||
}}
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
/>
|
||||
{interestId && inBundleBerths.length > 0 ? (
|
||||
<div className="mx-6 my-2 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}
|
||||
</>
|
||||
)}
|
||||
{step === 'configure-recipients' && (
|
||||
<RecipientsStep
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -162,10 +163,14 @@ function formatDesired(
|
||||
interface RecommendationCardProps {
|
||||
rec: Recommendation;
|
||||
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;
|
||||
}
|
||||
|
||||
function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
|
||||
function RecommendationCard({ rec, portSlug, interestId, onAdd }: RecommendationCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tier = TIER_LABELS[rec.tier];
|
||||
const showHeat = rec.heat && rec.heat.total > 0;
|
||||
@@ -279,6 +284,16 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
|
||||
) : null}
|
||||
<span className="ml-2 font-medium text-foreground">Fit {rec.fitScore}</span>
|
||||
</div>
|
||||
{rec.status !== 'available' ? (
|
||||
<div className="mt-1">
|
||||
<BerthOccupancyChip
|
||||
berthId={rec.berthId}
|
||||
portSlug={portSlug}
|
||||
excludeInterestId={interestId}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
@@ -666,6 +681,7 @@ export function BerthRecommenderPanel({
|
||||
key={rec.berthId}
|
||||
rec={rec}
|
||||
portSlug={portSlug}
|
||||
interestId={interestId}
|
||||
onAdd={setPendingBerth}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -122,16 +122,45 @@ export function ExternalEoiUploadDialog({
|
||||
},
|
||||
];
|
||||
}, [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'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: Array<{ mooringNumber: string | null }> }>(
|
||||
`/api/v1/interests/${interestId}/berths`,
|
||||
),
|
||||
apiFetch<{
|
||||
data: Array<{
|
||||
berthId: string;
|
||||
mooringNumber: string | null;
|
||||
isInEoiBundle: boolean;
|
||||
isSpecificInterest: boolean;
|
||||
}>;
|
||||
}>(`/api/v1/interests/${interestId}/berths`),
|
||||
enabled: open,
|
||||
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
|
||||
// offer "Replace the generated envelope" instead of leaving two parallel
|
||||
// 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 } };
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
onSuccess: async (response) => {
|
||||
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(
|
||||
stageChanged
|
||||
? 'External EOI uploaded. Stage advanced to EOI Signed.'
|
||||
@@ -227,6 +280,7 @@ export function ExternalEoiUploadDialog({
|
||||
setTitle('');
|
||||
setSignatoriesOverride(null);
|
||||
setNotes('');
|
||||
setPublicFlagOverride(null);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
@@ -412,6 +466,29 @@ export function ExternalEoiUploadDialog({
|
||||
className="mt-1"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip';
|
||||
|
||||
interface BerthRow {
|
||||
id: string;
|
||||
@@ -61,13 +61,15 @@ export function InterestBerthStatusBanner({
|
||||
// `/active-interests` endpoint shipped in 292a8b5. Filtered client-side
|
||||
// to interests OTHER THAN this one so a deal looking at its own berth
|
||||
// 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({
|
||||
queries: conflicts.map((b) => ({
|
||||
queryKey: ['berth-competing', b.id, interestId] as const,
|
||||
queryKey: ['berth', b.id, 'active-interests'] as const,
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`).then(
|
||||
(r) => r.data.filter((row) => row.interestId !== interestId),
|
||||
),
|
||||
apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`),
|
||||
enabled: conflicts.length > 0,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
@@ -94,7 +96,10 @@ export function InterestBerthStatusBanner({
|
||||
const lines = conflicts
|
||||
.map((b, 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 };
|
||||
})
|
||||
.filter((l) => l.competing !== null);
|
||||
@@ -115,18 +120,17 @@ export function InterestBerthStatusBanner({
|
||||
} to another deal.`
|
||||
: `${lines.length} linked berths are no longer freely available.`}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
<ul className="mt-1 space-y-1">
|
||||
{lines.map(({ berth, competing }) =>
|
||||
competing ? (
|
||||
<li key={berth.id} className="text-rose-900">
|
||||
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{competing.clientName}
|
||||
</Link>
|
||||
<li key={berth.id} className="flex items-center gap-1.5 text-rose-900">
|
||||
<span className="font-medium shrink-0">{berth.mooringNumber}:</span>
|
||||
<BerthOccupancyChip
|
||||
berthId={berth.id}
|
||||
portSlug={portSlug}
|
||||
excludeInterestId={interestId}
|
||||
compact
|
||||
/>
|
||||
</li>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
@@ -352,6 +352,28 @@ function ActiveEoiCard({
|
||||
? signers.find((s) => s.status === 'declined' || s.status === 'rejected')
|
||||
: 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({
|
||||
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
|
||||
onSuccess: () => {
|
||||
@@ -419,6 +441,11 @@ function ActiveEoiCard({
|
||||
? ` by ${rejectedSigner.signerName ?? rejectedSigner.signerEmail}`
|
||||
: ''}
|
||||
</p>
|
||||
{rejectionReason ? (
|
||||
<blockquote className="mt-1 rounded border-l-2 border-rose-400 bg-rose-50 px-2 py-1 text-rose-900 italic">
|
||||
“{rejectionReason}”
|
||||
</blockquote>
|
||||
) : null}
|
||||
<p className="mt-0.5 text-rose-800">
|
||||
The document is no longer valid. Cancel and regenerate, or reach out to the signer
|
||||
before re-sending.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { BerthOccupancyChip } from '@/components/berths/berth-occupancy-chip';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -263,6 +264,10 @@ function BypassDialog({ row, open, onOpenChange, onSubmit, isPending }: BypassDi
|
||||
interface RowProps {
|
||||
row: LinkedBerthRow;
|
||||
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;
|
||||
onUpdate: (berthId: string, patch: PatchPayload) => void;
|
||||
onRemove: (berthId: string) => void;
|
||||
@@ -274,6 +279,7 @@ interface RowProps {
|
||||
function LinkedBerthRowItem({
|
||||
row,
|
||||
portSlug,
|
||||
interestId,
|
||||
eoiStatus,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
@@ -305,6 +311,14 @@ function LinkedBerthRowItem({
|
||||
leading character of the mooring number rendered above, so
|
||||
surfacing it again is pure noise. Hidden 2026-05-15. */}
|
||||
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
|
||||
{row.status !== 'available' ? (
|
||||
<BerthOccupancyChip
|
||||
berthId={row.berthId}
|
||||
portSlug={portSlug}
|
||||
excludeInterestId={interestId}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
{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">
|
||||
<Star className="size-3" aria-hidden />
|
||||
@@ -667,6 +681,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
||||
key={row.id}
|
||||
row={row}
|
||||
portSlug={portSlug}
|
||||
interestId={interestId}
|
||||
eoiStatus={eoiStatus}
|
||||
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
|
||||
onRemove={(berthId) => removeMutation.mutate(berthId)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user