feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m45s
Build & Push Docker Images / build-and-push (push) Successful in 8m11s

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>
This commit is contained in:
2026-06-03 19:15:04 +02:00
parent 2a7f922a01
commit 1750e265e7
13 changed files with 301 additions and 59 deletions

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -80,29 +81,74 @@ export function BerthOccupancyChip({
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',
// 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]',
)}
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),
)}
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)})`}
>
{stageLabel(primary.pipelineStage)}
</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null}
</Link>
<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>
);
}