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

@@ -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,

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,
},
// ─── 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