'use client'; import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, Plus, Trash2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { FileInputButton } from '@/components/ui/file-input-button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; 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'; type SignatoryRole = 'client' | 'developer' | 'rep' | 'witness' | 'cc'; interface SignatoryRow { name: string; email: string; role: SignatoryRole; } const ROLE_LABELS: Record = { client: 'Client', developer: 'Developer', rep: 'Rep', witness: 'Witness', cc: 'CC (no signing)', }; 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(null); const [title, setTitle] = useState(''); const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10)); // `null` means "rep hasn't touched the list yet - show the // derived-from-interest seed". Once edited (add/remove/change), // the explicit array takes over. Avoids a setState-in-effect that // the React Compiler bans. const [signatoriesOverride, setSignatoriesOverride] = useState(null); const [notes, setNotes] = useState(''); // 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. Also drives auto-fill on signatory rows tagged // role=client. const { data: interestData } = useQuery<{ data: { clientName: string | null; clientPrimaryEmail: string | null }; }>({ queryKey: ['interests', interestId], queryFn: () => apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>( `/api/v1/interests/${interestId}`, ), enabled: open, staleTime: 60_000, }); // Compute the effective signatory list - when the rep hasn't touched // anything, seed from the interest's client. Once they edit, the // explicit override takes over. const signatories: SignatoryRow[] = useMemo(() => { if (signatoriesOverride !== null) return signatoriesOverride; if (!interestData?.data) return []; return [ { name: interestData.data.clientName ?? '', email: interestData.data.clientPrimaryEmail ?? '', role: 'client' as const, }, ]; }, [signatoriesOverride, interestData]); 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); const cleanSignatories = signatories .map((s) => ({ name: s.name.trim(), email: s.email.trim(), role: s.role })) .filter((s) => s.name && s.email); if (cleanSignatories.length > 0) { form.append('signatories', JSON.stringify(cleanSignatories)); // Back-compat for any consumer that still reads signerNames. form.append('signerNames', cleanSignatories.map((s) => s.name).join(', ')); } 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(''); setSignatoriesOverride(null); setNotes(''); onOpenChange(false); onSuccess?.(); }, onError: (err: unknown) => { toastError(err, 'Upload failed'); }, }); return ( Upload externally-signed EOI 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.
setFile(files[0] ?? null)} />
setTitle(e.target.value)} placeholder={defaultTitle} className="mt-1" />

Leave blank to use the default shown above.

{signatories.map((s, i) => (
setSignatoriesOverride( signatories.map((row, idx) => idx === i ? { ...row, name: e.target.value } : row, ), ) } className="flex-1 min-w-0" /> setSignatoriesOverride( signatories.map((row, idx) => idx === i ? { ...row, email: e.target.value } : row, ), ) } className="flex-[2] min-w-0" />
))}

Recorded against the document for the audit trail and the signed-count badge. CC recipients aren't counted as signatories.