diff --git a/src/app/api/v1/documents/template-defaults/route.ts b/src/app/api/v1/documents/template-defaults/route.ts new file mode 100644 index 00000000..aeedec26 --- /dev/null +++ b/src/app/api/v1/documents/template-defaults/route.ts @@ -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); + } + }), +); diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index 00e0b641..4fe4de52 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -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>({}); + 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): 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 = { - 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) {

- Source + Template

-
diff --git a/src/components/documents/new-document-menu.tsx b/src/components/documents/new-document-menu.tsx index 4ffbf68e..5a27e149 100644 --- a/src/components/documents/new-document-menu.tsx +++ b/src/components/documents/new-document-menu.tsx @@ -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(null); + const [markSignedDocType, setMarkSignedDocType] = useState<'eoi' | 'reservation' | 'contract'>( + 'eoi', + ); const queryClient = useQueryClient(); + const router = useRouter(); return ( <> @@ -99,9 +110,83 @@ export function NewDocumentMenu({
+ setMarkSignedOpen(true)} className="gap-2 py-2.5"> + +
+ Mark as signed (offline) + + Already paper-signed - record without going through Documenso + +
+
+ {/* 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. */} + + + + Mark as signed (offline) + + 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). + + +
+
+ + setMarkSignedInterestId(id)} + /> +
+
+ + setMarkSignedDocType(v as 'eoi' | 'reservation' | 'contract')} + className="flex flex-col gap-2" + > + + + + +
+
+ + + + +
+
+