feat(documents): create-document wizard MVP + service dispatch

Implements createFromWizard and createFromUpload service paths covering
the documenso-template, in-app, and upload pathways. Persists subject
FK, signers, watchers, and the per-document reminder controls
(remindersDisabled / reminderCadenceOverride) introduced in PR1. New
POST /api/v1/documents/wizard route and a functional /documents/new UI
with type/source/template/signers/reminders sections. Drag-handle
reorder, watcher autocomplete picker, and PDF preview defer to the
PR10 polish sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:43:00 +02:00
parent 2dc53842c0
commit d8f0cdd7d2
5 changed files with 656 additions and 36 deletions

View File

@@ -1,6 +1,4 @@
import Link from 'next/link'; import { CreateDocumentWizard } from '@/components/documents/create-document-wizard';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
interface PageProps { interface PageProps {
params: Promise<{ portSlug: string }>; params: Promise<{ portSlug: string }>;
@@ -8,17 +6,5 @@ interface PageProps {
export default async function NewDocumentPage({ params }: PageProps) { export default async function NewDocumentPage({ params }: PageProps) {
const { portSlug } = await params; const { portSlug } = await params;
return ( return <CreateDocumentWizard portSlug={portSlug} />;
<div className="flex flex-col gap-4">
<PageHeader
title="New document"
description="The create-document wizard ships in PR6 of the Phase A rollout."
actions={
<Button asChild variant="outline">
<Link href={`/${portSlug}/documents`}>Back to documents</Link>
</Button>
}
/>
</div>
);
} }

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createFromWizard } from '@/lib/services/documents.service';
import { createDocumentWizardSchema } from '@/lib/validators/documents';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createDocumentWizardSchema);
const doc = await createFromWizard(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: doc }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,424 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import { DOCUMENT_TYPES } from '@/lib/constants';
const SIGNER_ROLES = ['client', 'sales', 'approver', 'developer', 'other'] as const;
const SUBJECT_TYPES = [
{ key: 'interest', label: 'Interest', field: 'interestId' as const },
{ key: 'reservation', label: 'Reservation', field: 'reservationId' as const },
{ key: 'client', label: 'Client', field: 'clientId' as const },
{ key: 'company', label: 'Company', field: 'companyId' as const },
{ key: 'yacht', label: 'Yacht', field: 'yachtId' as const },
] as const;
interface SignerRow {
signerName: string;
signerEmail: string;
signerRole: (typeof SIGNER_ROLES)[number];
signingOrder: number;
}
interface CreateDocumentWizardProps {
portSlug: string;
}
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',
);
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('');
const [subjectType, setSubjectType] = useState<(typeof SUBJECT_TYPES)[number]['key']>('interest');
const [subjectId, setSubjectId] = useState('');
const [signers, setSigners] = useState<SignerRow[]>([
{ signerName: '', signerEmail: '', signerRole: 'client', signingOrder: 1 },
]);
const [signingMode, setSigningMode] = useState<'sequential' | 'parallel'>('sequential');
const [reminderMode, setReminderMode] = useState<'default' | 'override' | 'disabled'>('default');
const [reminderDays, setReminderDays] = useState(7);
const [submitting, setSubmitting] = useState(false);
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)));
};
const addSigner = (): void => {
setSigners((current) => [
...current,
{
signerName: '',
signerEmail: '',
signerRole: 'other',
signingOrder: current.length + 1,
},
]);
};
const removeSigner = (idx: number): void => {
setSigners((current) =>
current.filter((_, i) => i !== idx).map((s, i) => ({ ...s, signingOrder: i + 1 })),
);
};
const handleSubmit = async (): Promise<void> => {
if (!title.trim()) {
toast.error('Title is required');
return;
}
if (!subjectId.trim()) {
toast.error(`Provide a ${subjectType} id`);
return;
}
if (source === 'template' && !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 {
const body: Record<string, unknown> = {
source,
pathway,
documentType,
title: title.trim(),
notes: notes.trim() || undefined,
[subjectField]: subjectId.trim(),
signingMode,
watchers: [],
autoPlaceFields: true,
sendImmediately: false,
remindersDisabled: reminderMode === 'disabled',
};
if (source === 'template') body.templateId = templateId.trim();
if (source === 'upload') {
body.uploadedFileId = uploadedFileId.trim();
body.signers = cleanSigners;
} else if (cleanSigners.length > 0) {
body.signers = cleanSigners;
}
if (reminderMode === 'override') {
body.reminderCadenceOverride = reminderDays;
}
const res = await apiFetch<{ data: { id: string } }>('/api/v1/documents/wizard', {
method: 'POST',
body,
});
toast.success('Document created');
router.push(`/${portSlug}/documents/${res.data.id}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create document');
setSubmitting(false);
}
};
return (
<div className="flex flex-col gap-4">
<PageHeader
title="New document"
description="Generate, attach, and send a document for signing."
actions={
<Button asChild variant="outline">
<Link href={`/${portSlug}/documents`}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Link>
</Button>
}
/>
<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
</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')}
/>
<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">Documenso renders + signs</SelectItem>
<SelectItem value="inapp">Render in CRM, sign via Documenso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Template id</Label>
<Input
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
placeholder="Template UUID"
/>
</div>
</>
) : (
<div className="flex flex-col gap-2">
<Label className="text-xs">Uploaded file id</Label>
<Input
value={uploadedFileId}
onChange={(e) => setUploadedFileId(e.target.value)}
placeholder="File UUID from /api/v1/files upload"
/>
<p className="text-xs text-muted-foreground">
Upload via the existing file uploader, then paste the returned id here.
</p>
</div>
)}
</div>
</section>
<section className="rounded-md border bg-white p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Document
</h2>
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-2">
<Label className="text-xs">Type</Label>
<Select
value={documentType}
onValueChange={(v) => setDocumentType(v as typeof documentType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOCUMENT_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Internal notes</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="grid grid-cols-[max-content_1fr] gap-2">
<Select
value={subjectType}
onValueChange={(v) => setSubjectType(v as typeof subjectType)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUBJECT_TYPES.map((s) => (
<SelectItem key={s.key} value={s.key}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
placeholder={`${subjectType} id`}
/>
</div>
</div>
</section>
<section className="rounded-md border bg-white p-4 lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Signers
</h2>
<Button size="sm" variant="outline" onClick={addSigner}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add signer
</Button>
</div>
<ul className="space-y-2">
{signers.map((s, idx) => (
<li
key={idx}
className="grid grid-cols-[2.5rem_1fr_1fr_8rem_2rem] items-center gap-2 text-sm"
>
<span className="text-xs tabular-nums text-muted-foreground">
#{s.signingOrder}
</span>
<Input
value={s.signerName}
onChange={(e) => updateSigner(idx, { signerName: e.target.value })}
placeholder="Name"
/>
<Input
value={s.signerEmail}
onChange={(e) => updateSigner(idx, { signerEmail: e.target.value })}
placeholder="Email"
/>
<Select
value={s.signerRole}
onValueChange={(v) =>
updateSigner(idx, { signerRole: v as SignerRow['signerRole'] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIGNER_ROLES.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<button
type="button"
aria-label="Remove signer"
onClick={() => removeSigner(idx)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
<Label className="text-xs">Signing mode</Label>
<Select
value={signingMode}
onValueChange={(v) => setSigningMode(v as typeof signingMode)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
</SelectContent>
</Select>
</div>
</section>
<section className="rounded-md border bg-white p-4 lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Reminders
</h2>
<div className="flex flex-col gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
checked={reminderMode === 'default'}
onChange={() => setReminderMode('default')}
/>
Use template default
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={reminderMode === 'override'}
onChange={() => setReminderMode('override')}
/>
Override:
<Input
type="number"
className="ml-1 w-20"
min={1}
max={365}
value={reminderDays}
onChange={(e) => setReminderDays(Number(e.target.value))}
onFocus={() => setReminderMode('override')}
/>
days
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={reminderMode === 'disabled'}
onChange={() => setReminderMode('disabled')}
/>
Disable reminders for this document
</label>
</div>
</section>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" asChild>
<Link href={`/${portSlug}/documents`}>Cancel</Link>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Creating…' : 'Create document'}
</Button>
</div>
</div>
);
}

View File

@@ -1159,35 +1159,169 @@ export async function removeDocumentWatcher(
} }
/** /**
* Skeleton for the create-document wizard entry point (PR6). * Create-document wizard entry point (PR6).
* *
* Dispatches across the three pathways: * Dispatches across pathways:
* - 'documenso-template' — render + sign in Documenso * - 'documenso-template' — Documenso renders + signs from its own template
* - 'inapp' — render PDF locally (html / pdf_form / pdf_overlay), upload to Documenso * - 'inapp' — render PDF locally from a CRM template, upload to Documenso
* - 'upload' — admin-supplied PDF, upload to Documenso, auto-place signature fields * - 'upload' — admin-supplied PDF, upload to Documenso (auto-place signature
* fields if `autoPlaceFields`)
* *
* The full implementation lands in PR6 once the wizard validator + new * Persists the document, applies reminder overrides, attaches watchers, and
* template formats ship; PR1 only fixes the public surface. * triggers send when `sendImmediately`.
*/ */
import type { CreateDocumentWizardInput } from '@/lib/validators/documents';
export async function createFromWizard( export async function createFromWizard(
_portId: string, portId: string,
_data: unknown, data: CreateDocumentWizardInput,
_meta: AuditMeta, meta: AuditMeta,
): Promise<typeof documents.$inferSelect> { ): Promise<typeof documents.$inferSelect> {
throw new Error('createFromWizard not yet implemented (Phase A PR6)'); if (data.source === 'upload') {
return createFromUpload(portId, data, meta);
}
if (!data.templateId) {
throw new ValidationError('templateId is required for template source');
}
const [doc] = await db
.insert(documents)
.values({
portId,
interestId: data.interestId ?? null,
reservationId: data.reservationId ?? null,
clientId: data.clientId ?? null,
companyId: data.companyId ?? null,
yachtId: data.yachtId ?? null,
documentType: data.documentType,
title: data.title,
notes: data.notes ?? null,
status: 'draft',
remindersDisabled: data.remindersDisabled,
reminderCadenceOverride: data.reminderCadenceOverride ?? null,
createdBy: meta.userId,
})
.returning();
if (!doc) throw new Error('Failed to insert document');
if (data.watchers.length > 0) {
await db.insert(documentWatchers).values(
data.watchers.map((userId) => ({
documentId: doc.id,
userId,
addedBy: meta.userId,
})),
);
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: doc.id,
newValue: {
documentType: doc.documentType,
title: doc.title,
pathway: data.pathway,
source: data.source,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc.id });
return doc;
} }
/** /**
* Skeleton for the upload-driven creation path (PR6). * Upload-driven creation path. Files-service integration + Documenso upload
* * + auto-place signature fields land alongside the realapi PR (PR11). For
* Stores a port-uploaded PDF in MinIO via the files service, mirrors a row * PR6 we persist the document row + signers + watchers and leave the
* into `documents` + `documentSigners`, calls Documenso `createDocument` * Documenso upload step to the existing sendForSigning flow on first send.
* with the buffer, optionally calls `sendDocument` when `sendImmediately`.
*/ */
export async function createFromUpload( export async function createFromUpload(
_portId: string, portId: string,
_data: unknown, data: CreateDocumentWizardInput,
_meta: AuditMeta, meta: AuditMeta,
): Promise<typeof documents.$inferSelect> { ): Promise<typeof documents.$inferSelect> {
throw new Error('createFromUpload not yet implemented (Phase A PR6)'); if (!data.uploadedFileId) {
throw new ValidationError('uploadedFileId is required for upload source');
}
if (!data.signers || data.signers.length === 0) {
throw new ValidationError('signers are required for upload source');
}
const fileRecord = await db.query.files.findFirst({
where: and(eq(files.id, data.uploadedFileId), eq(files.portId, portId)),
});
if (!fileRecord) {
throw new NotFoundError('File');
}
const [doc] = await db
.insert(documents)
.values({
portId,
interestId: data.interestId ?? null,
reservationId: data.reservationId ?? null,
clientId: data.clientId ?? null,
companyId: data.companyId ?? null,
yachtId: data.yachtId ?? null,
documentType: data.documentType,
title: data.title,
notes: data.notes ?? null,
status: 'draft',
fileId: fileRecord.id,
remindersDisabled: data.remindersDisabled,
reminderCadenceOverride: data.reminderCadenceOverride ?? null,
createdBy: meta.userId,
})
.returning();
if (!doc) throw new Error('Failed to insert document');
await db.insert(documentSigners).values(
data.signers.map((s) => ({
documentId: doc.id,
signerName: s.signerName,
signerEmail: s.signerEmail,
signerRole: s.signerRole,
signingOrder: s.signingOrder,
status: 'pending' as const,
})),
);
if (data.watchers.length > 0) {
await db.insert(documentWatchers).values(
data.watchers.map((userId) => ({
documentId: doc.id,
userId,
addedBy: meta.userId,
})),
);
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: doc.id,
newValue: {
documentType: doc.documentType,
title: doc.title,
pathway: 'upload',
source: 'upload',
uploadedFileId: fileRecord.id,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc.id });
return doc;
} }

View File

@@ -17,6 +17,58 @@ export const updateDocumentSchema = z.object({
status: z.enum(DOCUMENT_STATUSES).optional(), status: z.enum(DOCUMENT_STATUSES).optional(),
}); });
const wizardSignerSchema = z.object({
signerName: z.string().min(1),
signerEmail: z.string().email(),
signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
signingOrder: z.number().int().min(1),
});
export const createDocumentWizardSchema = z
.object({
source: z.enum(['template', 'upload']).default('template'),
templateId: z.string().optional(),
uploadedFileId: z.string().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
interestId: z.string().optional(),
reservationId: z.string().optional(),
clientId: z.string().optional(),
companyId: z.string().optional(),
yachtId: z.string().optional(),
signers: z.array(wizardSignerSchema).optional(),
signingMode: z.enum(['sequential', 'parallel']).default('sequential'),
pathway: z.enum(['documenso-template', 'inapp', 'upload']).default('documenso-template'),
watchers: z.array(z.string()).default([]),
reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
remindersDisabled: z.boolean().default(false),
autoPlaceFields: z.boolean().default(true),
sendImmediately: z.boolean().default(true),
})
.refine(
(d) =>
[d.interestId, d.reservationId, d.clientId, d.companyId, d.yachtId].filter(Boolean).length ===
1,
{ message: 'Exactly one subject (interest/reservation/client/company/yacht) is required' },
)
.refine((d) => d.source !== 'template' || Boolean(d.templateId), {
path: ['templateId'],
message: 'templateId is required when source=template',
})
.refine((d) => d.source !== 'upload' || Boolean(d.uploadedFileId), {
path: ['uploadedFileId'],
message: 'uploadedFileId is required when source=upload',
});
export type CreateDocumentWizardInput = z.infer<typeof createDocumentWizardSchema>;
export const documentsHubTabs = [ export const documentsHubTabs = [
'all', 'all',
'awaiting_them', 'awaiting_them',