feat(documenso-phase-4): recipient configurator + field placement UI
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.
Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
scanner. Uses pdfjs-dist to extract per-page text + positions, then
matches anchor patterns (Signature, Date, Initials, Email, Name,
underscore-runs) and produces percent-coordinate DetectedField
rows. Recipient label inference walks ±100pt of each match for
Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
PDF is image-only; UI falls back to manual placement without an
error. 6 unit tests pin the matching + coordinate math.
- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
endpoint that delegates to detectFields(). Permission-gated by
documents.send_for_signing.
- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
surfaces just the per-port developer + approver display name/email
+ sendMode flag. No secrets exposed; lets the dialog prefill the
recipient configurator without an admin-scoped settings read.
- components/documents/upload-for-signing-dialog.tsx — the Phase 4
UI. Three-step state machine inside a single Dialog:
1. select-file: drop/click PDF picker + title input
2. configure-recipients: client + developer + approver prefilled,
rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
3. place-fields: react-pdf renders the source PDF; auto-detect
runs in the background on file load and seeds the overlay;
rep places, drags, resizes, deletes, reassigns fields via the
palette + side panel. Native DOM drag (no dnd-kit dependency
added — the coordinate math stays obvious).
Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
service); success toast reflects port sendMode (auto fires the
invite immediately, manual leaves it for the rep).
Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
swap the ComingSoonDialog placeholder for the real
UploadForSigningDialog with the matching documentType prop. The
placeholder ComingSoonDialog helper is deleted from both.
- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
staged-only tsconfig so side-effect CSS imports (e.g.
react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
declare-module shim. Without this fix the staged compile reports
TS2882 even though the full tsc --noEmit pass passes.
Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
coordinate math is short enough that adding a drag library adds
complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
rep doesn't have to click a second button — empty result drops
back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).
Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.
Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean.
Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
||||
import { SigningProgress } from '@/components/documents/signing-progress';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -168,16 +169,16 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload-for-Documenso-signing dialog placeholder. The real
|
||||
dialog (PDF picker + recipient configurator + send button)
|
||||
is part of the larger custom-doc-upload service that's a
|
||||
follow-up. For now show a friendly "coming soon" card. */}
|
||||
{/* Phase 4 — upload-for-Documenso-signing dialog. Multi-step
|
||||
(file → recipients → fields → send) backed by the Phase 3
|
||||
service. Auto-detect runs after the file lands; rep can
|
||||
tweak placements before sending. */}
|
||||
{uploadForSigningOpen && (
|
||||
<ComingSoonDialog
|
||||
<UploadForSigningDialog
|
||||
open={uploadForSigningOpen}
|
||||
onOpenChange={setUploadForSigningOpen}
|
||||
title="Send contract for signing"
|
||||
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the contract externally, get it signed via paper or another tool, then upload the signed copy here."
|
||||
interestId={interestId}
|
||||
documentType="contract"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,44 +382,3 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for the upload-for-Documenso-signing flow until the
|
||||
* full upload + recipient + field-placement service is shipped.
|
||||
* Intentional dead-end so reps know the path exists rather than
|
||||
* misclicking and getting confusing behaviour.
|
||||
*/
|
||||
function ComingSoonDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<div
|
||||
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
||||
import { SigningProgress } from '@/components/documents/signing-progress';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -171,16 +172,13 @@ export function InterestReservationTab({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload-for-Documenso-signing dialog placeholder. The real
|
||||
dialog (PDF picker + recipient configurator + send button)
|
||||
is part of the larger custom-doc-upload service that's a
|
||||
follow-up. For now show a friendly "coming soon" card. */}
|
||||
{/* Phase 4 — upload-for-Documenso-signing dialog. */}
|
||||
{uploadForSigningOpen && (
|
||||
<ComingSoonDialog
|
||||
<UploadForSigningDialog
|
||||
open={uploadForSigningOpen}
|
||||
onOpenChange={setUploadForSigningOpen}
|
||||
title="Send reservation for signing"
|
||||
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the reservation externally, get it signed via paper or another tool, then upload the signed copy here."
|
||||
interestId={interestId}
|
||||
documentType="reservation_agreement"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -384,44 +382,3 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for the upload-for-Documenso-signing flow until the
|
||||
* full upload + recipient + field-placement service is shipped.
|
||||
* Intentional dead-end so reps know the path exists rather than
|
||||
* misclicking and getting confusing behaviour.
|
||||
*/
|
||||
function ComingSoonDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<div
|
||||
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user