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>
208 lines
7.1 KiB
TypeScript
208 lines
7.1 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2, Upload } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
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,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (next: boolean) => void;
|
|
interestId: string;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSuccess }: Props) {
|
|
const qc = useQueryClient();
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [title, setTitle] = useState('');
|
|
const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10));
|
|
const [signerNames, setSignerNames] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
|
|
// 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);
|
|
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);
|
|
const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, {
|
|
method: 'POST',
|
|
body: form,
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
const err = (await res.json().catch(() => ({ error: 'Upload failed' }))) as {
|
|
error?: string;
|
|
};
|
|
throw new Error(err.error ?? 'Upload failed');
|
|
}
|
|
return (await res.json()) as { data?: { stageChanged?: boolean } };
|
|
},
|
|
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'] });
|
|
setFile(null);
|
|
setTitle('');
|
|
setSignerNames('');
|
|
setNotes('');
|
|
onOpenChange(false);
|
|
onSuccess?.();
|
|
},
|
|
onError: (err: unknown) => {
|
|
toastError(err, 'Upload failed');
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Upload externally-signed EOI</DialogTitle>
|
|
<DialogDescription>
|
|
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor).
|
|
The uploaded PDF is filed against this interest and the pipeline stage is advanced to
|
|
EOI Signed.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 py-2">
|
|
<div>
|
|
<Label>PDF file *</Label>
|
|
<Input
|
|
type="file"
|
|
accept="application/pdf,.pdf"
|
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Title (optional)</Label>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
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>
|
|
<Input
|
|
type="date"
|
|
value={signedAt}
|
|
onChange={(e) => setSignedAt(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Signer names (comma-separated)</Label>
|
|
<Input
|
|
value={signerNames}
|
|
onChange={(e) => setSignerNames(e.target.value)}
|
|
placeholder="e.g. John Smith, Marina Director"
|
|
className="mt-1"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Recorded in the audit trail alongside the document.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label>Notes (optional)</Label>
|
|
<Textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Where / how this EOI was signed"
|
|
rows={2}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={mutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => mutation.mutate()} disabled={!file || mutation.isPending}>
|
|
{mutation.isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" aria-hidden />
|
|
) : (
|
|
<Upload className="h-3.5 w-3.5 mr-1.5" aria-hidden />
|
|
)}
|
|
Upload
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|