diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 435306e6..2ddd66fe 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, Upload } from 'lucide-react'; import { toast } from 'sonner'; @@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; +import { formatBerthRange } from '@/lib/templates/berth-range'; import { Dialog, DialogContent, @@ -34,12 +36,49 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc const [signerNames, setSignerNames] = useState(''); const [notes, setNotes] = useState(''); - const mutation = useMutation({ + // Fetched on open to power the default title: + // "External EOI — — YYYY-MM-DD". Without + // this the file lands as just "External EOI - " which is + // unscannable in any list when a port has multiple deals closing on + // the same day. + const { data: interestData } = useQuery<{ data: { clientName: string | null } }>({ + queryKey: ['interests', interestId], + queryFn: () => + apiFetch<{ data: { clientName: string | null } }>(`/api/v1/interests/${interestId}`), + enabled: open, + staleTime: 60_000, + }); + const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({ + queryKey: ['interests', interestId, 'berths'], + queryFn: () => + apiFetch<{ data: Array<{ mooringNumber: string | null }> }>( + `/api/v1/interests/${interestId}/berths`, + ), + enabled: open, + staleTime: 60_000, + }); + + const defaultTitle = useMemo(() => { + const date = signedAt || new Date().toISOString().slice(0, 10); + const moorings = (berthsData?.data ?? []) + .map((b) => b.mooringNumber) + .filter((m): m is string => !!m); + const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null; + const clientName = interestData?.data?.clientName ?? null; + const parts = ['External EOI']; + if (clientName) parts.push(clientName); + if (berthLabel) parts.push(berthLabel); + parts.push(date); + return parts.join(' — '); + }, [interestData, berthsData, signedAt]); + + const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({ mutationFn: async () => { if (!file) throw new Error('No file selected'); const form = new FormData(); form.append('file', file); - if (title) form.append('title', title); + const effectiveTitle = title.trim() || defaultTitle; + if (effectiveTitle) form.append('title', effectiveTitle); if (signedAt) form.append('signedAt', signedAt); if (signerNames) form.append('signerNames', signerNames); if (notes) form.append('notes', notes); @@ -54,10 +93,15 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc }; throw new Error(err.error ?? 'Upload failed'); } - return res.json(); + return (await res.json()) as { data?: { stageChanged?: boolean } }; }, - onSuccess: () => { - toast.success('External EOI uploaded — interest advanced to EOI Signed'); + onSuccess: (response) => { + const stageChanged = response?.data?.stageChanged === true; + toast.success( + stageChanged + ? 'External EOI uploaded. Stage advanced to EOI Signed.' + : 'External EOI uploaded. Filed against this deal (stage unchanged).', + ); qc.invalidateQueries({ queryKey: ['interests', interestId] }); qc.invalidateQueries({ queryKey: ['interests'] }); qc.invalidateQueries({ queryKey: ['documents'] }); @@ -100,9 +144,12 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc setTitle(e.target.value)} - placeholder="Defaults to 'External EOI - '" + placeholder={defaultTitle} className="mt-1" /> +

+ Leave blank to use the default shown above. +

diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 7f4c58d0..79d5853e 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -26,6 +26,7 @@ import { EoiCancelDialog } from '@/components/documents/eoi-cancel-dialog'; import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog'; import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog'; import { SigningProgress } from '@/components/documents/signing-progress'; +import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -103,6 +104,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { const portSlug = useUIStore((s) => s.currentPortSlug); const [generateOpen, setGenerateOpen] = useState(false); const [uploadSignedOpen, setUploadSignedOpen] = useState(false); + // Lifted preview state so the View button on every signed-PDF row opens + // the in-app preview dialog rather than navigating to a presigned URL + // (which the storage backend serves with Content-Disposition=attachment, + // forcing a download even when the rep just wants to inspect the PDF). + const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null); const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({ queryKey: ['documents', { interestId, documentType: 'eoi' }], @@ -125,6 +131,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { doc={activeDoc} portSlug={portSlug ?? null} onUploadSigned={() => setUploadSignedOpen(true)} + onView={(id, name) => setPreviewFile({ id, name })} /> ) : ( {new Date(d.createdAt).toLocaleDateString()} - {d.signedFileId ? : null} + {d.signedFileId ? ( + setPreviewFile({ id, name })} + /> + ) : null} {portSlug && ( + + { + if (!o) setPreviewFile(null); + }} + fileId={previewFile?.id} + fileName={previewFile?.name} + />
); } @@ -193,10 +215,12 @@ function ActiveEoiCard({ doc, portSlug, onUploadSigned, + onView, }: { doc: DocumentRow; portSlug: string | null; onUploadSigned: () => void; + onView: (fileId: string, fileName?: string) => void; }) { const queryClient = useQueryClient(); const { confirm, dialog: confirmDialog } = useConfirmation(); @@ -399,7 +423,7 @@ function ActiveEoiCard({

Signed document

- + @@ -581,22 +605,26 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) { } /** - * View + Download buttons for a signed PDF. `/api/v1/files/[id]/download` - * returns a presigned URL in JSON (rather than streaming the file), so - * we fetch the URL via `apiFetch` and then either open it in a new tab - * (View) or trigger a programmatic download (Download). + * View + Download buttons for a signed PDF. View opens the in-app + * preview dialog (lifted to the parent so a single dialog instance + * serves every row); Download fetches the presigned URL and triggers a + * filename-preserving download via the shared helper. */ -function SignedPdfActions({ fileId }: { fileId: string }) { - const open = async (mode: 'view' | 'download') => { +function SignedPdfActions({ + fileId, + title, + onView, +}: { + fileId: string; + title?: string; + onView: (fileId: string, fileName?: string) => void; +}) { + const handleDownload = async () => { try { const res = await apiFetch<{ data: { url: string; filename: string } }>( `/api/v1/files/${fileId}/download`, ); - if (mode === 'view') { - window.open(res.data.url, '_blank', 'noopener,noreferrer'); - } else { - triggerUrlDownload(res.data.url, res.data.filename); - } + triggerUrlDownload(res.data.url, res.data.filename); } catch (err) { toastError(err, 'Failed to fetch signed PDF'); } @@ -605,14 +633,14 @@ function SignedPdfActions({ fileId }: { fileId: string }) { <>