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:
2026-05-26 21:17:17 +02:00
parent b6c27b506d
commit 210748076f
3 changed files with 197 additions and 102 deletions

View 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);
}
}),
);

View File

@@ -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> -&gt; Upload &amp; send for
signature instead. To record a paper-signed document, use{' '}
<strong>New document</strong> -&gt; 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>

View File

@@ -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&apos;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>