2026-05-26 21:48:19 +02:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
|
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
toggleable, default-open) + explicit download. Interest Documents tab
already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
linked-berth rows show an amber "Pin overrides pitch" badge + corrected
consequence copy when a berth is specifically-pitched but manually pinned
(the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
registry + admin Settings toggle, maintenance-module.service, port-provider
useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
deal (name + stage + in-EOI/primary + link); single stays a direct link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
2026-05-26 21:48:19 +02:00
|
|
|
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;
|
|
|
|
|
|
feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
toggleable, default-open) + explicit download. Interest Documents tab
already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
linked-berth rows show an amber "Pin overrides pitch" badge + corrected
consequence copy when a berth is specifically-pitched but manually pinned
(the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
registry + admin Settings toggle, maintenance-module.service, port-provider
useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
deal (name + stage + in-EOI/primary + link); single stays a direct link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
|
|
|
const chipClass = 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',
|
|
|
|
|
// Cap tight on narrow viewports, but give the name room on desktop
|
|
|
|
|
// so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
|
|
|
|
|
compact && 'max-w-[200px] md:max-w-[460px]',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const stageChip = (stage: string) => (
|
|
|
|
|
<span className={cn('shrink-0 rounded-full px-1.5 text-xs', stageBadgeClass(stage))}>
|
|
|
|
|
{stageLabel(stage)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Single competing interest → the chip is a direct link to it.
|
|
|
|
|
if (competing.length === 1) {
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
href={`/${portSlug}/interests/${primary.interestId}` as never}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className={chipClass}
|
|
|
|
|
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
|
2026-05-26 21:48:19 +02:00
|
|
|
>
|
feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
toggleable, default-open) + explicit download. Interest Documents tab
already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
linked-berth rows show an amber "Pin overrides pitch" badge + corrected
consequence copy when a berth is specifically-pitched but manually pinned
(the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
registry + admin Settings toggle, maintenance-module.service, port-provider
useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
deal (name + stage + in-EOI/primary + link); single stays a direct link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
|
|
|
<span className="font-medium">Under offer to:</span>
|
|
|
|
|
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
|
|
|
|
|
{stageChip(primary.pipelineStage)}
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Multiple competing interests → the chip opens a popover that lists
|
|
|
|
|
// every competing deal so no name is hidden behind "+N more" (UAT
|
|
|
|
|
// 2026-06-03). Each row links to its interest.
|
|
|
|
|
return (
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button type="button" onClick={(e) => e.stopPropagation()} className={chipClass}>
|
|
|
|
|
<span className="font-medium">Under offer to:</span>
|
|
|
|
|
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
|
|
|
|
|
{stageChip(primary.pipelineStage)}
|
|
|
|
|
<span className="shrink-0 text-amber-700">+{extras} more</span>
|
|
|
|
|
</button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent align="start" className="w-72 p-0" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
|
|
|
|
{competing.length} interests competing for this berth
|
|
|
|
|
</div>
|
|
|
|
|
<ul className="max-h-72 divide-y overflow-y-auto">
|
|
|
|
|
{competing.map((r) => (
|
|
|
|
|
<li key={r.interestId}>
|
|
|
|
|
<Link
|
|
|
|
|
href={`/${portSlug}/interests/${r.interestId}` as never}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/60"
|
|
|
|
|
>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">
|
|
|
|
|
{r.clientName}
|
|
|
|
|
{r.isInEoiBundle ? (
|
|
|
|
|
<span className="ml-1.5 text-xs text-amber-700">· in EOI</span>
|
|
|
|
|
) : r.isPrimary ? (
|
|
|
|
|
<span className="ml-1.5 text-xs text-muted-foreground">· primary</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
{stageChip(r.pipelineStage)}
|
|
|
|
|
</Link>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2026-05-26 21:48:19 +02:00
|
|
|
);
|
|
|
|
|
}
|