Files
pn-new-crm/src/components/berths/berth-tabs.tsx
Matt Ciaccio 249ffe3e4a feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.

Schema (migration 0030_berth_pdf_versions):
  - new table `berth_pdf_versions` with monotonic `version_number` per
    berth, `storage_key` (renamed convention from §4.7a), sha256, size,
    `download_url_expires_at` cache slot for §11.1 signed-URL throttling,
    and `parse_results` jsonb for the audit trail.
  - new column `berths.current_pdf_version_id` (deferred from Phase 0)
    with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
  - relations + types exported from `schema/berths.ts`.

3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
  1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
     `mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
     fields, so this is defensive coverage for future templates.
  2. OCR via Tesseract.js — positional/regex heuristics keyed off the
     §9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
     `WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
     per-field confidence + global mean; flags imperial-vs-metric drift
     >1% in `warnings`.
  3. AI fallback — gated via `getResolvedOcrConfig()` (existing
     openai/claude provider). Surfaced from the diff dialog only when
     `shouldOfferAiTier()` returns true (mean OCR confidence below
     0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.

Service layer (`lib/services/berth-pdf.service.ts`):
  - `uploadBerthPdf()` — magic-byte check, size cap, version-number
    bump + current pointer in one transaction.
  - `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
    flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
    columns; warns on mooring-number-in-PDF mismatch (§14.6).
  - `applyParseResults()` — hard allowlist of writable columns;
    stamps `appliedFields` onto `parse_results` for audit.
  - `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
  - `listBerthPdfVersions()` — version list with 15-min signed URLs.
  - `getMaxUploadMb()` — port-override → global → default 15 lookup
    on `system_settings.berth_pdf_max_upload_mb`.

§14.6 critical mitigations:
  - Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
    storage object and rejects the request.
  - Size cap from `system_settings.berth_pdf_max_upload_mb` (default
    15 MB); enforced in the upload-url presign AND server-side.
  - 0-byte uploads rejected.
  - Mooring-number mismatch surfaces as a `warnings[]` entry on the
    reconcile result so the rep sees it in the diff dialog.
  - Imperial vs metric ±1% tolerance in both the parser warnings and
    the reconcile equality check.
  - Path traversal already blocked at the storage layer (Phase 6a).

API + UI:
  - `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
    HMAC-signed proxy URL (filesystem) sized to the per-port cap.
  - `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
    `backend.head()`, writes the row, bumps `current_pdf_version_id`.
  - `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
  - `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
  - `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
    rep-confirmed diff payload.
  - New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
    with current-PDF panel, version history, Replace PDF button, and
    `<PdfReconcileDialog>` for the auto-applied + conflicts UX.

System settings:
  - `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
    + server-side validation. Resolved port-override → global → default.

Tests:
  - `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
    feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
    drift warning, AI-tier gate.
  - `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
    pdf-lib AcroForm round-trip.
  - `tests/integration/berth-pdf-versions.test.ts` — upload, version-
    number bump, magic-byte rejection, reconcile auto-applied vs
    conflicts vs ±1% tolerance, mooring-number warning,
    applyParseResults allowlist enforcement, rollback semantics.

Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00

257 lines
8.7 KiB
TypeScript

'use client';
import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TagBadge } from '@/components/shared/tag-badge';
import { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab';
import { BerthInterestPulse } from './berth-interest-pulse';
import { BerthDocumentsTab } from './berth-documents-tab';
type BerthData = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: string | null;
priceCurrency: string;
bowFacing: string | null;
berthApproved: boolean | null;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
statusLastChangedReason: string | null;
statusLastModified: string | null;
tags: Array<{ id: string; name: string; color: string }>;
};
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value && value !== 0 && value !== false) return null;
// Mobile-first: stack vertically with label on top so long values
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
// From `sm` (>=640px) up: switch to the original two-column layout.
return (
<div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
</div>
);
}
function OverviewTab({ berth }: { berth: BerthData }) {
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
const fmt = (v: string | null, fractionDigits = 2): string | null => {
if (v == null || v === '') return null;
const n = Number(v);
if (Number.isNaN(n)) return v;
return n.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
});
};
const formatDim = (ft: string | null, m: string | null) => {
const parts = [];
const ftFmt = fmt(ft);
const mFmt = fmt(m);
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
const ftFmt = fmt(ft, 0);
const mFmt = fmt(m);
const parts: string[] = [];
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
const formatPower = (kw: string | null) => {
const v = fmt(kw, 0);
return v ? `${v} kW` : null;
};
const formatVoltage = (v: string | null) => {
const fv = fmt(v, 0);
return fv ? `${fv} V` : null;
};
const price = berth.price
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: berth.priceCurrency || 'USD',
maximumFractionDigits: 0,
}).format(Number(berth.price))
: null;
return (
<div className="space-y-6">
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
who's interested + how warm without clicking into the Interests tab. */}
<BerthInterestPulse berthId={berth.id} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Specifications */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
<SpecRow
label="Width"
value={
formatDim(berth.widthFt, berth.widthM)
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow
label="Nominal Boat Size"
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
/>
<SpecRow
label="Water Depth"
value={
berth.waterDepth || berth.waterDepthM
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Mooring Type" value={berth.mooringType} />
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
<SpecRow label="Bow Facing" value={berth.bowFacing} />
<SpecRow label="Access" value={berth.access} />
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
</CardContent>
</Card>
{/* Infrastructure & Pricing */}
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
<SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
<SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} />
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow
label="Tenure Type"
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
/>
{berth.tenureType === 'fixed_term' && (
<>
<SpecRow label="Years" value={berth.tenureYears} />
<SpecRow label="Start Date" value={berth.tenureStartDate} />
<SpecRow label="End Date" value={berth.tenureEndDate} />
</>
)}
<SpecRow label="Price" value={price} />
</CardContent>
</Card>
{berth.tags.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1.5">
{berth.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
function StubTab({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<p className="text-muted-foreground">{label} coming soon</p>
</div>
);
}
export function buildBerthTabs(berth: BerthData): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab berth={berth} />,
},
{
id: 'interests',
label: 'Interests',
content: <BerthInterestsTab berthId={berth.id} />,
},
{
id: 'reservations',
label: 'Reservations',
content: <BerthReservationsTab berthId={berth.id} />,
},
{
id: 'documents',
label: 'Documents',
content: <BerthDocumentsTab berthId={berth.id} />,
},
{
id: 'waiting-list',
label: 'Waiting List',
content: <StubTab label="Waiting List" />,
},
{
id: 'maintenance',
label: 'Maintenance Log',
content: <StubTab label="Maintenance Log" />,
},
{
id: 'activity',
label: 'Activity',
content: <StubTab label="Activity" />,
},
];
}