feat(interests): upload externally-signed EOI (paper / non-Documenso)
Sales reps need to file EOIs that were signed outside Documenso — on paper, in person at a boat show, or via an alternate e-sign vendor. Until now the EOI flow assumed Documenso was the only path. - external-eoi.service.uploadExternallySignedEoi creates BOTH the document row AND the signed-file record in one shot. Document is marked isManualUpload=true with status=completed and signedFileId set. Distinct from the existing uploadSignedManually which augments a document row that came from the Documenso pathway. - POST /api/v1/interests/[id]/external-eoi accepts multipart with the PDF + optional title + signedAt date + comma-separated signer names + free-text notes. Gated on documents.upload_signed permission. - Interest stage auto-advances to eoi_signed (only when the interest is currently at or before eoi_sent — past that, just file the doc). - The signing date, signer names, and any notes are captured into document_events.eventData + the audit_log metadata so the audit trail records who said the document was signed and when. - ExternalEoiUploadDialog renders a small modal: file picker, title override, signed-date (defaults to today), comma-separated signer names, notes. Wired into interest-detail-header behind an Upload icon button (gated on documents.upload_signed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
157
src/components/interests/external-eoi-upload-dialog.tsx
Normal file
157
src/components/interests/external-eoi-upload-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, 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 {
|
||||
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('');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!file) throw new Error('No file selected');
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (title) form.append('title', title);
|
||||
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' }));
|
||||
throw new Error(err.error ?? 'Upload failed');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('External EOI uploaded — interest advanced to EOI Signed');
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
qc.invalidateQueries({ queryKey: ['interests'] });
|
||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||
setFile(null);
|
||||
setTitle('');
|
||||
setSignerNames('');
|
||||
setNotes('');
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Upload failed');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload externally-signed EOI</DialogTitle>
|
||||
<DialogDescription>
|
||||
For EOIs signed outside Documenso (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="Defaults to 'External EOI - <date>'"
|
||||
className="mt-1"
|
||||
/>
|
||||
</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" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user