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:
@@ -20,6 +20,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||
import { isExpensesModuleEnabled } from '@/lib/services/expenses-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 }) {
|
||||
const headerList = await headers();
|
||||
@@ -127,12 +128,29 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
const residentialModuleByPort: Record<string, boolean> =
|
||||
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 (
|
||||
<QueryProvider>
|
||||
<PortProvider
|
||||
ports={ports}
|
||||
defaultPortId={ports[0]?.id ?? null}
|
||||
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||
maintenanceModuleByPort={maintenanceModuleByPort}
|
||||
>
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
|
||||
@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { updateMaintenanceLogSchema } from '@/lib/validators/berths';
|
||||
import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service';
|
||||
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// PATCH /api/v1/berths/[id]/maintenance/[logId]
|
||||
export const PATCH = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
const body = await parseBody(req, updateMaintenanceLogSchema);
|
||||
const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
@@ -28,6 +30,7 @@ export const PATCH = withAuth(
|
||||
export const DELETE = withAuth(
|
||||
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
|
||||
@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { addMaintenanceLogSchema } from '@/lib/validators/berths';
|
||||
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
|
||||
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/[id]/maintenance
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
const logs = await getMaintenanceLogs(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: logs });
|
||||
} catch (error) {
|
||||
@@ -22,6 +24,7 @@ export const GET = withAuth(
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
const body = await parseBody(req, addMaintenanceLogSchema);
|
||||
const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -143,6 +143,10 @@ export interface InterestBerthWithDetails extends InterestBerth {
|
||||
mooringNumber: string | null;
|
||||
area: 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;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
@@ -169,6 +173,7 @@ export async function listBerthsForInterest(
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
statusOverrideMode: berths.statusOverrideMode,
|
||||
lengthFt: berths.lengthFt,
|
||||
widthFt: berths.widthFt,
|
||||
draftFt: berths.draftFt,
|
||||
|
||||
59
src/lib/services/maintenance-module.service.ts
Normal file
59
src/lib/services/maintenance-module.service.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -640,6 +640,23 @@ export const REGISTRY: SettingEntry[] = [
|
||||
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 ─────────────────────────────────────────
|
||||
// Port-scoped gate for the standalone `/invoices` flow. Audit conclusion
|
||||
// (2026-05-27, Initiative 1c): the schema is rich (invoices + invoice_line_items
|
||||
|
||||
@@ -16,6 +16,10 @@ interface PortContextValue {
|
||||
* Resolved server-side in the dashboard layout. Consumers read via
|
||||
* `useTenanciesModuleEnabled()`. */
|
||||
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>({
|
||||
@@ -24,6 +28,7 @@ const PortContext = createContext<PortContextValue>({
|
||||
currentPortId: null,
|
||||
currentPortSlug: null,
|
||||
tenanciesModuleByPort: {},
|
||||
maintenanceModuleByPort: {},
|
||||
});
|
||||
|
||||
interface PortProviderProps {
|
||||
@@ -31,6 +36,7 @@ interface PortProviderProps {
|
||||
ports: Port[];
|
||||
defaultPortId: string | null;
|
||||
tenanciesModuleByPort?: Record<string, boolean>;
|
||||
maintenanceModuleByPort?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function PortProvider({
|
||||
@@ -38,6 +44,7 @@ export function PortProvider({
|
||||
ports,
|
||||
defaultPortId,
|
||||
tenanciesModuleByPort = {},
|
||||
maintenanceModuleByPort = {},
|
||||
}: PortProviderProps) {
|
||||
const params = useParams();
|
||||
const portSlugFromUrl = params?.portSlug as string | undefined;
|
||||
@@ -87,6 +94,7 @@ export function PortProvider({
|
||||
currentPortId: currentPort?.id ?? null,
|
||||
currentPortSlug: currentPort?.slug ?? null,
|
||||
tenanciesModuleByPort,
|
||||
maintenanceModuleByPort,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -106,3 +114,13 @@ export function useTenanciesModuleEnabled(): boolean {
|
||||
if (!currentPortId) return 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user