fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override

Tackles the linked B4 #5 findings on the external-EOI flow. Item (e)
[Edit metadata affordance per row] is deferred to a later wave so it
can share infra with the broader signing-flow rework.

- (a) lying toast: uploadExternallySignedEoi now returns
  { stageChanged, newStage }. Client toasts conditionally so a
  Reservation+ deal that uploads paper-signing evidence no longer
  claims the stage advanced.
- (b) View downloads instead of previewing: SignedPdfActions takes an
  onView callback; InterestEoiTab lifts a single FilePreviewDialog and
  passes the callback down. Click-View opens the in-app preview rather
  than the presigned URL (which the storage backend served as
  attachment).
- (c) UUID filename on download: getDownloadUrl now passes the
  canonical filename through presignDownloadUrl; S3 backend adds a
  response-content-disposition override (filename + UTF-8 filename*)
  to the presign. Filesystem backend already passed it through.
- (d) Discarded dateEoiSigned: external-eoi service splits document-
  metadata writes (always — dateEoiSigned, eoiStatus='signed') from
  stage advance (gated on past-EOI). Also fires
  evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI
  is filed manually.
- Default title for external-EOI dialog now derives
  "External EOI — <Client> — <berth range> — <date>" via the existing
  formatBerthRange helper; rep can override.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:01:35 +02:00
parent abbaf406ab
commit 6cdb9af6b2
5 changed files with 170 additions and 48 deletions

View File

@@ -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 — <Client> — <berth range> — YYYY-MM-DD". Without
// this the file lands as just "External EOI - <date>" 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
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Defaults to 'External EOI - <date>'"
placeholder={defaultTitle}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use the default shown above.
</p>
</div>
<div>
<Label>Date signed</Label>

View File

@@ -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 })}
/>
) : (
<EmptyEoiState
@@ -154,7 +161,13 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{d.signedFileId ? <SignedPdfActions fileId={d.signedFileId} /> : null}
{d.signedFileId ? (
<SignedPdfActions
fileId={d.signedFileId}
title={d.title}
onView={(id, name) => setPreviewFile({ id, name })}
/>
) : null}
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -183,6 +196,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(o) => {
if (!o) setPreviewFile(null);
}}
fileId={previewFile?.id}
fileName={previewFile?.name}
/>
</div>
);
}
@@ -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({
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed document
</h3>
<SignedPdfActions fileId={doc.signedFileId} />
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
</div>
<SignedPdfPreview fileId={doc.signedFileId} />
</div>
@@ -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 }) {
<>
<button
type="button"
onClick={() => open('view')}
onClick={() => onView(fileId, title)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Eye className="size-3" aria-hidden /> View
</button>
<button
type="button"
onClick={() => open('download')}
onClick={handleDownload}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Download className="size-3" aria-hidden /> Download