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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user