F27–F29, G30, G31, H32, H33 from the 2026-05-21 plan.
Shipped now:
F28 Past-milestones expandable history. The Past strip on the
Interest overview becomes an <Accordion> — each row collapses
to the same one-line summary as before, expands to render the
full <MilestoneSection> (steps list, sub-status, inline doc
actions). Reuses the existing MilestoneSection so no new
per-milestone rendering needs to be maintained.
F29 Watchers configurable at document creation time. The unified
create-document wizard gets a Watchers section with a
multi-select checkbox list backed by /api/v1/admin/users/picker.
Selected user ids are sent in the `watchers` array on the POST
(replacing the prior hardcoded `[]`). UI matches the
post-creation WatchersCard so reps see the same identity rows
regardless of entry point.
G30 /admin/invitations merged into /admin/users. The Users page
now wraps the existing UserList + InvitationsManager in a
Tabs control (Active users / Invitations). The standalone
/admin/invitations route returns a redirect to the merged page
for bookmark back-compat. Removed nav catalog entry +
admin-sections-browser tile; extended the Users catalog
keywords with "invitations / pending invites / onboarding"
so command-K search still lands on the right surface.
G31 /admin/ai picks up the berth-PDF-parser section + a "planned
AI surfaces" placeholder. Berth PDF parser remains
env-configured today; the page now documents it so admins
don't hunt for the controls. Closes the "where do I configure
AI?" loop.
H32 Email settings explainer panel above the SMTP cards. Spells
out why noreply + sales have separate credentials and which
workflows ship from each mailbox. Existing field titles
gained the "(noreply)" suffix so the model maps cleanly.
H33 Supplemental-info-request email rebuilt to use the shared
branded shell (logo + blurred overhead background + max-
width 600 table layout) instead of the prior plain-HTML
page. Per-port branding (logo / primary color / background /
header / footer) flows from getPortBrandingConfig. CTA
button picks up the port's primary color.
Already shipped (verified pre-shipped):
F27 DocumentsHub root view already hides the breadcrumb via
`selectedFolderId !== undefined` conditional.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
540 lines
20 KiB
TypeScript
540 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { ArrowLeft, Eye, 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 { ClientPicker } from '@/components/shared/client-picker';
|
|
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 } from '@/lib/constants';
|
|
|
|
// Display labels for SIGNER_ROLES — internal values stay lowercase, UI shows
|
|
// capitalized. Falls back to capitalize-first-letter for any value not in the
|
|
// explicit map.
|
|
const SIGNER_ROLE_LABELS: Record<string, string> = {
|
|
client: 'Client',
|
|
sales: 'Sales',
|
|
approver: 'Approver',
|
|
developer: 'Developer',
|
|
other: 'Other',
|
|
};
|
|
function formatSignerRole(r: string): string {
|
|
return SIGNER_ROLE_LABELS[r] ?? r.charAt(0).toUpperCase() + r.slice(1);
|
|
}
|
|
|
|
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('');
|
|
|
|
// 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
|
|
// post-creation WatchersCard exposes, but lets the rep wire it
|
|
// upfront instead of digging into the detail page afterwards.
|
|
const [watcherUserIds, setWatcherUserIds] = useState<string[]>([]);
|
|
const [watcherUsers, setWatcherUsers] = useState<
|
|
Array<{ id: string; name: string | null; email: string | null }>
|
|
>([]);
|
|
useEffect(() => {
|
|
void apiFetch<{
|
|
data: Array<{ id: string; name: string | null; email: string | null }>;
|
|
}>('/api/v1/admin/users/picker')
|
|
.then((res) => setWatcherUsers(res.data ?? []))
|
|
.catch(() => setWatcherUsers([]));
|
|
}, []);
|
|
|
|
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: watcherUserIds,
|
|
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) {
|
|
toastError(err);
|
|
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" aria-hidden /> 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">
|
|
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>
|
|
</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);
|
|
// Reset subject id when the type changes — pickers are
|
|
// type-specific and old ids belong to the wrong table.
|
|
setSubjectId('');
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-32">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SUBJECT_TYPES.map((s) => (
|
|
<SelectItem key={s.key} value={s.key}>
|
|
{s.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{subjectType === 'client' ? (
|
|
<ClientPicker value={subjectId || null} onChange={(id) => setSubjectId(id ?? '')} />
|
|
) : subjectType === 'company' ? (
|
|
<CompanyPicker
|
|
value={subjectId || null}
|
|
onChange={(id) => setSubjectId(id ?? '')}
|
|
/>
|
|
) : subjectType === 'yacht' ? (
|
|
<YachtPicker value={subjectId || null} onChange={(id) => setSubjectId(id ?? '')} />
|
|
) : subjectType === 'interest' ? (
|
|
<InterestPicker
|
|
value={subjectId || null}
|
|
onChange={(id) => setSubjectId(id ?? '')}
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={subjectId}
|
|
onChange={(e) => setSubjectId(e.target.value)}
|
|
placeholder="Reservation 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" aria-hidden /> 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 className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SIGNER_ROLES.map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{formatSignerRole(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" aria-hidden />
|
|
</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 flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
<Eye className="size-3.5" aria-hidden />
|
|
Watchers
|
|
</h2>
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
Selected users receive an in-app notification on every signing event (opened, signed,
|
|
declined, completed). Can be edited from the document detail page after creation.
|
|
</p>
|
|
{watcherUsers.length === 0 ? (
|
|
<p className="text-xs italic text-muted-foreground">No users available to add.</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
|
{watcherUsers.map((u) => {
|
|
const checked = watcherUserIds.includes(u.id);
|
|
return (
|
|
<label
|
|
key={u.id}
|
|
className="flex cursor-pointer items-center gap-2 rounded-sm px-1 py-0.5 text-sm hover:bg-muted/50"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() =>
|
|
setWatcherUserIds((prev) =>
|
|
checked ? prev.filter((id) => id !== u.id) : [...prev, u.id],
|
|
)
|
|
}
|
|
/>
|
|
<span className="truncate">
|
|
{u.name ?? u.email ?? `User ${u.id.slice(0, 8)}…`}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|