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,
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
“{rejectionReason}”
|
||||||
|
</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.
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user