feat(documents): edit-metadata UI for externally-uploaded EOIs

External-EOI uploads previously had no edit path. Once the rep clicked
Upload, the recorded title / signed-date / signatories / notes were
stuck. Fixing a misspelled signer name or a wrong signing date meant
re-uploading the whole document.

- New service helper `updateExternalEoiMetadata` patches:
    documents.title, documents.notes
    interests.dateEoiSigned (when signedAt changes)
    document_signers (full-replacement by id-presence: rows with an id
      are UPDATEd, rows without are INSERTed, existing rows whose id
      isn't in the array are DELETEd)
  Mirrors the upload-time invariants. CC rows are stored but excluded
  from the X/Y signed count; non-CC rows pre-stamp `status='signed'`
  with the effective signedAt. Refuses to touch Documenso-managed docs
  (vendor owns their signer rows) or non-EOI types (form shape isn't
  widened yet) with ConflictError.

- `PATCH /api/v1/documents/[id]/metadata` route uses strict zod schema
  + documents.edit permission. 204 on success; service throws surface
  as the normal errorResponse mapping.

- `<ExternalEoiEditDialog>` mirrors the upload-dialog's signatory
  affordance (name + email + role + add/remove) plus title / signed
  date / notes. Title is required; remove rows via the trash icon.

- Document detail page gains an "Edit metadata" button (Pencil icon)
  that renders only when `isManualUpload && documentType === 'eoi'`.
  Initial signing date derives from the earliest stamped signer's
  signedAt to match what the upload service writes.

- Trails the edit in document_events as `metadata_updated` so the
  activity timeline distinguishes upload-time vs edit-time changes.

Dialog state is initialised once per mount; the parent only renders the
dialog while open so each open is a fresh mount (avoids
setState-in-effect re-hydration banned by lint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:34:19 +02:00
parent 7881da675b
commit 235e0645cb
4 changed files with 553 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ import {
Eye,
FileText,
Mail,
Pencil,
Send,
Trash2,
UserPlus,
@@ -32,6 +33,7 @@ import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { SigningProgress } from '@/components/documents/signing-progress';
import { ExternalEoiEditDialog } from '@/components/documents/external-eoi-edit-dialog';
import {
Select,
SelectContent,
@@ -62,6 +64,8 @@ interface DetailDoc {
companyId: string | null;
createdAt: string;
createdBy: string;
isManualUpload: boolean;
notes: string | null;
}
interface DetailSigner {
@@ -130,6 +134,7 @@ interface DocumentDetailProps {
export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
const router = useRouter();
const [isCancelling, setIsCancelling] = useState(false);
const [editingMetadata, setEditingMetadata] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading, error } = useQuery<DetailResponse>({
@@ -303,6 +308,11 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</Button>
</>
) : null}
{doc.isManualUpload && doc.documentType === 'eoi' ? (
<Button size="sm" variant="outline" onClick={() => setEditingMetadata(true)}>
<Pencil className="mr-1.5 h-4 w-4" aria-hidden /> Edit metadata
</Button>
) : null}
{isInFlight ? (
<Button size="sm" variant="outline" onClick={handleCancel} disabled={isCancelling}>
<X className="mr-1.5 h-4 w-4" /> Cancel
@@ -364,6 +374,33 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
</div>
{confirmDialog}
{doc.isManualUpload && doc.documentType === 'eoi' && editingMetadata ? (
<ExternalEoiEditDialog
open
onOpenChange={setEditingMetadata}
documentId={doc.id}
initial={{
title: doc.title,
// Use the earliest signedAt across signers as the canonical date
// (matches the upload service's `signedAtMoment` semantics). Falls
// back to the doc's createdAt slice if no signer is stamped.
signedAt: (() => {
const stamps = signers
.map((s) => s.signedAt)
.filter((d): d is string => !!d)
.sort();
return (stamps[0] ?? doc.createdAt).slice(0, 10);
})(),
notes: doc.notes ?? '',
signatories: signers.map((s) => ({
id: s.id,
name: s.signerName,
email: s.signerEmail,
role: (s.signerRole as 'client' | 'developer' | 'rep' | 'witness' | 'cc') ?? 'client',
})),
}}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,256 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Save, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
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';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type SignatoryRole = 'client' | 'developer' | 'rep' | 'witness' | 'cc';
const ROLE_LABELS: Record<SignatoryRole, string> = {
client: 'Client',
developer: 'Developer',
rep: 'Rep',
witness: 'Witness',
cc: 'CC (no signing)',
};
interface SignatoryRow {
/** Existing rows carry the document_signers.id; new rows are added without
* one and the service treats them as inserts. */
id?: string;
name: string;
email: string;
role: SignatoryRole;
}
interface InitialState {
title: string;
/** YYYY-MM-DD slice — the DatePicker treats it as ISO date. */
signedAt: string;
notes: string;
signatories: SignatoryRow[];
}
interface Props {
open: boolean;
onOpenChange: (next: boolean) => void;
documentId: string;
initial: InitialState;
}
/**
* Edits an existing external-EOI document's metadata (title, signed
* date, notes, signatories). Backed by `PATCH /api/v1/documents/[id]/metadata`,
* which refuses on Documenso-managed docs — so the caller (detail page)
* already gates rendering on `isManualUpload`.
*
* Mirrors the upload-side dialog's signatory shape so the on-screen
* affordances feel identical to the rep.
*/
export function ExternalEoiEditDialog({ open, onOpenChange, documentId, initial }: Props) {
// State is initialised once per mount; the parent guarantees a fresh
// mount on every open by only rendering this component when the
// dialog is open. That avoids a setState-in-effect re-hydration
// pattern (banned by lint) — the dialog's open lifecycle IS the
// initialisation trigger.
const qc = useQueryClient();
const [title, setTitle] = useState(initial.title);
const [signedAt, setSignedAt] = useState(initial.signedAt);
const [notes, setNotes] = useState(initial.notes);
const [signatories, setSignatories] = useState<SignatoryRow[]>(initial.signatories);
const mutation = useMutation({
mutationFn: async () => {
const trimmed = signatories.map((s) => ({
...(s.id ? { id: s.id } : {}),
name: s.name.trim(),
email: s.email.trim(),
role: s.role,
}));
// Block invalid rows client-side so the rep doesn't get a generic
// 400 they have to map back to a row.
const invalid = trimmed.find((s) => !s.name || !s.email);
if (invalid) {
throw new Error('Every signatory needs a name + email.');
}
await apiFetch(`/api/v1/documents/${documentId}/metadata`, {
method: 'PATCH',
body: {
title: title.trim(),
signedAt,
notes,
signatories: trimmed,
},
});
},
onSuccess: () => {
toast.success('Metadata updated');
void qc.invalidateQueries({ queryKey: ['document-detail', documentId] });
onOpenChange(false);
},
onError: (err) => {
toastError(err);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit document metadata</DialogTitle>
<DialogDescription>
Adjust the title, signing date, notes, and signatories on this externally-signed EOI.
The PDF itself stays put. This only edits the recorded metadata.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="edit-eoi-title">Title</Label>
<Input
id="edit-eoi-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Date signed</Label>
<div className="mt-1">
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
</div>
</div>
<div className="space-y-2">
<Label>Signatories</Label>
<div className="space-y-2">
{signatories.map((s, i) => (
<div key={s.id ?? `new-${i}`} className="flex gap-2 items-center">
<Input
placeholder="Name"
value={s.name}
onChange={(e) =>
setSignatories((rows) =>
rows.map((r, idx) => (idx === i ? { ...r, name: e.target.value } : r)),
)
}
className="flex-1 min-w-0"
/>
<Input
type="email"
placeholder="email@example.com"
value={s.email}
onChange={(e) =>
setSignatories((rows) =>
rows.map((r, idx) => (idx === i ? { ...r, email: e.target.value } : r)),
)
}
className="flex-[2] min-w-0"
/>
<Select
value={s.role}
onValueChange={(v) =>
setSignatories((rows) =>
rows.map((r, idx) => (idx === i ? { ...r, role: v as SignatoryRole } : r)),
)
}
>
<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={() => setSignatories((rows) => rows.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={() =>
setSignatories((rows) => [...rows, { name: '', email: '', role: 'client' }])
}
>
<Plus className="size-4 mr-1.5" aria-hidden />
Add signatory
</Button>
<p className="text-xs text-muted-foreground">
CC entries are recorded but excluded from the X / Y signed badge.
</p>
</div>
<div>
<Label htmlFor="edit-eoi-notes">Notes</Label>
<Textarea
id="edit-eoi-notes"
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={mutation.isPending || !title.trim()}>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" aria-hidden />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" aria-hidden />
)}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}