feat(wizard-refactor): drop inapp pathway + upload branch + per-port template defaults + mark-signed dropdown
Phase 2 of the comprehensive UAT round. Locked decisions from the
2026-05-26 question round (see docs/superpowers/audits/active-uat.md
"Decisions locked" block).
P2.1 — drop the inapp template pathway
Removed the dead pathway dropdown. Generate-from-template flow is
now exclusively documenso-template; the inapp (pdf-lib + CRM-render)
branch was never surfaced as a deliberate choice and was a config
trap. Server-side route still accepts pathway='inapp' for backcompat
with older clients - wizard now always sends 'documenso-template'.
P2.2 — delete the wizard's upload branch
Reps who want to upload a finished PDF go through the New-document
dropdown -> "Upload & send for signature" (UploadForSigningDialog,
the proper field-placement flow) instead of the wizard's
half-implemented upload sub-form. Wizard's Source section becomes
a one-line explainer + the template picker; no more redundant
radio-then-pathway-then-template layering.
P2.3 — per-port doc-type template defaults
New GET /api/v1/documents/template-defaults endpoint returns
{ eoi, contract, reservation_agreement } template ids from
getPortDocumensoConfig. Settings registry keys already existed for
contract + reservation; config + resolver already plumbed them.
CreateDocumentWizard now fetches the map on mount and auto-sets
templateId whenever documentType changes (empty picker OR currently
showing a different doc-type's default both get re-aligned). Admin
override via the picker still works.
P2.4 — surface flow 3 (mark signed offline) from the dropdown
NewDocumentMenu gains a 4th item: "Mark as signed (offline)".
Opens a small dialog that asks for the interest + doc type
(eoi/reservation/contract), then navigates to the matching
per-interest tab with ?tab=...&action=upload-signed query param.
Per-interest tabs are the single source of truth for the
pipeline-stage + doc-status side effects of the mark-signed flow;
the hub-level dropdown just routes the rep to the right place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
src/app/api/v1/documents/template-defaults/route.ts
Normal file
43
src/app/api/v1/documents/template-defaults/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
|
||||
/**
|
||||
* GET `/api/v1/documents/template-defaults`
|
||||
*
|
||||
* Returns the per-port default Documenso template id keyed by
|
||||
* documentType. The CreateDocumentWizard reads this to auto-resolve
|
||||
* the template the rep doesn't have to remember — picking "EOI" /
|
||||
* "Reservation Agreement" / "Contract" defaults to the matching port
|
||||
* template id. Admins with the explicit perm can still override via
|
||||
* the DocumentTemplatePicker.
|
||||
*
|
||||
* Permission: documents.create — the only caller is the wizard which
|
||||
* already requires this permission to complete the flow. View-only
|
||||
* roles don't see the wizard at all.
|
||||
*
|
||||
* Response:
|
||||
* { data: { eoi: number | null, contract: number | null,
|
||||
* reservation_agreement: number | null } }
|
||||
*
|
||||
* `null` means no template configured for that doc type (rep must
|
||||
* pick one manually via the override picker).
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('documents', 'create', async (_req, ctx) => {
|
||||
try {
|
||||
const cfg = await getPortDocumensoConfig(ctx.portId);
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
eoi: cfg.eoiTemplateId > 0 ? cfg.eoiTemplateId : null,
|
||||
contract: cfg.contractTemplateId,
|
||||
reservation_agreement: cfg.reservationTemplateId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -22,7 +22,6 @@ import { CompanyPicker } from '@/components/companies/company-picker';
|
||||
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||
import { InterestPicker } from '@/components/interests/interest-picker';
|
||||
import { DocumentTemplatePicker } from '@/components/documents/document-template-picker';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { DOCUMENT_TYPES, DOCUMENT_TYPE_LABELS } from '@/lib/constants';
|
||||
@@ -65,12 +64,14 @@ interface CreateDocumentWizardProps {
|
||||
export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [source, setSource] = useState<'template' | 'upload'>('template');
|
||||
const [pathway, setPathway] = useState<'documenso-template' | 'inapp' | 'upload'>(
|
||||
'documenso-template',
|
||||
);
|
||||
// Wizard is generation-only — the upload branch was removed per the
|
||||
// 2026-05-26 wizard-refactor decision. Reps who want to upload a
|
||||
// finished PDF go through the New-document dropdown → "Upload & send
|
||||
// for signature" (UploadForSigningDialog) or → "Mark as signed
|
||||
// offline" (ExternalEoiUploadDialog) instead. The `pathway` state is
|
||||
// also gone: `inapp` was a dead CRM-rendered branch that no UI ever
|
||||
// surfaced as a deliberate choice; everything flows through Documenso.
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [uploadedFileId, setUploadedFileId] = useState('');
|
||||
const [documentType, setDocumentType] = useState<(typeof DOCUMENT_TYPES)[number]>('eoi');
|
||||
const [title, setTitle] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
@@ -78,6 +79,42 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
const [subjectType, setSubjectType] = useState<(typeof SUBJECT_TYPES)[number]['key']>('interest');
|
||||
const [subjectId, setSubjectId] = useState('');
|
||||
|
||||
// Per-port template defaults keyed by documentType. Loaded once on
|
||||
// mount; whenever documentType changes (or this map lands after the
|
||||
// first paint) we auto-populate templateId so the rep doesn't have to
|
||||
// know "which template did the admin set for EOI on this port?". The
|
||||
// rep can still override via the picker — auto-fill only writes when
|
||||
// templateId is currently empty OR matches a different doc-type's
|
||||
// default (so they don't get stuck on the wrong template after
|
||||
// switching doc types).
|
||||
const [templateDefaults, setTemplateDefaults] = useState<Record<string, number | null>>({});
|
||||
useEffect(() => {
|
||||
void apiFetch<{
|
||||
data: { eoi: number | null; contract: number | null; reservation_agreement: number | null };
|
||||
}>('/api/v1/documents/template-defaults')
|
||||
.then((res) => setTemplateDefaults(res.data ?? {}))
|
||||
.catch(() => setTemplateDefaults({}));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const fallbackId = templateDefaults[documentType];
|
||||
if (fallbackId == null) return;
|
||||
const allTemplateDefaultIds = new Set(
|
||||
Object.values(templateDefaults).filter((v): v is number => v != null),
|
||||
);
|
||||
// Set when picker is empty OR when current pick is a different
|
||||
// doc-type's default (rep switched doc types — auto-realign). This
|
||||
// is the canonical controlled-state-sync pattern: external input
|
||||
// (doc-type selection) drives a dependent field (template id)
|
||||
// whose default the rep can still override via the picker. Pure
|
||||
// useMemo derivation isn't an option because the picker also writes
|
||||
// templateId on explicit user input.
|
||||
|
||||
if (!templateId || allTemplateDefaultIds.has(Number(templateId))) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTemplateId(String(fallbackId));
|
||||
}
|
||||
}, [documentType, templateDefaults, templateId]);
|
||||
|
||||
// Watchers picked at create-time. Each selected user gets an in-app
|
||||
// notification on every signing event (opened, signed, declined,
|
||||
// completed) once the document is created. Same surface the
|
||||
@@ -107,15 +144,6 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
|
||||
const subjectField = SUBJECT_TYPES.find((s) => s.key === subjectType)!.field;
|
||||
|
||||
const setSourceAndPathway = (next: 'template' | 'upload'): void => {
|
||||
setSource(next);
|
||||
if (next === 'upload') {
|
||||
setPathway('upload');
|
||||
} else if (pathway === 'upload') {
|
||||
setPathway('documenso-template');
|
||||
}
|
||||
};
|
||||
|
||||
const updateSigner = (idx: number, patch: Partial<SignerRow>): void => {
|
||||
setSigners((current) => current.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
||||
};
|
||||
@@ -147,25 +175,24 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
toast.error(`Provide a ${subjectType} id`);
|
||||
return;
|
||||
}
|
||||
if (source === 'template' && !templateId.trim()) {
|
||||
if (!templateId.trim()) {
|
||||
toast.error('Pick a template');
|
||||
return;
|
||||
}
|
||||
if (source === 'upload' && !uploadedFileId.trim()) {
|
||||
toast.error('Provide an uploaded file id');
|
||||
return;
|
||||
}
|
||||
const cleanSigners = signers.filter((s) => s.signerEmail.trim() && s.signerName.trim());
|
||||
if (source === 'upload' && cleanSigners.length === 0) {
|
||||
toast.error('Upload path requires at least one signer');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Wizard is generation-only: source is always 'template', pathway
|
||||
// is always 'documenso-template'. The upload branch + inapp
|
||||
// pathway were removed per the 2026-05-26 wizard-refactor
|
||||
// decision. The server-side wizard route still accepts both
|
||||
// fields for backcompat with older clients; we pass the fixed
|
||||
// values explicitly so the API contract stays stable while the
|
||||
// UI surface narrows.
|
||||
const body: Record<string, unknown> = {
|
||||
source,
|
||||
pathway,
|
||||
source: 'template',
|
||||
pathway: 'documenso-template',
|
||||
documentType,
|
||||
title: title.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
@@ -175,13 +202,10 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
autoPlaceFields: true,
|
||||
sendImmediately: false,
|
||||
remindersDisabled: reminderMode === 'disabled',
|
||||
templateId: templateId.trim(),
|
||||
};
|
||||
|
||||
if (source === 'template') body.templateId = templateId.trim();
|
||||
if (source === 'upload') {
|
||||
body.uploadedFileId = uploadedFileId.trim();
|
||||
body.signers = cleanSigners;
|
||||
} else if (cleanSigners.length > 0) {
|
||||
if (cleanSigners.length > 0) {
|
||||
body.signers = cleanSigners;
|
||||
}
|
||||
|
||||
@@ -218,79 +242,22 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-md border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Source
|
||||
Template
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={source === 'template'}
|
||||
onChange={() => setSourceAndPathway('template')}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generates a Documenso envelope from a stored template. To upload a finished PDF and
|
||||
place fields yourself, use <strong>New document</strong> -> Upload & send for
|
||||
signature instead. To record a paper-signed document, use{' '}
|
||||
<strong>New document</strong> -> Mark as signed offline.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Template</Label>
|
||||
<DocumentTemplatePicker
|
||||
value={templateId || null}
|
||||
onChange={(id) => setTemplateId(id ?? '')}
|
||||
/>
|
||||
<span>Generate from a template</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={source === 'upload'}
|
||||
onChange={() => setSourceAndPathway('upload')}
|
||||
/>
|
||||
<span>Upload a finished PDF</span>
|
||||
</label>
|
||||
|
||||
{source === 'template' ? (
|
||||
<>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Label className="text-xs">Pathway</Label>
|
||||
<Select value={pathway} onValueChange={(v) => setPathway(v as typeof pathway)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="documenso-template">
|
||||
Generated EOI - rendered + signed externally
|
||||
</SelectItem>
|
||||
<SelectItem value="inapp">
|
||||
Manual EOI - rendered in CRM, sent for e-signature
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Template</Label>
|
||||
<DocumentTemplatePicker
|
||||
value={templateId || null}
|
||||
onChange={(id) => setTemplateId(id ?? '')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Upload PDF</Label>
|
||||
{uploadedFileId ? (
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||
<span className="truncate">File ready (id: {uploadedFileId.slice(0, 8)}…)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadedFileId('')}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUploadZone
|
||||
onUploadComplete={(file) => {
|
||||
if (file?.id) setUploadedFileId(file.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drop a PDF or click to browse. The file is stored, then the wizard wires it as the
|
||||
source for signing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react';
|
||||
import { CheckCircle2, ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -19,7 +21,10 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { InterestPicker } from '@/components/interests/interest-picker';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
|
||||
/**
|
||||
@@ -57,7 +62,13 @@ export function NewDocumentMenu({
|
||||
}: NewDocumentMenuProps) {
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
|
||||
const [markSignedOpen, setMarkSignedOpen] = useState(false);
|
||||
const [markSignedInterestId, setMarkSignedInterestId] = useState<string | null>(null);
|
||||
const [markSignedDocType, setMarkSignedDocType] = useState<'eoi' | 'reservation' | 'contract'>(
|
||||
'eoi',
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -99,9 +110,83 @@ export function NewDocumentMenu({
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setMarkSignedOpen(true)} className="gap-2 py-2.5">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden />
|
||||
<div className="flex flex-col">
|
||||
<span>Mark as signed (offline)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Already paper-signed - record without going through Documenso
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Mark-as-signed picker dialog. Reps choose interest + doc type;
|
||||
we navigate to the matching per-interest tab with an
|
||||
action=upload-signed query param so the existing tab's own
|
||||
ExternalEoi / external-reservation / external-contract upload
|
||||
dialog handles the file capture + the pipeline transition.
|
||||
This keeps a single source of truth for the per-type side
|
||||
effects (eoiStatus / reservationDocStatus / contractDocStatus
|
||||
flips, stage advance, audit log) instead of recreating them
|
||||
at the hub level. */}
|
||||
<Dialog open={markSignedOpen} onOpenChange={setMarkSignedOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mark as signed (offline)</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the deal + document type. We'll open the relevant tab where you can upload
|
||||
the signed copy (or just mark it signed without a file).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Interest / deal</Label>
|
||||
<InterestPicker
|
||||
value={markSignedInterestId}
|
||||
onChange={(id) => setMarkSignedInterestId(id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Document type</Label>
|
||||
<RadioGroup
|
||||
value={markSignedDocType}
|
||||
onValueChange={(v) => setMarkSignedDocType(v as 'eoi' | 'reservation' | 'contract')}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="eoi" /> EOI
|
||||
</Label>
|
||||
<Label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="reservation" /> Reservation Agreement
|
||||
</Label>
|
||||
<Label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="contract" /> Contract
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMarkSignedOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!markSignedInterestId}
|
||||
onClick={() => {
|
||||
if (!markSignedInterestId) return;
|
||||
router.push(
|
||||
`/${portSlug}/interests/${markSignedInterestId}?tab=${markSignedDocType}&action=upload-signed` as never,
|
||||
);
|
||||
setMarkSignedOpen(false);
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user