diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index c9776f0e..9addbeb7 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -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 = 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 = + Object.fromEntries(maintenanceModuleEntries); + return ( diff --git a/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts b/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts index 111f821a..6510dc56 100644 --- a/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts +++ b/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts @@ -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, diff --git a/src/app/api/v1/berths/[id]/maintenance/route.ts b/src/app/api/v1/berths/[id]/maintenance/route.ts index a8d1c0a2..b49c7a90 100644 --- a/src/app/api/v1/berths/[id]/maintenance/route.ts +++ b/src/app/api/v1/berths/[id]/maintenance/route.ts @@ -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, diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 2ed0bf20..c91c592f 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -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', diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index c9ddb422..83ce1d21 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -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({ queryKey: ['berth', berthId], @@ -86,7 +87,9 @@ export function BerthDetail({ berthId }: BerthDetailProps) { : null} - tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []} + tabs={ + berth ? buildBerthTabs(berth, { tenanciesModuleEnabled, maintenanceModuleEnabled }) : [] + } defaultTab="overview" /> {berth ? : null} diff --git a/src/components/berths/berth-documents-tab.tsx b/src/components/berths/berth-documents-tab.tsx index 07ed655c..5926400e 100644 --- a/src/components/berths/berth-documents-tab.tsx +++ b/src/components/berths/berth-documents-tab.tsx @@ -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: () => ( +
+ Loading PDF viewer… +
+ ), + }, +); + interface PdfVersionRow { id: string; versionNumber: number; @@ -53,6 +70,7 @@ interface UploadUrlResponse { export function BerthDocumentsTab({ berthId }: { berthId: string }) { const qc = useQueryClient(); const fileInputRef = useRef(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 }) { - + {isLoading ? (

Loading…

) : current ? ( -
- - {current.fileName} - - - v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB - - {current.parseEngine ? : null} -
+ <> +
+ + + v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB + + {current.parseEngine ? : null} + + + Download + +
+ {previewOpen ? ( +
+ +
+ ) : null} + ) : (

No PDF uploaded yet.

)} diff --git a/src/components/berths/berth-occupancy-chip.tsx b/src/components/berths/berth-occupancy-chip.tsx index 42d022a2..0b780e86 100644 --- a/src/components/berths/berth-occupancy-chip.tsx +++ b/src/components/berths/berth-occupancy-chip.tsx @@ -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 ( - 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)})`} - > - Under offer to: - {primary.clientName} - ( + + {stageLabel(stage)} + + ); + + // Single competing interest → the chip is a direct link to it. + if (competing.length === 1) { + return ( + e.stopPropagation()} + className={chipClass} + title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`} > - {stageLabel(primary.pipelineStage)} - - {extras > 0 ? +{extras} more : null} - + Under offer to: + {primary.clientName} + {stageChip(primary.pipelineStage)} + + ); + } + + // 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 ( + + + + + e.stopPropagation()}> +
+ {competing.length} interests competing for this berth +
+
    + {competing.map((r) => ( +
  • + e.stopPropagation()} + className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/60" + > + + {r.clientName} + {r.isInEoiBundle ? ( + · in EOI + ) : r.isPrimary ? ( + · primary + ) : null} + + {stageChip(r.pipelineStage)} + +
  • + ))} +
+
+
); } diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index 82c21877..f05a9fa0 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -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: , }); } - 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: , }, - { + ]; + if (opts.maintenanceModuleEnabled) { + tabs.push({ id: 'maintenance', label: 'Maintenance', content: , - }, - { - id: 'activity', - label: 'Activity', - content: ( - - ), - }, - ]; + }); + } + tabs.push({ + id: 'activity', + label: 'Activity', + content: ( + + ), + }); + return tabs; } diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx index be2131fd..5a3a5e34 100644 --- a/src/components/interests/linked-berths-list.tsx +++ b/src/components/interests/linked-berths-list.tsx @@ -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 ) : null} + {row.isSpecificInterest && row.statusOverrideMode === 'manual' ? ( + + + Pin overrides pitch + + ) : null} {dims ?
{dims}
: null} @@ -400,7 +410,11 @@ function LinkedBerthRowItem({

- {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}

diff --git a/src/lib/services/interest-berths.service.ts b/src/lib/services/interest-berths.service.ts index a2d08458..25434230 100644 --- a/src/lib/services/interest-berths.service.ts +++ b/src/lib/services/interest-berths.service.ts @@ -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, diff --git a/src/lib/services/maintenance-module.service.ts b/src/lib/services/maintenance-module.service.ts new file mode 100644 index 00000000..367ea532 --- /dev/null +++ b/src/lib/services/maintenance-module.service.ts @@ -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 { + 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 { + const enabled = await isMaintenanceModuleEnabled(portId); + if (!enabled) { + throw new NotFoundError('Maintenance module is not enabled for this port.'); + } +} diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index 72e0a361..6459d370 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -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 diff --git a/src/providers/port-provider.tsx b/src/providers/port-provider.tsx index 6b463926..09335109 100644 --- a/src/providers/port-provider.tsx +++ b/src/providers/port-provider.tsx @@ -16,6 +16,10 @@ interface PortContextValue { * Resolved server-side in the dashboard layout. Consumers read via * `useTenanciesModuleEnabled()`. */ tenanciesModuleByPort: Record; + /** Per-port Maintenance-module flag. Resolved server-side in the + * dashboard layout; consumers read via `useMaintenanceModuleEnabled()`. + * Defaults to enabled (unset port => true). */ + maintenanceModuleByPort: Record; } const PortContext = createContext({ @@ -24,6 +28,7 @@ const PortContext = createContext({ currentPortId: null, currentPortSlug: null, tenanciesModuleByPort: {}, + maintenanceModuleByPort: {}, }); interface PortProviderProps { @@ -31,6 +36,7 @@ interface PortProviderProps { ports: Port[]; defaultPortId: string | null; tenanciesModuleByPort?: Record; + maintenanceModuleByPort?: Record; } 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; +}