feat(uat-batch-6): external-EOI structured signatories + X/Y signed counter
Replace the freetext CSV signer-names field with a structured recipient editor (name / email / role per row). Service now persists each non-CC signatory as a `document_signers` row pre-stamped `status='signed'` so the document-detail "X / Y signed" badge counts correctly for manually-uploaded EOIs. - ExternalEoiInput gains a structured `signatories` field; legacy `signerNames` retained for back-compat. Role enum: `client | developer | rep | witness | cc`. - uploadExternallySignedEoi inserts `document_signers` rows for every non-CC entry inside the existing transaction. - documentEvents.completed event records both shapes for full audit fidelity. - POST /api/v1/interests/[id]/external-eoi parses the `signatories` JSON multipart field defensively; malformed payloads fall back to signerNames. - Dialog UI: per-row Name / Email / Role inputs with add / remove. Seeds from interest's clientName + clientPrimaryEmail via a signatoriesOverride/null pattern (React-Compiler safe — no setState-in-effect). tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,40 @@ export const POST = withAuth(
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Newer clients post a structured `signatories` JSON array. Parse
|
||||||
|
// safely and ignore malformed payloads (downstream falls back to
|
||||||
|
// signerNames).
|
||||||
|
const signatoriesRaw = (form.get('signatories') as string | null) ?? null;
|
||||||
|
let signatories:
|
||||||
|
| Array<{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: 'client' | 'developer' | 'rep' | 'witness' | 'cc';
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
|
if (signatoriesRaw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(signatoriesRaw) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
signatories = parsed
|
||||||
|
.map((row) => {
|
||||||
|
if (typeof row !== 'object' || row === null) return null;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
if (typeof r.name !== 'string' || typeof r.email !== 'string') return null;
|
||||||
|
const role = typeof r.role === 'string' ? r.role : 'client';
|
||||||
|
if (!['client', 'developer', 'rep', 'witness', 'cc'].includes(role)) return null;
|
||||||
|
return {
|
||||||
|
name: r.name.trim(),
|
||||||
|
email: r.email.trim(),
|
||||||
|
role: role as 'client' | 'developer' | 'rep' | 'witness' | 'cc',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((s): s is NonNullable<typeof s> => s !== null && !!s.name && !!s.email);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
const signedAtRaw = (form.get('signedAt') as string | null) ?? null;
|
const signedAtRaw = (form.get('signedAt') as string | null) ?? null;
|
||||||
const signedAt = signedAtRaw ? new Date(signedAtRaw) : undefined;
|
const signedAt = signedAtRaw ? new Date(signedAtRaw) : undefined;
|
||||||
if (signedAt && Number.isNaN(signedAt.getTime())) {
|
if (signedAt && Number.isNaN(signedAt.getTime())) {
|
||||||
@@ -53,6 +87,7 @@ export const POST = withAuth(
|
|||||||
title,
|
title,
|
||||||
signedAt,
|
signedAt,
|
||||||
signerNames,
|
signerNames,
|
||||||
|
signatories,
|
||||||
notes,
|
notes,
|
||||||
meta: {
|
meta: {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2, Upload } from 'lucide-react';
|
import { Loader2, Plus, Trash2, Upload } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -10,10 +10,33 @@ import { DatePicker } from '@/components/ui/date-picker';
|
|||||||
import { FileInputButton } from '@/components/ui/file-input-button';
|
import { FileInputButton } from '@/components/ui/file-input-button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { formatBerthRange } from '@/lib/templates/berth-range';
|
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<SignatoryRole, string> = {
|
||||||
|
client: 'Client',
|
||||||
|
developer: 'Developer',
|
||||||
|
rep: 'Rep',
|
||||||
|
witness: 'Witness',
|
||||||
|
cc: 'CC (no signing)',
|
||||||
|
};
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -35,21 +58,45 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10));
|
const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [signerNames, setSignerNames] = useState('');
|
// `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<SignatoryRow[] | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
// Fetched on open to power the default title:
|
// Fetched on open to power the default title:
|
||||||
// "External EOI — <Client> — <berth range> — YYYY-MM-DD". Without
|
// "External EOI — <Client> — <berth range> — YYYY-MM-DD". Without
|
||||||
// this the file lands as just "External EOI - <date>" which is
|
// this the file lands as just "External EOI - <date>" which is
|
||||||
// unscannable in any list when a port has multiple deals closing on
|
// unscannable in any list when a port has multiple deals closing on
|
||||||
// the same day.
|
// the same day. Also drives auto-fill on signatory rows tagged
|
||||||
const { data: interestData } = useQuery<{ data: { clientName: string | null } }>({
|
// role=client.
|
||||||
|
const { data: interestData } = useQuery<{
|
||||||
|
data: { clientName: string | null; clientPrimaryEmail: string | null };
|
||||||
|
}>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: { clientName: string | null } }>(`/api/v1/interests/${interestId}`),
|
apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>(
|
||||||
|
`/api/v1/interests/${interestId}`,
|
||||||
|
),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
staleTime: 60_000,
|
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 }> }>({
|
const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({
|
||||||
queryKey: ['interests', interestId, 'berths'],
|
queryKey: ['interests', interestId, 'berths'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -82,7 +129,14 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
const effectiveTitle = title.trim() || defaultTitle;
|
const effectiveTitle = title.trim() || defaultTitle;
|
||||||
if (effectiveTitle) form.append('title', effectiveTitle);
|
if (effectiveTitle) form.append('title', effectiveTitle);
|
||||||
if (signedAt) form.append('signedAt', signedAt);
|
if (signedAt) form.append('signedAt', signedAt);
|
||||||
if (signerNames) form.append('signerNames', signerNames);
|
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);
|
if (notes) form.append('notes', notes);
|
||||||
const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, {
|
const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -109,7 +163,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setSignerNames('');
|
setSignatoriesOverride(null);
|
||||||
setNotes('');
|
setNotes('');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@@ -160,16 +214,86 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
|
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Signer names (comma-separated)</Label>
|
<Label>Signatories</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{signatories.map((s, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
value={signerNames}
|
placeholder="Name"
|
||||||
onChange={(e) => setSignerNames(e.target.value)}
|
value={s.name}
|
||||||
placeholder="e.g. John Smith, Marina Director"
|
onChange={(e) =>
|
||||||
className="mt-1"
|
setSignatoriesOverride(
|
||||||
|
signatories.map((row, idx) =>
|
||||||
|
idx === i ? { ...row, name: e.target.value } : row,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex-1 min-w-0"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<Input
|
||||||
Recorded in the audit trail alongside the document.
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={s.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSignatoriesOverride(
|
||||||
|
signatories.map((row, idx) =>
|
||||||
|
idx === i ? { ...row, email: e.target.value } : row,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex-[2] min-w-0"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={s.role}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setSignatoriesOverride(
|
||||||
|
signatories.map((row, idx) =>
|
||||||
|
idx === i ? { ...row, role: v as SignatoryRole } : row,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36 shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(ROLE_LABELS) as SignatoryRole[]).map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>
|
||||||
|
{ROLE_LABELS[r]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
setSignatoriesOverride(signatories.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
aria-label="Remove signatory"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSignatoriesOverride([...signatories, { name: '', email: '', role: 'client' }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-4 mr-1.5" aria-hidden />
|
||||||
|
Add signatory
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Recorded against the document for the audit trail and the signed-count badge. CC
|
||||||
|
recipients aren't counted as signatories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { documents, documentEvents, files } from '@/lib/db/schema/documents';
|
import { documents, documentEvents, documentSigners, files } from '@/lib/db/schema/documents';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { buildStoragePath } from '@/lib/minio';
|
import { buildStoragePath } from '@/lib/minio';
|
||||||
@@ -21,6 +21,15 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|||||||
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
|
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
|
||||||
|
/** A single signatory on an externally-signed document. */
|
||||||
|
export interface ExternalSignatory {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
/** Drives auto-fill on the dialog + downstream "Email copy" recipient
|
||||||
|
* list. `cc` recipients aren't signers but show up on send-out lists. */
|
||||||
|
role: 'client' | 'developer' | 'rep' | 'witness' | 'cc';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExternalEoiInput {
|
export interface ExternalEoiInput {
|
||||||
interestId: string;
|
interestId: string;
|
||||||
portId: string;
|
portId: string;
|
||||||
@@ -30,8 +39,14 @@ export interface ExternalEoiInput {
|
|||||||
title?: string;
|
title?: string;
|
||||||
/** When the signing actually happened (the date on the paper / contract). */
|
/** When the signing actually happened (the date on the paper / contract). */
|
||||||
signedAt?: Date;
|
signedAt?: Date;
|
||||||
/** Names of the people who signed (free-text — we don't manage signer
|
/**
|
||||||
* identities for external sigs). Recorded in metadata. */
|
* Structured signatory list — preferred over the legacy `signerNames`
|
||||||
|
* CSV. When present, the service inserts one `document_signers` row
|
||||||
|
* per non-CC entry pre-stamped `status='signed'` so the
|
||||||
|
* "X / Y signed" badge renders correctly downstream.
|
||||||
|
*/
|
||||||
|
signatories?: ExternalSignatory[];
|
||||||
|
/** Legacy CSV form kept for backwards compat with older callers. */
|
||||||
signerNames?: string[];
|
signerNames?: string[];
|
||||||
/** Free-text note (e.g. "signed in person at boat show"). */
|
/** Free-text note (e.g. "signed in person at boat show"). */
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -123,14 +138,35 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill document_signers rows for the structured signatory list
|
||||||
|
// so the document-detail "X / Y signed" badge counts correctly. CC
|
||||||
|
// recipients aren't signatories — they're recipients of the email
|
||||||
|
// copy and don't show up in the signed-count denominator.
|
||||||
|
const signedAtMoment = input.signedAt ?? new Date();
|
||||||
|
const signerRows = (input.signatories ?? [])
|
||||||
|
.filter((s) => s.role !== 'cc')
|
||||||
|
.map((s, i) => ({
|
||||||
|
documentId: doc.id,
|
||||||
|
signerName: s.name,
|
||||||
|
signerEmail: s.email,
|
||||||
|
signerRole: s.role,
|
||||||
|
signingOrder: i + 1,
|
||||||
|
status: 'signed' as const,
|
||||||
|
signedAt: signedAtMoment,
|
||||||
|
}));
|
||||||
|
if (signerRows.length > 0) {
|
||||||
|
await tx.insert(documentSigners).values(signerRows);
|
||||||
|
}
|
||||||
|
|
||||||
await tx.insert(documentEvents).values({
|
await tx.insert(documentEvents).values({
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
eventType: 'completed',
|
eventType: 'completed',
|
||||||
eventData: {
|
eventData: {
|
||||||
isManualUpload: true,
|
isManualUpload: true,
|
||||||
external: true,
|
external: true,
|
||||||
signerNames: input.signerNames ?? [],
|
signerNames: input.signerNames ?? (input.signatories ?? []).map((s) => s.name),
|
||||||
signedAt: (input.signedAt ?? new Date()).toISOString(),
|
signatories: input.signatories ?? null,
|
||||||
|
signedAt: signedAtMoment.toISOString(),
|
||||||
fileId: fileRecord.id,
|
fileId: fileRecord.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user