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

@@ -20,6 +20,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service'; import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
import { isMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers(); const headerList = await headers();
@@ -127,12 +128,29 @@ export default async function DashboardLayout({ children }: { children: React.Re
const residentialModuleByPort: Record<string, boolean> = const residentialModuleByPort: Record<string, boolean> =
Object.fromEntries(residentialModuleEntries); Object.fromEntries(residentialModuleEntries);
// Per-port maintenance-module gate. Defaults to enabled (registry
// default) so existing ports keep the berth Maintenance tab on deploy.
// Resolved server-side so the tab SSRs in/out without flicker.
const maintenanceModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isMaintenanceModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature visible.
return [p.id, true] as const;
}
}),
);
const maintenanceModuleByPort: Record<string, boolean> =
Object.fromEntries(maintenanceModuleEntries);
return ( return (
<QueryProvider> <QueryProvider>
<PortProvider <PortProvider
ports={ports} ports={ports}
defaultPortId={ports[0]?.id ?? null} defaultPortId={ports[0]?.id ?? null}
tenanciesModuleByPort={tenanciesModuleByPort} tenanciesModuleByPort={tenanciesModuleByPort}
maintenanceModuleByPort={maintenanceModuleByPort}
> >
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>

View File

@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { updateMaintenanceLogSchema } from '@/lib/validators/berths'; import { updateMaintenanceLogSchema } from '@/lib/validators/berths';
import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service'; import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// PATCH /api/v1/berths/[id]/maintenance/[logId] // PATCH /api/v1/berths/[id]/maintenance/[logId]
export const PATCH = withAuth( export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => { withPermission('berths', 'edit', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, updateMaintenanceLogSchema); const body = await parseBody(req, updateMaintenanceLogSchema);
const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, { const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, {
userId: ctx.userId, userId: ctx.userId,
@@ -28,6 +30,7 @@ export const PATCH = withAuth(
export const DELETE = withAuth( export const DELETE = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => { withPermission('berths', 'edit', async (_req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, { await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, {
userId: ctx.userId, userId: ctx.userId,
portId: ctx.portId, portId: ctx.portId,

View File

@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { addMaintenanceLogSchema } from '@/lib/validators/berths'; import { addMaintenanceLogSchema } from '@/lib/validators/berths';
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service'; import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]/maintenance // GET /api/v1/berths/[id]/maintenance
export const GET = withAuth( export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => { withPermission('berths', 'view', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const logs = await getMaintenanceLogs(params.id!, ctx.portId); const logs = await getMaintenanceLogs(params.id!, ctx.portId);
return NextResponse.json({ data: logs }); return NextResponse.json({ data: logs });
} catch (error) { } catch (error) {
@@ -22,6 +24,7 @@ export const GET = withAuth(
export const POST = withAuth( export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => { withPermission('berths', 'edit', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, addMaintenanceLogSchema); const body = await parseBody(req, addMaintenanceLogSchema);
const log = await addMaintenanceLog(params.id!, ctx.portId, body, { const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
userId: ctx.userId, userId: ctx.userId,

View File

@@ -72,6 +72,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, 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', key: 'ai_interest_scoring',
label: 'AI Interest Scoring', label: 'AI Interest Scoring',

View File

@@ -8,7 +8,7 @@ import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found'; import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { apiFetch } from '@/lib/api/client'; 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 { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header'; import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header';
import { BerthForm } from './berth-form'; import { BerthForm } from './berth-form';
@@ -22,6 +22,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const tenanciesModuleEnabled = useTenanciesModuleEnabled(); const tenanciesModuleEnabled = useTenanciesModuleEnabled();
const maintenanceModuleEnabled = useMaintenanceModuleEnabled();
const { data, isLoading, error } = useQuery<BerthDetailData>({ const { data, isLoading, error } = useQuery<BerthDetailData>({
queryKey: ['berth', berthId], queryKey: ['berth', berthId],
@@ -86,7 +87,9 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
<DetailLayout <DetailLayout
isLoading={isLoading} isLoading={isLoading}
header={berth ? <BerthDetailHeader berth={berth} /> : null} header={berth ? <BerthDetailHeader berth={berth} /> : null}
tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []} tabs={
berth ? buildBerthTabs(berth, { tenanciesModuleEnabled, maintenanceModuleEnabled }) : []
}
defaultTab="overview" defaultTab="overview"
/> />
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null} {berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}

View File

@@ -19,8 +19,10 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; 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 { Badge } from '@/components/ui/badge';
import { PdfReconcileDialog } from './pdf-reconcile-dialog'; 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 { interface PdfVersionRow {
id: string; id: string;
versionNumber: number; versionNumber: number;
@@ -53,6 +70,7 @@ interface UploadUrlResponse {
export function BerthDocumentsTab({ berthId }: { berthId: string }) { export function BerthDocumentsTab({ berthId }: { berthId: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [previewOpen, setPreviewOpen] = useState(true);
const [pendingDiff, setPendingDiff] = useState<{ const [pendingDiff, setPendingDiff] = useState<{
versionId: string; versionId: string;
autoApplied: Array<{ field: string; value: string | number }>; autoApplied: Array<{ field: string; value: string | number }>;
@@ -187,24 +205,45 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0 text-sm"> <CardContent className="space-y-3 pt-0 text-sm">
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground">Loading</p> <p className="text-muted-foreground">Loading</p>
) : current ? ( ) : current ? (
<div className="flex flex-wrap items-center gap-2"> <>
<a <div className="flex flex-wrap items-center gap-2">
href={current.downloadUrl} <button
target="_blank" type="button"
rel="noreferrer" onClick={() => setPreviewOpen((o) => !o)}
className="font-medium underline underline-offset-2" className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
> aria-expanded={previewOpen}
{current.fileName} >
</a> {previewOpen ? (
<span className="text-muted-foreground"> <ChevronDown className="size-3.5 shrink-0" aria-hidden />
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB ) : (
</span> <ChevronRight className="size-3.5 shrink-0" aria-hidden />
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null} )}
</div> {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> <p className="text-muted-foreground">No PDF uploaded yet.</p>
)} )}

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { stageBadgeClass, stageLabel } from '@/lib/constants'; import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -80,29 +81,74 @@ export function BerthOccupancyChip({
competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!; competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!;
const extras = competing.length - 1; const extras = competing.length - 1;
return ( const chipClass = cn(
<Link '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',
href={`/${portSlug}/interests/${primary.interestId}` as never} // Cap tight on narrow viewports, but give the name room on desktop
onClick={(e) => e.stopPropagation()} // so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
className={cn( compact && 'max-w-[200px] md:max-w-[460px]',
'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). const stageChip = (stage: string) => (
compact && 'max-w-[200px] md:max-w-[460px]', <span className={cn('shrink-0 rounded-full px-1.5 text-xs', stageBadgeClass(stage))}>
)} {stageLabel(stage)}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`} </span>
> );
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span> // Single competing interest → the chip is a direct link to it.
<span if (competing.length === 1) {
className={cn( return (
'shrink-0 rounded-full px-1.5 text-xs', <Link
stageBadgeClass(primary.pipelineStage), href={`/${portSlug}/interests/${primary.interestId}` as never}
)} onClick={(e) => e.stopPropagation()}
className={chipClass}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
> >
{stageLabel(primary.pipelineStage)} <span className="font-medium">Under offer to:</span>
</span> <span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null} {stageChip(primary.pipelineStage)}
</Link> </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>
); );
} }

View File

@@ -427,7 +427,10 @@ function OverviewTab({ berth }: { berth: BerthData }) {
export function buildBerthTabs( export function buildBerthTabs(
berth: BerthData, berth: BerthData,
opts: { tenanciesModuleEnabled: boolean } = { tenanciesModuleEnabled: false }, opts: { tenanciesModuleEnabled: boolean; maintenanceModuleEnabled: boolean } = {
tenanciesModuleEnabled: false,
maintenanceModuleEnabled: true,
},
): DetailTab[] { ): DetailTab[] {
const tabs: DetailTab[] = [ const tabs: DetailTab[] = [
{ {
@@ -448,12 +451,15 @@ export function buildBerthTabs(
content: <BerthTenanciesTab berthId={berth.id} />, content: <BerthTenanciesTab berthId={berth.id} />,
}); });
} }
tabs.push(...buildBerthDetailRemainder(berth)); tabs.push(...buildBerthDetailRemainder(berth, opts));
return tabs; return tabs;
} }
function buildBerthDetailRemainder(berth: BerthData): DetailTab[] { function buildBerthDetailRemainder(
return [ berth: BerthData,
opts: { maintenanceModuleEnabled: boolean } = { maintenanceModuleEnabled: true },
): DetailTab[] {
const tabs: DetailTab[] = [
{ {
id: 'spec', id: 'spec',
label: 'Spec', label: 'Spec',
@@ -469,20 +475,23 @@ function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
label: 'Waiting List', label: 'Waiting List',
content: <WaitingListManager berthId={berth.id} />, content: <WaitingListManager berthId={berth.id} />,
}, },
{ ];
if (opts.maintenanceModuleEnabled) {
tabs.push({
id: 'maintenance', id: 'maintenance',
label: 'Maintenance', label: 'Maintenance',
content: <BerthMaintenanceTab berthId={berth.id} />, content: <BerthMaintenanceTab berthId={berth.id} />,
}, });
{ }
id: 'activity', tabs.push({
label: 'Activity', id: 'activity',
content: ( label: 'Activity',
<EntityActivityFeed content: (
endpoint={`/api/v1/berths/${berth.id}/activity`} <EntityActivityFeed
emptyText="No activity recorded for this berth yet." endpoint={`/api/v1/berths/${berth.id}/activity`}
/> emptyText="No activity recorded for this berth yet."
), />
}, ),
]; });
return tabs;
} }

View File

@@ -20,7 +20,7 @@ import { useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -70,6 +70,7 @@ export interface LinkedBerthRow {
mooringNumber: string | null; mooringNumber: string | null;
area: string | null; area: string | null;
status: string; status: string;
statusOverrideMode: string | null;
lengthFt: string | null; lengthFt: string | null;
widthFt: string | null; widthFt: string | null;
draftFt: string | null; draftFt: string | null;
@@ -330,6 +331,15 @@ function LinkedBerthRowItem({
EOI bypassed EOI bypassed
</span> </span>
) : null} ) : 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> </div>
{dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null} {dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null}
</div> </div>
@@ -400,7 +410,11 @@ function LinkedBerthRowItem({
</Tooltip> </Tooltip>
</div> </div>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">

View File

@@ -143,6 +143,10 @@ export interface InterestBerthWithDetails extends InterestBerth {
mooringNumber: string | null; mooringNumber: string | null;
area: string | null; area: string | null;
status: string | null; status: string | null;
/** Soft-pin marker: when 'manual', the berth's pinned status wins over
* this link's is_specific_interest signal on the public map, so the UI
* warns the rep that "Specifically pitching" won't surface Under Offer. */
statusOverrideMode: string | null;
lengthFt: string | null; lengthFt: string | null;
widthFt: string | null; widthFt: string | null;
draftFt: string | null; draftFt: string | null;
@@ -169,6 +173,7 @@ export async function listBerthsForInterest(
mooringNumber: berths.mooringNumber, mooringNumber: berths.mooringNumber,
area: berths.area, area: berths.area,
status: berths.status, status: berths.status,
statusOverrideMode: berths.statusOverrideMode,
lengthFt: berths.lengthFt, lengthFt: berths.lengthFt,
widthFt: berths.widthFt, widthFt: berths.widthFt,
draftFt: berths.draftFt, draftFt: berths.draftFt,

View File

@@ -0,0 +1,59 @@
/**
* Maintenance module gate. Port-scoped on/off switch for the per-berth
* maintenance surface (the berth "Maintenance" tab, its log API, and the
* maintenance-reminder notification preference).
*
* Defaults to ENABLED so existing ports keep the feature on deploy.
* When an admin turns it off:
* - the Maintenance tab disappears from the berth detail page (gated via
* the port-resolved maintenanceModuleByPort prop on the layout →
* port-provider → `useMaintenanceModuleEnabled()`)
* - the maintenance log routes reject reads/writes at the boundary
* (`assertMaintenanceModuleEnabled` → 404)
* - previously-recorded maintenance logs are preserved (no destructive
* cleanup) so re-enabling restores everything
*/
import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { NotFoundError } from '@/lib/errors';
/**
* Resolve whether the Maintenance module is currently active for the given
* port. Reads from `system_settings.maintenance_module_enabled` (port-
* scoped row first, then global row, then registry default = true).
*
* Defaulting to enabled mirrors how the feature behaved before the toggle
* existed: deploying this change to a port that has never configured the
* setting leaves the feature visible.
*/
export async function isMaintenanceModuleEnabled(portId: string): Promise<boolean> {
const settingRow = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'maintenance_module_enabled'),
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
),
)
.limit(1);
// Stored JSONB shape is the raw boolean; only an explicit `false` turns
// the module off — missing row / true / unrecognized shape => enabled.
if (settingRow[0]?.value === false) return false;
return true;
}
/**
* Throw `NotFoundError` (→ 404) when the Maintenance module is disabled.
* Defense-in-depth for the maintenance log routes; the UI independently
* hides the tab so a rep never sees a link that would 404.
*/
export async function assertMaintenanceModuleEnabled(portId: string): Promise<void> {
const enabled = await isMaintenanceModuleEnabled(portId);
if (!enabled) {
throw new NotFoundError('Maintenance module is not enabled for this port.');
}
}

View File

@@ -640,6 +640,23 @@ export const REGISTRY: SettingEntry[] = [
defaultValue: true, defaultValue: true,
}, },
// ─── Operations - Maintenance module ──────────────────────────────────────
// Port-scoped gate for the per-berth Maintenance surface (the berth
// "Maintenance" tab + its log API + the maintenance-reminder notification
// preference). Defaults to enabled so existing ports keep the feature on
// deploy. Disabling hides the Maintenance tab and blocks the maintenance
// log routes; previously-recorded maintenance logs are preserved.
{
key: 'maintenance_module_enabled',
section: 'operations.maintenance',
label: 'Berth maintenance module',
description:
'When enabled, reps can log maintenance work against individual berths (the "Maintenance" tab on each berth) and receive maintenance reminders. Turning this off hides the Maintenance tab everywhere and blocks its routes. Disabling does not delete previously-recorded maintenance logs — they reappear on re-enable.',
type: 'boolean',
scope: 'port',
defaultValue: true,
},
// ─── Operations - Invoices module ───────────────────────────────────────── // ─── Operations - Invoices module ─────────────────────────────────────────
// Port-scoped gate for the standalone `/invoices` flow. Audit conclusion // Port-scoped gate for the standalone `/invoices` flow. Audit conclusion
// (2026-05-27, Initiative 1c): the schema is rich (invoices + invoice_line_items // (2026-05-27, Initiative 1c): the schema is rich (invoices + invoice_line_items

View File

@@ -16,6 +16,10 @@ interface PortContextValue {
* Resolved server-side in the dashboard layout. Consumers read via * Resolved server-side in the dashboard layout. Consumers read via
* `useTenanciesModuleEnabled()`. */ * `useTenanciesModuleEnabled()`. */
tenanciesModuleByPort: Record<string, boolean>; tenanciesModuleByPort: Record<string, boolean>;
/** Per-port Maintenance-module flag. Resolved server-side in the
* dashboard layout; consumers read via `useMaintenanceModuleEnabled()`.
* Defaults to enabled (unset port => true). */
maintenanceModuleByPort: Record<string, boolean>;
} }
const PortContext = createContext<PortContextValue>({ const PortContext = createContext<PortContextValue>({
@@ -24,6 +28,7 @@ const PortContext = createContext<PortContextValue>({
currentPortId: null, currentPortId: null,
currentPortSlug: null, currentPortSlug: null,
tenanciesModuleByPort: {}, tenanciesModuleByPort: {},
maintenanceModuleByPort: {},
}); });
interface PortProviderProps { interface PortProviderProps {
@@ -31,6 +36,7 @@ interface PortProviderProps {
ports: Port[]; ports: Port[];
defaultPortId: string | null; defaultPortId: string | null;
tenanciesModuleByPort?: Record<string, boolean>; tenanciesModuleByPort?: Record<string, boolean>;
maintenanceModuleByPort?: Record<string, boolean>;
} }
export function PortProvider({ export function PortProvider({
@@ -38,6 +44,7 @@ export function PortProvider({
ports, ports,
defaultPortId, defaultPortId,
tenanciesModuleByPort = {}, tenanciesModuleByPort = {},
maintenanceModuleByPort = {},
}: PortProviderProps) { }: PortProviderProps) {
const params = useParams(); const params = useParams();
const portSlugFromUrl = params?.portSlug as string | undefined; const portSlugFromUrl = params?.portSlug as string | undefined;
@@ -87,6 +94,7 @@ export function PortProvider({
currentPortId: currentPort?.id ?? null, currentPortId: currentPort?.id ?? null,
currentPortSlug: currentPort?.slug ?? null, currentPortSlug: currentPort?.slug ?? null,
tenanciesModuleByPort, tenanciesModuleByPort,
maintenanceModuleByPort,
}} }}
> >
{children} {children}
@@ -106,3 +114,13 @@ export function useTenanciesModuleEnabled(): boolean {
if (!currentPortId) return false; if (!currentPortId) return false;
return tenanciesModuleByPort[currentPortId] ?? false; return tenanciesModuleByPort[currentPortId] ?? false;
} }
/** Read the maintenance-module-enabled flag for the currently-active port.
* Defaults to ENABLED (true) when unset so the feature stays visible
* unless an admin has explicitly turned it off. Server-side resolved in
* the dashboard layout — synchronous read, no fetch latency/flicker. */
export function useMaintenanceModuleEnabled(): boolean {
const { currentPortId, maintenanceModuleByPort } = useContext(PortContext);
if (!currentPortId) return true;
return maintenanceModuleByPort[currentPortId] ?? true;
}