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>
2026-05-13 14:03:27 +02:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
|
import { Document, Page, pdfjs } from 'react-pdf';
|
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import {
|
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Pen,
|
|
|
|
|
|
Calendar as CalendarIcon,
|
|
|
|
|
|
Type,
|
|
|
|
|
|
CheckSquare,
|
|
|
|
|
|
Mail,
|
|
|
|
|
|
User as UserIcon,
|
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
|
|
|
|
|
|
|
|
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
|
|
|
|
|
import 'react-pdf/dist/Page/TextLayer.css';
|
|
|
|
|
|
|
|
|
|
|
|
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Phase 4 — Upload-for-Documenso-signing dialog.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Four-step flow inside one dialog:
|
|
|
|
|
|
* 1. select-file — drag/drop or click to upload a PDF
|
|
|
|
|
|
* 2. configure-recipients — name/email/role per signer, with
|
|
|
|
|
|
* client + developer + approver prefilled from port + interest
|
|
|
|
|
|
* 3. place-fields — render the PDF page-by-page, run
|
|
|
|
|
|
* auto-detect, let the rep drag/place/delete fields per signer
|
|
|
|
|
|
* 4. sending — POST to /upload-for-signing, show spinner
|
|
|
|
|
|
*
|
|
|
|
|
|
* The implementation is intentionally compact — the field-overlay
|
|
|
|
|
|
* uses native DOM drag rather than dnd-kit so the coordinate math
|
|
|
|
|
|
* stays obvious. Auto-detect lives on the server (uses pdfjs-dist) so
|
|
|
|
|
|
* the same parser ships once.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
interface Recipient {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
email: string;
|
|
|
|
|
|
role: 'SIGNER' | 'APPROVER' | 'CC';
|
|
|
|
|
|
signingOrder: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type FieldType =
|
|
|
|
|
|
| 'SIGNATURE'
|
|
|
|
|
|
| 'FREE_SIGNATURE'
|
|
|
|
|
|
| 'INITIALS'
|
|
|
|
|
|
| 'DATE'
|
|
|
|
|
|
| 'EMAIL'
|
|
|
|
|
|
| 'NAME'
|
|
|
|
|
|
| 'TEXT'
|
|
|
|
|
|
| 'NUMBER'
|
|
|
|
|
|
| 'CHECKBOX'
|
|
|
|
|
|
| 'DROPDOWN'
|
|
|
|
|
|
| 'RADIO';
|
|
|
|
|
|
|
|
|
|
|
|
interface PlacedField {
|
|
|
|
|
|
/** Client-side id only — server doesn't see this. */
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
type: FieldType;
|
|
|
|
|
|
recipientIndex: number;
|
|
|
|
|
|
pageNumber: number;
|
|
|
|
|
|
/** All 0..100 percent of page dimensions. */
|
|
|
|
|
|
pageX: number;
|
|
|
|
|
|
pageY: number;
|
|
|
|
|
|
pageWidth: number;
|
|
|
|
|
|
pageHeight: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface DetectedFieldResponse {
|
|
|
|
|
|
type: FieldType;
|
|
|
|
|
|
pageNumber: number;
|
|
|
|
|
|
pageX: number;
|
|
|
|
|
|
pageY: number;
|
|
|
|
|
|
pageWidth: number;
|
|
|
|
|
|
pageHeight: number;
|
|
|
|
|
|
confidence: number;
|
|
|
|
|
|
anchorText?: string;
|
|
|
|
|
|
inferredRecipientLabel?: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SigningDefaults {
|
|
|
|
|
|
developer: { name: string; email: string; label: string };
|
|
|
|
|
|
approver: { name: string; email: string; label: string };
|
|
|
|
|
|
sendMode: 'auto' | 'manual';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const FIELD_DEFAULTS: Record<
|
|
|
|
|
|
FieldType,
|
|
|
|
|
|
{ widthPct: number; heightPct: number; label: string; icon: typeof Pen }
|
|
|
|
|
|
> = {
|
|
|
|
|
|
SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Signature', icon: Pen },
|
|
|
|
|
|
FREE_SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Free signature', icon: Pen },
|
|
|
|
|
|
INITIALS: { widthPct: 8, heightPct: 5, label: 'Initials', icon: Pen },
|
|
|
|
|
|
DATE: { widthPct: 12, heightPct: 3, label: 'Date', icon: CalendarIcon },
|
|
|
|
|
|
EMAIL: { widthPct: 25, heightPct: 3, label: 'Email', icon: Mail },
|
|
|
|
|
|
NAME: { widthPct: 20, heightPct: 3, label: 'Name', icon: UserIcon },
|
|
|
|
|
|
TEXT: { widthPct: 25, heightPct: 3, label: 'Text', icon: Type },
|
|
|
|
|
|
NUMBER: { widthPct: 15, heightPct: 3, label: 'Number', icon: Type },
|
|
|
|
|
|
CHECKBOX: { widthPct: 3, heightPct: 3, label: 'Checkbox', icon: CheckSquare },
|
|
|
|
|
|
DROPDOWN: { widthPct: 20, heightPct: 3, label: 'Dropdown', icon: Type },
|
|
|
|
|
|
RADIO: { widthPct: 3, heightPct: 3, label: 'Radio', icon: CheckSquare },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const RECIPIENT_COLORS = [
|
|
|
|
|
|
'rgb(59 130 246)', // blue-500
|
|
|
|
|
|
'rgb(168 85 247)', // purple-500
|
|
|
|
|
|
'rgb(34 197 94)', // green-500
|
|
|
|
|
|
'rgb(249 115 22)', // orange-500
|
|
|
|
|
|
'rgb(239 68 68)', // red-500
|
|
|
|
|
|
'rgb(20 184 166)', // teal-500
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
interface UploadForSigningDialogProps {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
interestId: string;
|
|
|
|
|
|
/** Pre-set the document type — the parent (Contract/Reservation tab)
|
|
|
|
|
|
* decides which to upload. */
|
|
|
|
|
|
documentType: 'contract' | 'reservation_agreement';
|
|
|
|
|
|
/** Optional: client name/email to prefill the first recipient.
|
|
|
|
|
|
* When omitted the dialog fetches from the interest. */
|
|
|
|
|
|
clientPrefill?: { name: string; email: string };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function UploadForSigningDialog({
|
|
|
|
|
|
open,
|
|
|
|
|
|
onOpenChange,
|
|
|
|
|
|
interestId,
|
|
|
|
|
|
documentType,
|
|
|
|
|
|
clientPrefill,
|
|
|
|
|
|
}: UploadForSigningDialogProps) {
|
|
|
|
|
|
// Re-mount the body on every open so all state resets cleanly. Same
|
|
|
|
|
|
// pattern as hard-delete-dialog (set-state-in-effect avoidance).
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
<DialogContent className="max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
<DialogBody
|
|
|
|
|
|
key={`${interestId}:${documentType}`}
|
|
|
|
|
|
interestId={interestId}
|
|
|
|
|
|
documentType={documentType}
|
|
|
|
|
|
clientPrefill={clientPrefill}
|
|
|
|
|
|
onClose={() => onOpenChange(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Step = 'select-file' | 'configure-recipients' | 'place-fields';
|
|
|
|
|
|
|
2026-05-21 23:16:00 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* localStorage key for draft persistence. Versioned (`v1`) so a future
|
|
|
|
|
|
* shape change can invalidate stale drafts without crashing the parser.
|
|
|
|
|
|
* Scoped per interest+documentType so a rep can have an in-flight
|
|
|
|
|
|
* contract upload AND reservation upload in the same browser session
|
|
|
|
|
|
* without them clobbering each other.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function draftStorageKey(interestId: string, documentType: string): string {
|
|
|
|
|
|
return `pn-crm.upload-for-signing.draft.v1:${interestId}:${documentType}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface PersistedDraft {
|
|
|
|
|
|
step: Step;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
recipients: Recipient[];
|
|
|
|
|
|
fields: PlacedField[];
|
|
|
|
|
|
invitationMessage: string;
|
|
|
|
|
|
/** Saved at timestamp — surfaces in the UI as "Draft saved <relative>". */
|
|
|
|
|
|
savedAt: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadDraft(interestId: string, documentType: string): PersistedDraft | null {
|
|
|
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = window.localStorage.getItem(draftStorageKey(interestId, documentType));
|
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
const parsed = JSON.parse(raw) as PersistedDraft;
|
|
|
|
|
|
// Defensive shape check — drop drafts that look malformed rather
|
|
|
|
|
|
// than crashing the dialog.
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof parsed.title !== 'string' ||
|
|
|
|
|
|
!Array.isArray(parsed.recipients) ||
|
|
|
|
|
|
!Array.isArray(parsed.fields)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return parsed;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveDraft(interestId: string, documentType: string, draft: PersistedDraft): void {
|
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// localStorage may throw on private mode or quota — swallow.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearDraft(interestId: string, documentType: string): void {
|
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.localStorage.removeItem(draftStorageKey(interestId, documentType));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
function DialogBody({
|
|
|
|
|
|
interestId,
|
|
|
|
|
|
documentType,
|
|
|
|
|
|
clientPrefill,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
interestId: string;
|
|
|
|
|
|
documentType: 'contract' | 'reservation_agreement';
|
|
|
|
|
|
clientPrefill?: { name: string; email: string };
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}) {
|
2026-05-21 23:16:00 +02:00
|
|
|
|
// Hydrate from the persisted draft once on mount. The `key` prop on
|
|
|
|
|
|
// the parent re-mounts this body on every open, so this useState
|
|
|
|
|
|
// initializer runs once per dialog session.
|
|
|
|
|
|
const initialDraft = useMemo(
|
|
|
|
|
|
() => loadDraft(interestId, documentType),
|
|
|
|
|
|
[interestId, documentType],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const [step, setStep] = useState<Step>(initialDraft?.step ?? 'select-file');
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
const [file, setFile] = useState<File | null>(null);
|
2026-05-21 23:16:00 +02:00
|
|
|
|
const [title, setTitle] = useState(initialDraft?.title ?? '');
|
|
|
|
|
|
const [recipients, setRecipients] = useState<Recipient[]>(initialDraft?.recipients ?? []);
|
|
|
|
|
|
const [fields, setFields] = useState<PlacedField[]>(initialDraft?.fields ?? []);
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
2026-05-13 14:17:39 +02:00
|
|
|
|
// Phase 6 polish — optional rep-authored note that appears above the
|
|
|
|
|
|
// CTA in every invitation email for this doc. Empty string means
|
|
|
|
|
|
// "no custom note — use the template default copy".
|
2026-05-21 23:16:00 +02:00
|
|
|
|
const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? '');
|
|
|
|
|
|
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(initialDraft?.savedAt ?? null);
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
|
|
|
|
|
|
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
|
|
|
|
|
|
|
|
|
|
|
|
// Defaults endpoint — drives the developer/approver prefill.
|
|
|
|
|
|
const { data: defaults } = useQuery<{ data: SigningDefaults }>({
|
|
|
|
|
|
queryKey: ['documents', 'signing-defaults'],
|
|
|
|
|
|
queryFn: () => apiFetch<{ data: SigningDefaults }>('/api/v1/documents/signing-defaults'),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Interest endpoint — used to prefill the client recipient when the
|
|
|
|
|
|
// caller didn't supply one. Cached so the same dialog open/reopen
|
|
|
|
|
|
// hits the cache.
|
|
|
|
|
|
const { data: interestData } = useQuery<{
|
|
|
|
|
|
data: { client: { fullName: string; email: string | null } };
|
|
|
|
|
|
}>({
|
|
|
|
|
|
queryKey: ['interest', interestId, 'prefill'],
|
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
|
apiFetch<{ data: { client: { fullName: string; email: string | null } } }>(
|
|
|
|
|
|
`/api/v1/interests/${interestId}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
enabled: !clientPrefill,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Build the prefill recipient list from the async query data. The
|
|
|
|
|
|
* dialog reads this on the "Next" button click in the file-picker
|
|
|
|
|
|
* step to seed `recipients` — keeping the seeding as a user-event
|
|
|
|
|
|
* handler rather than an effect avoids the cascading-render lint
|
|
|
|
|
|
* (react-hooks/set-state-in-effect, Wave 3) that earlier versions
|
|
|
|
|
|
* tripped. Returns an empty array until the defaults query resolves;
|
|
|
|
|
|
* the file-picker step's Next button stays clickable so the rep can
|
|
|
|
|
|
* still proceed and add recipients manually if the defaults endpoint
|
|
|
|
|
|
* is slow.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const prefillRecipients = useMemo<Recipient[]>(() => {
|
|
|
|
|
|
if (!defaults?.data) return [];
|
|
|
|
|
|
const client = clientPrefill ?? {
|
|
|
|
|
|
name: interestData?.data?.client?.fullName ?? '',
|
|
|
|
|
|
email: interestData?.data?.client?.email ?? '',
|
|
|
|
|
|
};
|
|
|
|
|
|
const next: Recipient[] = [];
|
|
|
|
|
|
if (client.name && client.email) {
|
|
|
|
|
|
next.push({ name: client.name, email: client.email, role: 'SIGNER', signingOrder: 1 });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (defaults.data.developer.email) {
|
|
|
|
|
|
next.push({
|
|
|
|
|
|
name: defaults.data.developer.name || defaults.data.developer.label,
|
|
|
|
|
|
email: defaults.data.developer.email,
|
|
|
|
|
|
role: 'SIGNER',
|
|
|
|
|
|
signingOrder: next.length + 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (defaults.data.approver.email) {
|
|
|
|
|
|
next.push({
|
|
|
|
|
|
name: defaults.data.approver.name || defaults.data.approver.label,
|
|
|
|
|
|
email: defaults.data.approver.email,
|
|
|
|
|
|
role: 'APPROVER',
|
|
|
|
|
|
signingOrder: next.length + 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
}, [defaults, interestData, clientPrefill]);
|
|
|
|
|
|
|
|
|
|
|
|
const fileObjectUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (fileObjectUrl) URL.revokeObjectURL(fileObjectUrl);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [fileObjectUrl]);
|
|
|
|
|
|
|
2026-05-21 23:16:00 +02:00
|
|
|
|
// Persist the rep's progress to localStorage as they work. Debounced
|
|
|
|
|
|
// at 500ms so a flurry of state updates (typing a long invitation
|
|
|
|
|
|
// message, dragging a field across the page) doesn't hammer storage.
|
|
|
|
|
|
// We DO NOT persist the File object itself — the rep has to re-pick
|
|
|
|
|
|
// the PDF after a refresh. Everything else (title, signers,
|
|
|
|
|
|
// placements, custom note) round-trips. The `step` is restored too
|
|
|
|
|
|
// so the dialog reopens on the same screen the rep left.
|
|
|
|
|
|
const draftDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
|
|
|
|
|
|
draftDebounceRef.current = setTimeout(() => {
|
|
|
|
|
|
// Skip persistence in the pristine "no progress yet" state so
|
|
|
|
|
|
// dismissing the dialog without touching anything doesn't leave
|
|
|
|
|
|
// a phantom draft behind.
|
|
|
|
|
|
const hasProgress =
|
|
|
|
|
|
title.length > 0 ||
|
|
|
|
|
|
recipients.length > 0 ||
|
|
|
|
|
|
fields.length > 0 ||
|
|
|
|
|
|
invitationMessage.length > 0;
|
|
|
|
|
|
if (!hasProgress) {
|
|
|
|
|
|
clearDraft(interestId, documentType);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
saveDraft(interestId, documentType, {
|
|
|
|
|
|
step,
|
|
|
|
|
|
title,
|
|
|
|
|
|
recipients,
|
|
|
|
|
|
fields,
|
|
|
|
|
|
invitationMessage,
|
|
|
|
|
|
savedAt: now,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setDraftSavedAt(now);
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [step, title, recipients, fields, invitationMessage, interestId, documentType]);
|
|
|
|
|
|
|
|
|
|
|
|
function discardDraft() {
|
|
|
|
|
|
clearDraft(interestId, documentType);
|
|
|
|
|
|
setTitle('');
|
|
|
|
|
|
setRecipients([]);
|
|
|
|
|
|
setFields([]);
|
|
|
|
|
|
setInvitationMessage('');
|
|
|
|
|
|
setStep('select-file');
|
|
|
|
|
|
setDraftSavedAt(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
const autoDetect = useMutation({
|
|
|
|
|
|
mutationFn: async (uploadedFile: File) => {
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append('file', uploadedFile);
|
|
|
|
|
|
// apiFetch JSON-encodes the body when set, so we go raw here.
|
|
|
|
|
|
const res = await fetch('/api/v1/documents/auto-detect-fields', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: form,
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) throw new Error('Auto-detect failed');
|
|
|
|
|
|
return (await res.json()) as { data: { fields: DetectedFieldResponse[] } };
|
|
|
|
|
|
},
|
|
|
|
|
|
onSuccess: (res) => {
|
|
|
|
|
|
// Drop detected fields onto a temporary "unassigned" recipient
|
|
|
|
|
|
// (index 0). Rep reassigns via the side panel.
|
|
|
|
|
|
const placed: PlacedField[] = res.data.fields.map((f) => ({
|
|
|
|
|
|
id: `det-${Math.random().toString(36).slice(2, 10)}`,
|
|
|
|
|
|
type: f.type,
|
|
|
|
|
|
recipientIndex: 0,
|
|
|
|
|
|
pageNumber: f.pageNumber,
|
|
|
|
|
|
pageX: f.pageX,
|
|
|
|
|
|
pageY: f.pageY,
|
|
|
|
|
|
pageWidth: f.pageWidth,
|
|
|
|
|
|
pageHeight: f.pageHeight,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setFields((existing) => [...existing, ...placed]);
|
|
|
|
|
|
if (placed.length > 0) {
|
|
|
|
|
|
toast.success(
|
|
|
|
|
|
`Auto-detect placed ${placed.length} field${placed.length === 1 ? '' : 's'}.`,
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.info('No fields auto-detected — place them manually.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: () => {
|
|
|
|
|
|
toast.info('Auto-detect skipped — place fields manually.');
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
const sendMutation = useMutation({
|
|
|
|
|
|
mutationFn: async () => {
|
|
|
|
|
|
if (!file) throw new Error('No file selected');
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append('file', file);
|
|
|
|
|
|
form.append('documentType', documentType);
|
|
|
|
|
|
form.append('title', title || file.name.replace(/\.pdf$/i, ''));
|
|
|
|
|
|
form.append('recipients', JSON.stringify(recipients));
|
2026-05-13 14:17:39 +02:00
|
|
|
|
if (invitationMessage.trim()) {
|
|
|
|
|
|
form.append('invitationMessage', invitationMessage.trim());
|
|
|
|
|
|
}
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
// Strip the client-side `id` from each placed field — the server
|
|
|
|
|
|
// assigns its own ids on the documenso side.
|
|
|
|
|
|
form.append(
|
|
|
|
|
|
'fields',
|
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
|
fields.map((f) => ({
|
|
|
|
|
|
type: f.type,
|
|
|
|
|
|
recipientIndex: f.recipientIndex,
|
|
|
|
|
|
pageNumber: f.pageNumber,
|
|
|
|
|
|
pageX: f.pageX,
|
|
|
|
|
|
pageY: f.pageY,
|
|
|
|
|
|
pageWidth: f.pageWidth,
|
|
|
|
|
|
pageHeight: f.pageHeight,
|
|
|
|
|
|
})),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: form,
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const body = (await res.json().catch(() => ({}))) as {
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
requestId?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
const err = new Error(body.error ?? 'Upload failed');
|
|
|
|
|
|
// Tack the requestId onto the Error so toastError can surface it.
|
|
|
|
|
|
(err as Error & { requestId?: string }).requestId = body.requestId;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return res.json() as Promise<{
|
|
|
|
|
|
data: { documentId: string; signingUrls: Record<string, string> };
|
|
|
|
|
|
}>;
|
|
|
|
|
|
},
|
|
|
|
|
|
onSuccess: (res) => {
|
|
|
|
|
|
toast.success(
|
|
|
|
|
|
defaults?.data?.sendMode === 'auto'
|
|
|
|
|
|
? 'Document sent for signing — first signer has been invited.'
|
|
|
|
|
|
: 'Document uploaded and ready to send. Use the Send button on the doc to email the first signer.',
|
|
|
|
|
|
);
|
|
|
|
|
|
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
|
|
|
|
|
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
|
|
|
|
|
|
void res;
|
2026-05-21 23:16:00 +02:00
|
|
|
|
// Clear the draft on successful submission — the in-flight upload
|
|
|
|
|
|
// is now an actual document; the localStorage shouldn't keep its
|
|
|
|
|
|
// shadow around.
|
|
|
|
|
|
clearDraft(interestId, documentType);
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
onClose();
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (err) => toastError(err, 'Upload failed'),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Step renderers ─────────────────────────────────────────────
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
|
2026-05-21 23:16:00 +02:00
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<DialogTitle>Send {docLabel.toLowerCase()} for signing</DialogTitle>
|
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
|
{step === 'select-file' && 'Upload the draft PDF to send via Documenso.'}
|
|
|
|
|
|
{step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'}
|
|
|
|
|
|
{step === 'place-fields' &&
|
|
|
|
|
|
'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'}
|
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* Draft-saved indicator + Discard button. Renders when there's
|
|
|
|
|
|
a persisted draft so the rep knows progress is saved across
|
|
|
|
|
|
dialog open / close cycles. Discard wipes the draft and
|
|
|
|
|
|
resets to the file-picker step. The file itself isn't
|
|
|
|
|
|
persisted (large blobs + browser quota), so on reopen the
|
|
|
|
|
|
rep needs to re-pick the PDF — the rest of the state
|
|
|
|
|
|
(title, signers, placements, custom note) survives. */}
|
|
|
|
|
|
{draftSavedAt ? (
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
|
|
|
|
|
|
<span title={`Draft auto-saved ${new Date(draftSavedAt).toLocaleString()}`}>
|
|
|
|
|
|
Draft saved
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="h-6 px-2 text-[11px] text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
onClick={discardDraft}
|
|
|
|
|
|
>
|
|
|
|
|
|
Discard
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
|
|
|
|
{step === 'select-file' && (
|
|
|
|
|
|
<FilePickerStep
|
|
|
|
|
|
onFileSelected={(f) => {
|
|
|
|
|
|
setFile(f);
|
|
|
|
|
|
setTitle(f.name.replace(/\.pdf$/i, ''));
|
|
|
|
|
|
// Seed recipients from the prefill snapshot when the rep
|
|
|
|
|
|
// first lands a file — only if they haven't already
|
|
|
|
|
|
// edited the list. This pattern keeps the prefill
|
|
|
|
|
|
// synchronization in user-event handlers (no setState-
|
|
|
|
|
|
// in-effect lint trip).
|
|
|
|
|
|
if (recipients.length === 0 && prefillRecipients.length > 0) {
|
|
|
|
|
|
setRecipients(prefillRecipients);
|
|
|
|
|
|
}
|
|
|
|
|
|
setStep('configure-recipients');
|
|
|
|
|
|
autoDetect.mutate(f);
|
|
|
|
|
|
}}
|
|
|
|
|
|
title={title}
|
|
|
|
|
|
onTitleChange={setTitle}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{step === 'configure-recipients' && (
|
|
|
|
|
|
<RecipientsStep
|
|
|
|
|
|
recipients={recipients}
|
|
|
|
|
|
onChange={setRecipients}
|
|
|
|
|
|
title={title}
|
|
|
|
|
|
onTitleChange={setTitle}
|
2026-05-13 14:17:39 +02:00
|
|
|
|
invitationMessage={invitationMessage}
|
|
|
|
|
|
onInvitationMessageChange={setInvitationMessage}
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{step === 'place-fields' && fileObjectUrl && (
|
|
|
|
|
|
<FieldPlacementStep
|
|
|
|
|
|
fileUrl={fileObjectUrl}
|
|
|
|
|
|
fields={fields}
|
|
|
|
|
|
onFieldsChange={setFields}
|
|
|
|
|
|
recipients={recipients}
|
|
|
|
|
|
selectedFieldId={selectedFieldId}
|
|
|
|
|
|
onSelectField={setSelectedFieldId}
|
|
|
|
|
|
isDetecting={autoDetect.isPending}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="px-6 py-4 border-t flex-shrink-0 flex items-center gap-2">
|
|
|
|
|
|
<StepIndicator step={step} />
|
|
|
|
|
|
<div className="ml-auto flex gap-2">
|
|
|
|
|
|
{step === 'configure-recipients' && (
|
|
|
|
|
|
<Button variant="outline" onClick={() => setStep('select-file')}>
|
|
|
|
|
|
Back
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{step === 'place-fields' && (
|
|
|
|
|
|
<Button variant="outline" onClick={() => setStep('configure-recipients')}>
|
|
|
|
|
|
Back
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{step !== 'place-fields' && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (step === 'select-file') {
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
|
toast.error('Pick a PDF first');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recipients.length === 0 && prefillRecipients.length > 0) {
|
|
|
|
|
|
setRecipients(prefillRecipients);
|
|
|
|
|
|
}
|
|
|
|
|
|
setStep('configure-recipients');
|
|
|
|
|
|
} else if (step === 'configure-recipients') {
|
|
|
|
|
|
if (recipients.length === 0) {
|
|
|
|
|
|
toast.error('Add at least one recipient');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recipients.some((r) => !r.email || !r.name)) {
|
|
|
|
|
|
toast.error('Every recipient needs a name and email');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setStep('place-fields');
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Next
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{step === 'place-fields' && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={() => sendMutation.mutate()}
|
|
|
|
|
|
disabled={sendMutation.isPending || fields.length === 0}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
|
|
|
|
|
{defaults?.data?.sendMode === 'auto' ? 'Send for signing' : 'Send'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function StepIndicator({ step }: { step: Step }) {
|
|
|
|
|
|
const dots = [
|
|
|
|
|
|
{ key: 'select-file', label: 'File' },
|
|
|
|
|
|
{ key: 'configure-recipients', label: 'Recipients' },
|
|
|
|
|
|
{ key: 'place-fields', label: 'Fields' },
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
const activeIdx = dots.findIndex((d) => d.key === step);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
|
{dots.map((d, i) => (
|
|
|
|
|
|
<span key={d.key} className="flex items-center gap-1.5">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={
|
|
|
|
|
|
'size-2 rounded-full ' + (i <= activeIdx ? 'bg-foreground' : 'bg-muted-foreground/30')
|
|
|
|
|
|
}
|
|
|
|
|
|
aria-hidden
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className={i === activeIdx ? 'font-medium text-foreground' : ''}>{d.label}</span>
|
|
|
|
|
|
{i < dots.length - 1 && <span className="text-muted-foreground/40">›</span>}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Step 1: file picker ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function FilePickerStep({
|
|
|
|
|
|
onFileSelected,
|
|
|
|
|
|
title,
|
|
|
|
|
|
onTitleChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
onFileSelected: (file: File) => void;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
onTitleChange: (t: string) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
function handleFile(f: File) {
|
|
|
|
|
|
if (!f.name.toLowerCase().endsWith('.pdf') && f.type !== 'application/pdf') {
|
|
|
|
|
|
toast.error('Only PDF files are accepted');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
onFileSelected(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="px-6 py-4 space-y-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="doc-title">Document title</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="doc-title"
|
|
|
|
|
|
value={title}
|
|
|
|
|
|
onChange={(e) => onTitleChange(e.target.value)}
|
|
|
|
|
|
placeholder="e.g. Berth A-12 Sales Contract — John Smith"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onDragOver={(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setDragging(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDragLeave={() => setDragging(false)}
|
|
|
|
|
|
onDrop={(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setDragging(false);
|
|
|
|
|
|
const f = e.dataTransfer.files[0];
|
|
|
|
|
|
if (f) handleFile(f);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={
|
|
|
|
|
|
'rounded-lg border-2 border-dashed p-12 text-center transition-colors cursor-pointer ' +
|
|
|
|
|
|
(dragging
|
|
|
|
|
|
? 'border-foreground bg-muted/40'
|
|
|
|
|
|
: 'border-muted-foreground/30 hover:border-muted-foreground/60')
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={() => inputRef.current?.click()}
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
inputRef.current?.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="text-sm text-foreground font-medium">Drop a PDF here, or click to browse</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">Max 50 MB.</p>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="application/pdf,.pdf"
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const f = e.target.files?.[0];
|
|
|
|
|
|
if (f) handleFile(f);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Step 2: recipient configurator ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function RecipientsStep({
|
|
|
|
|
|
recipients,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
title,
|
|
|
|
|
|
onTitleChange,
|
2026-05-13 14:17:39 +02:00
|
|
|
|
invitationMessage,
|
|
|
|
|
|
onInvitationMessageChange,
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
recipients: Recipient[];
|
|
|
|
|
|
onChange: (next: Recipient[]) => void;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
onTitleChange: (t: string) => void;
|
2026-05-13 14:17:39 +02:00
|
|
|
|
invitationMessage: string;
|
|
|
|
|
|
onInvitationMessageChange: (next: string) => void;
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
function update(i: number, patch: Partial<Recipient>) {
|
|
|
|
|
|
const next = [...recipients];
|
|
|
|
|
|
next[i] = { ...next[i]!, ...patch };
|
|
|
|
|
|
onChange(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
function remove(i: number) {
|
|
|
|
|
|
const next = recipients.filter((_, idx) => idx !== i);
|
|
|
|
|
|
// Reflow signingOrder
|
|
|
|
|
|
onChange(next.map((r, idx) => ({ ...r, signingOrder: idx + 1 })));
|
|
|
|
|
|
}
|
|
|
|
|
|
function add() {
|
|
|
|
|
|
onChange([
|
|
|
|
|
|
...recipients,
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
role: 'SIGNER',
|
|
|
|
|
|
signingOrder: recipients.length + 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="doc-title-step2">Document title</Label>
|
|
|
|
|
|
<Input id="doc-title-step2" value={title} onChange={(e) => onTitleChange(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>Recipients (in signing order)</Label>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{recipients.map((r, i) => (
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
<div key={i} className="flex gap-2 items-center">
|
|
|
|
|
|
<span className="w-8 shrink-0 text-xs text-center text-muted-foreground tabular-nums">
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
#{r.signingOrder}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Input
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
className="flex-1 min-w-0"
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
placeholder="Name"
|
|
|
|
|
|
value={r.name}
|
|
|
|
|
|
onChange={(e) => update(i, { name: e.target.value })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
className="flex-[2] min-w-0"
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
placeholder="email@example.com"
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
value={r.email}
|
|
|
|
|
|
onChange={(e) => update(i, { email: e.target.value })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={r.role}
|
|
|
|
|
|
onValueChange={(v) => update(i, { role: v as Recipient['role'] })}
|
|
|
|
|
|
>
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
<SelectTrigger className="w-40 shrink-0">
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="SIGNER">Signer</SelectItem>
|
|
|
|
|
|
<SelectItem value="APPROVER">Approver</SelectItem>
|
|
|
|
|
|
<SelectItem value="CC">CC (no signing)</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => remove(i)}
|
|
|
|
|
|
aria-label="Remove recipient"
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
className="shrink-0"
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="size-4" aria-hidden />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" variant="outline" size="sm" onClick={add} className="gap-1.5">
|
|
|
|
|
|
<Plus className="size-4" aria-hidden /> Add recipient
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-05-13 14:17:39 +02:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="invitation-message">
|
|
|
|
|
|
Optional message to include in the signing invitation
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
id="invitation-message"
|
|
|
|
|
|
value={invitationMessage}
|
|
|
|
|
|
onChange={(e) => onInvitationMessageChange(e.target.value)}
|
feat(uat-batch-5): UI polish — dialog width, chart centering, recommender pill, audit link, inbox reorder
Six surgical Wave-2-3 wins:
- UploadForSigningDialog: dialog widened to max-w-[1400px] w-[95vw] so
the place-fields step actually has room; recipient row converts from
fixed grid to flex (name flex-1, email flex-[2] for the longer
string, role w-40, delete shrink-0); invitation-message textarea
rows 3 → 6.
- ChartCard becomes flex-col with flex-1 + items-center on CardContent
so charts vertically center when neighbouring cards make the row
taller (e.g. Pipeline Value's full breakdown).
- Berth recommender pill: drops the "Tier {letter} · " prefix; shows
just the plain-English label ("Open" / "Fall-through" / "Active
interest" / "Late stage") as a Popover trigger that explains the
4-state ladder. HelpCircle icon makes the tooltip discoverable.
- Activity feed gains a "See all" link in the header pointing at
/<port>/admin/audit, permission-gated by `admin.view_audit_log`.
- Inbox section order swaps to Reminders above Alerts (rep-noted
priority); PageHeader title flips to "Reminders & Alerts". Section
ids, deep-link hashes, and localStorage open-state keys untouched.
- Inbox ReminderList (embedded mode only): "New Reminder" button now
shares the filter row (right-aligned via ml-auto) instead of
occupying its own dedicated row above the filters.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:28:20 +02:00
|
|
|
|
placeholder="Hi John, please review the attached contract before signing. Reach out if anything needs adjusting."
|
|
|
|
|
|
rows={6}
|
2026-05-13 14:17:39 +02:00
|
|
|
|
maxLength={1000}
|
|
|
|
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Appears above the Sign button in every recipient’s invitation email. Plain text
|
|
|
|
|
|
only; 1000 characters max.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Step 3: field placement overlay ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function FieldPlacementStep({
|
|
|
|
|
|
fileUrl,
|
|
|
|
|
|
fields,
|
|
|
|
|
|
onFieldsChange,
|
|
|
|
|
|
recipients,
|
|
|
|
|
|
selectedFieldId,
|
|
|
|
|
|
onSelectField,
|
|
|
|
|
|
isDetecting,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
fileUrl: string;
|
|
|
|
|
|
fields: PlacedField[];
|
|
|
|
|
|
onFieldsChange: (next: PlacedField[]) => void;
|
|
|
|
|
|
recipients: Recipient[];
|
|
|
|
|
|
selectedFieldId: string | null;
|
|
|
|
|
|
onSelectField: (id: string | null) => void;
|
|
|
|
|
|
isDetecting: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [numPages, setNumPages] = useState(1);
|
|
|
|
|
|
const [pageNumber, setPageNumber] = useState(1);
|
|
|
|
|
|
const [placingType, setPlacingType] = useState<FieldType | null>(null);
|
2026-05-21 23:16:00 +02:00
|
|
|
|
// PDF render zoom — defaults to 1 (the historical fixed scale). Buttons
|
|
|
|
|
|
// below the page-nav let reps zoom out for an overview or zoom in for
|
|
|
|
|
|
// tight placement work. Field coordinates stay in % of page dimensions
|
|
|
|
|
|
// so the placed-field overlay scales automatically with the PDF.
|
|
|
|
|
|
const [pdfScale, setPdfScale] = useState(1);
|
|
|
|
|
|
// Surfaces a useful error message when the PDF fails to load (CORS
|
|
|
|
|
|
// mismatch, malformed file, worker init failure). Previously the
|
|
|
|
|
|
// dialog showed an infinite spinner that gave reps no signal to act on.
|
|
|
|
|
|
const [pdfLoadError, setPdfLoadError] = useState<string | null>(null);
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
const pageContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const pageFields = useMemo(
|
|
|
|
|
|
() => fields.filter((f) => f.pageNumber === pageNumber),
|
|
|
|
|
|
[fields, pageNumber],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function placeFieldAt(clientX: number, clientY: number, container: HTMLElement) {
|
|
|
|
|
|
if (!placingType) return;
|
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
|
const pageX = ((clientX - rect.left) / rect.width) * 100;
|
|
|
|
|
|
const pageY = ((clientY - rect.top) / rect.height) * 100;
|
|
|
|
|
|
const defaults = FIELD_DEFAULTS[placingType];
|
|
|
|
|
|
const newField: PlacedField = {
|
|
|
|
|
|
id: `f-${Math.random().toString(36).slice(2, 10)}`,
|
|
|
|
|
|
type: placingType,
|
|
|
|
|
|
recipientIndex: 0,
|
|
|
|
|
|
pageNumber,
|
|
|
|
|
|
pageX: Math.max(0, Math.min(100 - defaults.widthPct, pageX - defaults.widthPct / 2)),
|
|
|
|
|
|
pageY: Math.max(0, Math.min(100 - defaults.heightPct, pageY - defaults.heightPct / 2)),
|
|
|
|
|
|
pageWidth: defaults.widthPct,
|
|
|
|
|
|
pageHeight: defaults.heightPct,
|
|
|
|
|
|
};
|
|
|
|
|
|
onFieldsChange([...fields, newField]);
|
|
|
|
|
|
onSelectField(newField.id);
|
|
|
|
|
|
setPlacingType(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateField(id: string, patch: Partial<PlacedField>) {
|
|
|
|
|
|
onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
|
|
|
|
|
}
|
|
|
|
|
|
function removeField(id: string) {
|
|
|
|
|
|
onFieldsChange(fields.filter((f) => f.id !== id));
|
|
|
|
|
|
if (selectedFieldId === id) onSelectField(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:16:00 +02:00
|
|
|
|
// Keyboard shortcuts on the placement canvas — Delete / Backspace
|
|
|
|
|
|
// removes the selected field; arrow keys nudge it by 0.5% (Shift = 5%
|
|
|
|
|
|
// for coarser moves). Listens at document level so the handler still
|
|
|
|
|
|
// fires when the rep's focus is on the PDF canvas (which doesn't take
|
|
|
|
|
|
// text input).
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
|
|
|
|
if (!selectedFieldId) return;
|
|
|
|
|
|
// Ignore when the focus is on a real input / textarea so the
|
|
|
|
|
|
// shortcuts never steal typing.
|
|
|
|
|
|
const tgt = e.target as HTMLElement | null;
|
|
|
|
|
|
if (
|
|
|
|
|
|
tgt &&
|
|
|
|
|
|
(tgt.tagName === 'INPUT' ||
|
|
|
|
|
|
tgt.tagName === 'TEXTAREA' ||
|
|
|
|
|
|
tgt.tagName === 'SELECT' ||
|
|
|
|
|
|
tgt.isContentEditable)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const field = fields.find((f) => f.id === selectedFieldId);
|
|
|
|
|
|
if (!field) return;
|
|
|
|
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
removeField(selectedFieldId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.key.startsWith('Arrow')) {
|
|
|
|
|
|
const step = e.shiftKey ? 5 : 0.5;
|
|
|
|
|
|
const patch: Partial<PlacedField> = {};
|
|
|
|
|
|
if (e.key === 'ArrowUp') patch.pageY = Math.max(0, field.pageY - step);
|
|
|
|
|
|
if (e.key === 'ArrowDown')
|
|
|
|
|
|
patch.pageY = Math.min(100 - field.pageHeight, field.pageY + step);
|
|
|
|
|
|
if (e.key === 'ArrowLeft') patch.pageX = Math.max(0, field.pageX - step);
|
|
|
|
|
|
if (e.key === 'ArrowRight')
|
|
|
|
|
|
patch.pageX = Math.min(100 - field.pageWidth, field.pageX + step);
|
|
|
|
|
|
if (Object.keys(patch).length > 0) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
updateField(selectedFieldId, patch);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('keydown', onKey);
|
|
|
|
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
|
|
|
|
}, [selectedFieldId, fields, onFieldsChange]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex-1 flex overflow-hidden">
|
|
|
|
|
|
{/* Field palette */}
|
|
|
|
|
|
<div className="w-44 border-r p-3 flex-shrink-0 overflow-y-auto bg-muted/30">
|
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground mb-2">Field palette</p>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{(Object.keys(FIELD_DEFAULTS) as FieldType[])
|
|
|
|
|
|
.filter((t) => t !== 'FREE_SIGNATURE') // collapsed with SIGNATURE for the palette
|
|
|
|
|
|
.map((t) => {
|
|
|
|
|
|
const def = FIELD_DEFAULTS[t];
|
|
|
|
|
|
const Icon = def.icon;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={t}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setPlacingType(t === placingType ? null : t)}
|
|
|
|
|
|
className={
|
|
|
|
|
|
'w-full text-left text-xs px-2 py-1.5 rounded flex items-center gap-2 transition ' +
|
|
|
|
|
|
(placingType === t
|
|
|
|
|
|
? 'bg-foreground text-background'
|
|
|
|
|
|
: 'hover:bg-background border border-transparent hover:border-border')
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="size-3.5" aria-hidden />
|
|
|
|
|
|
{def.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{placingType && (
|
|
|
|
|
|
<p className="mt-3 text-xs text-muted-foreground">
|
|
|
|
|
|
Click on the PDF to place a {FIELD_DEFAULTS[placingType].label.toLowerCase()}.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<hr className="my-3 border-muted-foreground/20" />
|
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground mb-2">Recipients</p>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{recipients.map((r, i) => (
|
|
|
|
|
|
<div key={i} className="text-xs flex items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="size-2 rounded-full shrink-0"
|
|
|
|
|
|
style={{ backgroundColor: RECIPIENT_COLORS[i % RECIPIENT_COLORS.length] }}
|
|
|
|
|
|
aria-hidden
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="truncate">{r.name || r.email || `#${r.signingOrder}`}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* PDF + overlay */}
|
|
|
|
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
|
|
|
|
<div className="flex items-center gap-2 border-b bg-muted/20 px-3 py-2 text-sm">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
disabled={pageNumber <= 1}
|
|
|
|
|
|
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
|
|
|
|
|
|
aria-label="Previous page"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span className="min-w-[80px] text-center tabular-nums">
|
|
|
|
|
|
{pageNumber} / {numPages}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
disabled={pageNumber >= numPages}
|
|
|
|
|
|
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
|
|
|
|
|
|
aria-label="Next page"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight className="size-4" />
|
|
|
|
|
|
</Button>
|
2026-05-21 23:16:00 +02:00
|
|
|
|
{/* Zoom controls — render zoom only, field coordinates stay
|
|
|
|
|
|
in % so placements scale automatically with the canvas. */}
|
|
|
|
|
|
<div className="ml-3 flex items-center gap-1 border-l pl-3">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
disabled={pdfScale <= 0.5}
|
|
|
|
|
|
onClick={() => setPdfScale((s) => Math.max(0.5, Math.round((s - 0.25) * 100) / 100))}
|
|
|
|
|
|
aria-label="Zoom out"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-base font-bold leading-none">−</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span className="min-w-[44px] text-center text-xs tabular-nums text-muted-foreground">
|
|
|
|
|
|
{Math.round(pdfScale * 100)}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
disabled={pdfScale >= 2}
|
|
|
|
|
|
onClick={() => setPdfScale((s) => Math.min(2, Math.round((s + 0.25) * 100) / 100))}
|
|
|
|
|
|
aria-label="Zoom in"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-base font-bold leading-none">+</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
{isDetecting && (
|
|
|
|
|
|
<span className="ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
|
|
|
<Loader2 className="size-3 animate-spin" /> Auto-detecting fields…
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
{fields.length} {fields.length === 1 ? 'field' : 'fields'} placed
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 overflow-auto bg-muted/30 p-4">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={pageContainerRef}
|
|
|
|
|
|
className="relative mx-auto bg-white shadow"
|
|
|
|
|
|
style={{ width: 'fit-content', cursor: placingType ? 'crosshair' : 'default' }}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
if (!placingType) return;
|
|
|
|
|
|
if (!pageContainerRef.current) return;
|
|
|
|
|
|
// Bail when the click landed on an existing field (we
|
|
|
|
|
|
// handle those via the field's own onClick).
|
|
|
|
|
|
if ((e.target as HTMLElement).closest('[data-field-id]')) return;
|
|
|
|
|
|
placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-21 23:16:00 +02:00
|
|
|
|
{pdfLoadError ? (
|
|
|
|
|
|
<div className="flex h-96 w-[600px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
<p className="font-medium text-destructive">PDF preview failed to load</p>
|
|
|
|
|
|
<p className="font-mono text-xs break-all">{pdfLoadError}</p>
|
|
|
|
|
|
<p className="text-xs">
|
|
|
|
|
|
Field placement still works once the file is uploaded; placements drag onto a
|
|
|
|
|
|
blank canvas. Re-pick the file from step 1 to retry.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Document
|
|
|
|
|
|
file={fileUrl}
|
|
|
|
|
|
onLoadSuccess={({ numPages: n }) => {
|
|
|
|
|
|
setNumPages(n);
|
|
|
|
|
|
setPdfLoadError(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onLoadError={(err) => {
|
|
|
|
|
|
setPdfLoadError(err instanceof Error ? err.message : 'Unknown error');
|
|
|
|
|
|
}}
|
|
|
|
|
|
loading={
|
|
|
|
|
|
<div className="flex h-64 w-96 items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
|
<Loader2 className="size-4 mr-2 animate-spin" /> Loading PDF…
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Page
|
|
|
|
|
|
pageNumber={pageNumber}
|
|
|
|
|
|
scale={pdfScale}
|
|
|
|
|
|
renderAnnotationLayer={false}
|
|
|
|
|
|
renderTextLayer={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Document>
|
|
|
|
|
|
)}
|
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>
2026-05-13 14:03:27 +02:00
|
|
|
|
{/* Overlay layer */}
|
|
|
|
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
|
|
|
|
{pageFields.map((field) => (
|
|
|
|
|
|
<FieldOverlay
|
|
|
|
|
|
key={field.id}
|
|
|
|
|
|
field={field}
|
|
|
|
|
|
selected={selectedFieldId === field.id}
|
|
|
|
|
|
recipients={recipients}
|
|
|
|
|
|
onSelect={() => onSelectField(field.id)}
|
|
|
|
|
|
onUpdate={(patch) => updateField(field.id, patch)}
|
|
|
|
|
|
onRemove={() => removeField(field.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Side panel for selected field */}
|
|
|
|
|
|
{selectedFieldId && (
|
|
|
|
|
|
<FieldSidePanel
|
|
|
|
|
|
field={fields.find((f) => f.id === selectedFieldId)!}
|
|
|
|
|
|
recipients={recipients}
|
|
|
|
|
|
onUpdate={(patch) => updateField(selectedFieldId, patch)}
|
|
|
|
|
|
onRemove={() => removeField(selectedFieldId)}
|
|
|
|
|
|
onClose={() => onSelectField(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FieldOverlay({
|
|
|
|
|
|
field,
|
|
|
|
|
|
selected,
|
|
|
|
|
|
recipients,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
onUpdate,
|
|
|
|
|
|
onRemove,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
field: PlacedField;
|
|
|
|
|
|
selected: boolean;
|
|
|
|
|
|
recipients: Recipient[];
|
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
|
onUpdate: (patch: Partial<PlacedField>) => void;
|
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const Icon = FIELD_DEFAULTS[field.type].icon;
|
|
|
|
|
|
const color = RECIPIENT_COLORS[field.recipientIndex % RECIPIENT_COLORS.length];
|
|
|
|
|
|
const recipient = recipients[field.recipientIndex];
|
|
|
|
|
|
|
|
|
|
|
|
// Drag handler — translate mouse-move pixels into percent deltas
|
|
|
|
|
|
// against the parent container's bounding rect.
|
|
|
|
|
|
function startDrag(e: React.MouseEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onSelect();
|
|
|
|
|
|
const container = (e.currentTarget.parentElement?.parentElement as HTMLElement) ?? null;
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
|
const startX = e.clientX;
|
|
|
|
|
|
const startY = e.clientY;
|
|
|
|
|
|
const startPageX = field.pageX;
|
|
|
|
|
|
const startPageY = field.pageY;
|
|
|
|
|
|
|
|
|
|
|
|
function onMove(ev: MouseEvent) {
|
|
|
|
|
|
const dxPct = ((ev.clientX - startX) / rect.width) * 100;
|
|
|
|
|
|
const dyPct = ((ev.clientY - startY) / rect.height) * 100;
|
|
|
|
|
|
onUpdate({
|
|
|
|
|
|
pageX: Math.max(0, Math.min(100 - field.pageWidth, startPageX + dxPct)),
|
|
|
|
|
|
pageY: Math.max(0, Math.min(100 - field.pageHeight, startPageY + dyPct)),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
function onUp() {
|
|
|
|
|
|
window.removeEventListener('mousemove', onMove);
|
|
|
|
|
|
window.removeEventListener('mouseup', onUp);
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('mousemove', onMove);
|
|
|
|
|
|
window.addEventListener('mouseup', onUp);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
data-field-id={field.id}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onSelect();
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseDown={startDrag}
|
|
|
|
|
|
className={
|
|
|
|
|
|
'absolute pointer-events-auto cursor-move rounded border-2 text-xs flex items-center gap-1 px-1 ' +
|
|
|
|
|
|
(selected ? 'ring-2 ring-offset-1 ring-foreground' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: `${field.pageX}%`,
|
|
|
|
|
|
top: `${field.pageY}%`,
|
|
|
|
|
|
width: `${field.pageWidth}%`,
|
|
|
|
|
|
height: `${field.pageHeight}%`,
|
|
|
|
|
|
backgroundColor: color + '22',
|
|
|
|
|
|
borderColor: color,
|
|
|
|
|
|
}}
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
aria-label={`${FIELD_DEFAULTS[field.type].label} for ${recipient?.name ?? 'unassigned'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="size-3 shrink-0" aria-hidden style={{ color }} />
|
|
|
|
|
|
<span className="truncate" style={{ color }}>
|
|
|
|
|
|
{FIELD_DEFAULTS[field.type].label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{selected && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onRemove();
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="ml-auto rounded p-0.5 hover:bg-background/60"
|
|
|
|
|
|
aria-label="Delete field"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="size-3" aria-hidden />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FieldSidePanel({
|
|
|
|
|
|
field,
|
|
|
|
|
|
recipients,
|
|
|
|
|
|
onUpdate,
|
|
|
|
|
|
onRemove,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
field: PlacedField;
|
|
|
|
|
|
recipients: Recipient[];
|
|
|
|
|
|
onUpdate: (patch: Partial<PlacedField>) => void;
|
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-64 border-l p-4 flex-shrink-0 bg-background space-y-3 overflow-y-auto">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<p className="text-sm font-medium">Field properties</p>
|
|
|
|
|
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close panel">
|
|
|
|
|
|
<X className="size-4" aria-hidden />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label className="text-xs">Type</Label>
|
|
|
|
|
|
<Select value={field.type} onValueChange={(v) => onUpdate({ type: v as FieldType })}>
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{(Object.keys(FIELD_DEFAULTS) as FieldType[]).map((t) => (
|
|
|
|
|
|
<SelectItem key={t} value={t}>
|
|
|
|
|
|
{FIELD_DEFAULTS[t].label}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label className="text-xs">Recipient</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={String(field.recipientIndex)}
|
|
|
|
|
|
onValueChange={(v) => onUpdate({ recipientIndex: Number(v) })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{recipients.map((r, i) => (
|
|
|
|
|
|
<SelectItem key={i} value={String(i)}>
|
|
|
|
|
|
#{r.signingOrder} {r.name || r.email}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Width %</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={field.pageWidth.toFixed(1)}
|
|
|
|
|
|
onChange={(e) => onUpdate({ pageWidth: Number(e.target.value) })}
|
|
|
|
|
|
step="0.5"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
max="100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Height %</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={field.pageHeight.toFixed(1)}
|
|
|
|
|
|
onChange={(e) => onUpdate({ pageHeight: Number(e.target.value) })}
|
|
|
|
|
|
step="0.5"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
max="100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
|
|
|
|
|
|
<Trash2 className="size-4" aria-hidden /> Delete field
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|