/** * Documents tab on the berth detail page (Phase 6b — see plan §5.6). * * Sections: * - Current PDF panel (download link, "Replace PDF" button, parse-engine chip). * - Version history list — newest first, with rollback affordance on every * non-current row. * - Reconcile-diff dialog (PdfReconcileDialog), opened after a successful * upload + parse. Shows auto-applied vs conflicted fields and lets the * rep accept the conflict resolution. * * The actual upload is split in two steps: * 1. POST /pdf-upload-url -> presigned URL + storageKey * 2. PUT the file to that URL (multipart for filesystem-proxy mode, signed * PUT for S3 mode) * 3. POST /pdf-versions with the storage key + parse results */ 'use client'; import { useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api/client'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { PdfReconcileDialog } from './pdf-reconcile-dialog'; interface PdfVersionRow { id: string; versionNumber: number; fileName: string; fileSizeBytes: number; uploadedBy: string; uploadedAt: string; isCurrent: boolean; downloadUrl: string; downloadUrlExpiresAt: string; parseEngine: 'acroform' | 'ocr' | 'ai' | null; } interface UploadUrlResponse { url: string; method: 'PUT' | 'POST'; storageKey: string; maxBytes: number; backend: 's3' | 'filesystem'; } export function BerthDocumentsTab({ berthId }: { berthId: string }) { const qc = useQueryClient(); const fileInputRef = useRef(null); const [pendingDiff, setPendingDiff] = useState<{ versionId: string; autoApplied: Array<{ field: string; value: string | number }>; conflicts: Array<{ field: string; crmValue: string | number | null; pdfValue: string | number | null; pdfConfidence: number; }>; warnings: string[]; } | null>(null); const { data: versions, isLoading } = useQuery({ queryKey: ['berth-pdf-versions', berthId], queryFn: () => apiFetch<{ data: PdfVersionRow[] }>(`/api/v1/berths/${berthId}/pdf-versions`).then( (r) => r.data, ), }); const rollback = useMutation({ mutationFn: (versionId: string) => apiFetch(`/api/v1/berths/${berthId}/pdf-versions/${versionId}/rollback`, { method: 'POST', }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['berth-pdf-versions', berthId] }); void qc.invalidateQueries({ queryKey: ['berth', berthId] }); toast.success('Rolled back to selected version.'); }, onError: (err: Error) => { toast.error('Rollback failed', { description: err.message }); }, }); const upload = useMutation({ mutationFn: async (file: File) => { // 1. ask the server for a presigned upload URL const upRes = await apiFetch<{ data: UploadUrlResponse }>( `/api/v1/berths/${berthId}/pdf-upload-url`, { method: 'POST', body: { fileName: file.name, sizeBytes: file.size }, }, ); const { url, method, storageKey, maxBytes } = upRes.data; if (file.size > maxBytes) { throw new Error( `File ${(file.size / 1024 / 1024).toFixed(1)} MB exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit`, ); } // 2. upload directly to storage (filesystem-proxy or S3) const putRes = await fetch(url, { method, body: file, headers: { 'content-type': 'application/pdf' }, credentials: url.startsWith('/') ? 'include' : 'omit', }); if (!putRes.ok) { throw new Error(`Storage PUT failed (${putRes.status})`); } // 3. compute sha256 in the browser for the metadata row const sha256 = await sha256Hex(file); // 4. register the version metadata + parse server-side. The server // runs parseBerthPdf via the buffer from storage; the client // doesn't ship the raw PDF a second time. const verRes = await apiFetch<{ data: { versionId: string } }>( `/api/v1/berths/${berthId}/pdf-versions`, { method: 'POST', body: { storageKey, fileName: file.name, fileSizeBytes: file.size, sha256, }, }, ); return { versionId: verRes.data.versionId }; }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['berth-pdf-versions', berthId] }); void qc.invalidateQueries({ queryKey: ['berth', berthId] }); toast.success('PDF uploaded.'); }, onError: (err: Error) => { toast.error('Upload failed', { description: err.message }); }, }); const onFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!file.name.toLowerCase().endsWith('.pdf')) { toast.error('Only PDFs are accepted.'); return; } upload.mutate(file); if (fileInputRef.current) fileInputRef.current.value = ''; }; const current = versions?.find((v) => v.isCurrent); const others = versions?.filter((v) => !v.isCurrent) ?? []; return (
Current PDF
{isLoading ? (

Loading…

) : current ? (
{current.fileName} v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB {current.parseEngine ? : null}
) : (

No PDF uploaded yet.

)}
Version history {others.length === 0 ? (

No prior versions.

) : (
    {others.map((v) => (
  • {v.fileName} {' '} v{v.versionNumber} · {(v.fileSizeBytes / 1024 / 1024).toFixed(2)} MB ·{' '} {new Date(v.uploadedAt).toLocaleDateString()}
  • ))}
)}
{pendingDiff ? ( setPendingDiff(null)} /> ) : null}
); } function ParseEngineBadge({ engine }: { engine: 'acroform' | 'ocr' | 'ai' }) { const tone = engine === 'acroform' ? 'default' : engine === 'ocr' ? 'secondary' : 'outline'; const label = engine === 'acroform' ? 'AcroForm' : engine === 'ocr' ? 'OCR' : 'AI'; return {label}; } async function sha256Hex(file: File): Promise { const buf = await file.arrayBuffer(); const hash = await crypto.subtle.digest('SHA-256', buf); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); }