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>
This commit is contained in:
@@ -72,6 +72,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'maintenance_module_enabled',
|
||||
label: 'Berth Maintenance Module',
|
||||
description:
|
||||
'Enable the per-berth maintenance log (the "Maintenance" tab on each berth detail page). On by default. Disabling hides the Maintenance tab everywhere and blocks its log routes; previously-recorded maintenance logs are preserved and reappear when you re-enable.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'ai_interest_scoring',
|
||||
label: 'AI Interest Scoring',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useTenanciesModuleEnabled } from '@/providers/port-provider';
|
||||
import { useTenanciesModuleEnabled, useMaintenanceModuleEnabled } from '@/providers/port-provider';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header';
|
||||
import { BerthForm } from './berth-form';
|
||||
@@ -22,6 +22,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const tenanciesModuleEnabled = useTenanciesModuleEnabled();
|
||||
const maintenanceModuleEnabled = useMaintenanceModuleEnabled();
|
||||
|
||||
const { data, isLoading, error } = useQuery<BerthDetailData>({
|
||||
queryKey: ['berth', berthId],
|
||||
@@ -86,7 +87,9 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
<DetailLayout
|
||||
isLoading={isLoading}
|
||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||
tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []}
|
||||
tabs={
|
||||
berth ? buildBerthTabs(berth, { tenanciesModuleEnabled, maintenanceModuleEnabled }) : []
|
||||
}
|
||||
defaultTab="overview"
|
||||
/>
|
||||
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
@@ -29,6 +31,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PdfReconcileDialog } from './pdf-reconcile-dialog';
|
||||
|
||||
// pdfjs-dist is ~150kb gzip — lazy-load so the berth page only pulls it
|
||||
// in when a rep actually expands the spec-sheet preview. ssr:false
|
||||
// because the pdfjs worker setup needs `window`.
|
||||
const PdfViewer = dynamic(
|
||||
() => import('@/components/files/pdf-viewer').then((m) => ({ default: m.PdfViewer })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[600px] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading PDF viewer…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface PdfVersionRow {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
@@ -53,6 +70,7 @@ interface UploadUrlResponse {
|
||||
export function BerthDocumentsTab({ berthId }: { berthId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewOpen, setPreviewOpen] = useState(true);
|
||||
const [pendingDiff, setPendingDiff] = useState<{
|
||||
versionId: string;
|
||||
autoApplied: Array<{ field: string; value: string | number }>;
|
||||
@@ -187,24 +205,45 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm">
|
||||
<CardContent className="space-y-3 pt-0 text-sm">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Loading…</p>
|
||||
) : current ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={current.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
{current.fileName}
|
||||
</a>
|
||||
<span className="text-muted-foreground">
|
||||
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
|
||||
aria-expanded={previewOpen}
|
||||
>
|
||||
{previewOpen ? (
|
||||
<ChevronDown className="size-3.5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
{current.fileName}
|
||||
</button>
|
||||
<span className="text-muted-foreground">
|
||||
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
|
||||
<a
|
||||
href={current.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
{previewOpen ? (
|
||||
<div className="h-[600px] overflow-hidden rounded-md border bg-muted/20">
|
||||
<PdfViewer url={current.downloadUrl} fileName={current.fileName} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No PDF uploaded yet.</p>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -427,7 +427,10 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
|
||||
export function buildBerthTabs(
|
||||
berth: BerthData,
|
||||
opts: { tenanciesModuleEnabled: boolean } = { tenanciesModuleEnabled: false },
|
||||
opts: { tenanciesModuleEnabled: boolean; maintenanceModuleEnabled: boolean } = {
|
||||
tenanciesModuleEnabled: false,
|
||||
maintenanceModuleEnabled: true,
|
||||
},
|
||||
): DetailTab[] {
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
@@ -448,12 +451,15 @@ export function buildBerthTabs(
|
||||
content: <BerthTenanciesTab berthId={berth.id} />,
|
||||
});
|
||||
}
|
||||
tabs.push(...buildBerthDetailRemainder(berth));
|
||||
tabs.push(...buildBerthDetailRemainder(berth, opts));
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
|
||||
return [
|
||||
function buildBerthDetailRemainder(
|
||||
berth: BerthData,
|
||||
opts: { maintenanceModuleEnabled: boolean } = { maintenanceModuleEnabled: true },
|
||||
): DetailTab[] {
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
id: 'spec',
|
||||
label: 'Spec',
|
||||
@@ -469,20 +475,23 @@ function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
|
||||
label: 'Waiting List',
|
||||
content: <WaitingListManager berthId={berth.id} />,
|
||||
},
|
||||
{
|
||||
];
|
||||
if (opts.maintenanceModuleEnabled) {
|
||||
tabs.push({
|
||||
id: 'maintenance',
|
||||
label: 'Maintenance',
|
||||
content: <BerthMaintenanceTab berthId={berth.id} />,
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: (
|
||||
<EntityActivityFeed
|
||||
endpoint={`/api/v1/berths/${berth.id}/activity`}
|
||||
emptyText="No activity recorded for this berth yet."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
tabs.push({
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: (
|
||||
<EntityActivityFeed
|
||||
endpoint={`/api/v1/berths/${berth.id}/activity`}
|
||||
emptyText="No activity recorded for this berth yet."
|
||||
/>
|
||||
),
|
||||
});
|
||||
return tabs;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Anchor, Loader2, Plus, Star, Trash2 } from 'lucide-react';
|
||||
import { Anchor, Loader2, Pin, Plus, Star, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -70,6 +70,7 @@ export interface LinkedBerthRow {
|
||||
mooringNumber: string | null;
|
||||
area: string | null;
|
||||
status: string;
|
||||
statusOverrideMode: string | null;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
@@ -330,6 +331,15 @@ function LinkedBerthRowItem({
|
||||
EOI bypassed
|
||||
</span>
|
||||
) : null}
|
||||
{row.isSpecificInterest && row.statusOverrideMode === 'manual' ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-900"
|
||||
title={`This berth's status is manually pinned, which overrides "Specifically pitching" on the public map. It will display as "${formatStatus(row.status)}" — not "Under Offer" — until the pin is cleared (edit the berth's status).`}
|
||||
>
|
||||
<Pin className="size-3" aria-hidden />
|
||||
Pin overrides pitch
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null}
|
||||
</div>
|
||||
@@ -400,7 +410,11 @@ function LinkedBerthRowItem({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
|
||||
{row.isSpecificInterest && row.statusOverrideMode === 'manual'
|
||||
? `Overridden: this berth's status is manually pinned, so the public map shows “${formatStatus(row.status)}”, not “Under Offer”. Clear the pin on the berth to let this take effect.`
|
||||
: row.isSpecificInterest
|
||||
? SPECIFIC_CONSEQUENCE_ON
|
||||
: SPECIFIC_CONSEQUENCE_OFF}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
Reference in New Issue
Block a user