feat(documents): universal upload-with-fields UI wiring (B3 #11)
Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.
- UploadForSigningDialog: interestId now string | null; new entity?,
folderId?, onCreated? props. Generic path POSTs to new endpoint
/api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
lookup, pipeline-stage advance, doc-status flip on the generic path.
Routes file FK + auto-filed folder via either interest.clientId or the
caller-supplied entity. Validation enforces the matching invariant
(generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
gated by documents.send_for_signing.
Existing unit tests for the service still pass (validation paths unchanged).
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, FileSignature, Plus, Upload } from 'lucide-react';
|
||||
import { ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
|
||||
/**
|
||||
* Dropdown that replaces the bare "+ New document" button on the documents
|
||||
@@ -55,6 +56,7 @@ export function NewDocumentMenu({
|
||||
size = 'default',
|
||||
}: NewDocumentMenuProps) {
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
@@ -77,6 +79,15 @@ export function NewDocumentMenu({
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setUploadForSigningOpen(true)} className="gap-2 py-2.5">
|
||||
<Pen className="h-4 w-4" aria-hidden />
|
||||
<div className="flex flex-col">
|
||||
<span>Upload & send for signature</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Drop a PDF, place fields, send via Documenso
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="gap-2 py-2.5">
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<FileSignature className="h-4 w-4" aria-hidden />
|
||||
@@ -123,6 +134,17 @@ export function NewDocumentMenu({
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{uploadForSigningOpen && (
|
||||
<UploadForSigningDialog
|
||||
open={uploadForSigningOpen}
|
||||
onOpenChange={setUploadForSigningOpen}
|
||||
interestId={null}
|
||||
documentType="generic"
|
||||
entity={entityType && entityId ? { type: entityType, id: entityId } : undefined}
|
||||
folderId={folderId ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,17 +139,37 @@ const RECIPIENT_COLORS = [
|
||||
'rgb(20 184 166)', // teal-500
|
||||
];
|
||||
|
||||
export interface UploadForSigningEntity {
|
||||
type: 'client' | 'company' | 'yacht';
|
||||
id: string;
|
||||
/** Display label only — used in the dialog header so the rep can
|
||||
* see which entity the doc will be filed under. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface UploadForSigningDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interestId: string;
|
||||
/** Pre-set the document type - the parent (EOI/Contract/Reservation
|
||||
* tab) decides which to upload. EOI here is the upload-draft path;
|
||||
* the template-driven generate flow lives on EoiGenerateDialog. */
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement';
|
||||
/** Required for eoi / contract / reservation_agreement (the pipeline
|
||||
* side effects need it). MUST be null for documentType='generic' —
|
||||
* in that case the upload routes through the generic endpoint and
|
||||
* optionally files the doc against the supplied `entity`. */
|
||||
interestId: string | null;
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
|
||||
/** Optional: client name/email to prefill the first recipient.
|
||||
* When omitted the dialog fetches from the interest. */
|
||||
* When omitted the dialog fetches from the interest (interest-scoped
|
||||
* flows) or leaves the recipient blank (generic flow). */
|
||||
clientPrefill?: { name: string; email: string };
|
||||
/** Generic flow only: routes the resulting file/document row to the
|
||||
* entity's FK column + auto-files it into the entity's system
|
||||
* folder. Ignored when `interestId` is set. */
|
||||
entity?: UploadForSigningEntity;
|
||||
/** Generic flow only: explicit folder placement (e.g. rep is
|
||||
* uploading from within a Documents Hub folder). */
|
||||
folderId?: string | null;
|
||||
/** Generic flow only: caller-supplied success hook. Receives the
|
||||
* new documentId and can invalidate caches / show a toast. */
|
||||
onCreated?: (result: { documentId: string }) => void;
|
||||
}
|
||||
|
||||
export function UploadForSigningDialog({
|
||||
@@ -158,18 +178,25 @@ export function UploadForSigningDialog({
|
||||
interestId,
|
||||
documentType,
|
||||
clientPrefill,
|
||||
entity,
|
||||
folderId,
|
||||
onCreated,
|
||||
}: UploadForSigningDialogProps) {
|
||||
// Re-mount the body on every open so all state resets cleanly. Same
|
||||
// pattern as hard-delete-dialog (set-state-in-effect avoidance).
|
||||
if (!open) return null;
|
||||
const draftKey = interestId ?? entity?.id ?? 'generic';
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
|
||||
<DialogBody
|
||||
key={`${interestId}:${documentType}`}
|
||||
key={`${draftKey}:${documentType}`}
|
||||
interestId={interestId}
|
||||
documentType={documentType}
|
||||
clientPrefill={clientPrefill}
|
||||
entity={entity}
|
||||
folderId={folderId ?? null}
|
||||
onCreated={onCreated}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -186,8 +213,8 @@ type Step = 'select-file' | 'configure-recipients' | 'place-fields';
|
||||
* contract upload AND reservation upload in the same browser session
|
||||
* without them clobbering each other.
|
||||
*/
|
||||
function draftStorageKey(interestId: string, documentType: string): string {
|
||||
return `pn-crm.upload-for-signing.draft.v1:${interestId}:${documentType}`;
|
||||
function draftStorageKey(scopeId: string, documentType: string): string {
|
||||
return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`;
|
||||
}
|
||||
|
||||
interface PersistedDraft {
|
||||
@@ -200,10 +227,10 @@ interface PersistedDraft {
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function loadDraft(interestId: string, documentType: string): PersistedDraft | null {
|
||||
function loadDraft(scopeId: string, documentType: string): PersistedDraft | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(draftStorageKey(interestId, documentType));
|
||||
const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PersistedDraft;
|
||||
// Defensive shape check - drop drafts that look malformed rather
|
||||
@@ -221,19 +248,19 @@ function loadDraft(interestId: string, documentType: string): PersistedDraft | n
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(interestId: string, documentType: string, draft: PersistedDraft): void {
|
||||
function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft));
|
||||
window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft));
|
||||
} catch {
|
||||
// localStorage may throw on private mode or quota - swallow.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(interestId: string, documentType: string): void {
|
||||
function clearDraft(scopeId: string, documentType: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.removeItem(draftStorageKey(interestId, documentType));
|
||||
window.localStorage.removeItem(draftStorageKey(scopeId, documentType));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -243,19 +270,30 @@ function DialogBody({
|
||||
interestId,
|
||||
documentType,
|
||||
clientPrefill,
|
||||
entity,
|
||||
folderId,
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
interestId: string;
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement';
|
||||
interestId: string | null;
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
|
||||
clientPrefill?: { name: string; email: string };
|
||||
entity?: UploadForSigningEntity;
|
||||
folderId?: string | null;
|
||||
onCreated?: (result: { documentId: string }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Draft scope: interestId when scoped to a deal, otherwise the
|
||||
// entity id (so the rep can have one in-flight upload per entity),
|
||||
// else 'generic' for the root Documents Hub flow.
|
||||
const draftScopeId = interestId ?? entity?.id ?? 'generic';
|
||||
|
||||
// Hydrate from the persisted draft once on mount. The `key` prop on
|
||||
// the parent re-mounts this body on every open, so this useState
|
||||
// initializer runs once per dialog session.
|
||||
const initialDraft = useMemo(
|
||||
() => loadDraft(interestId, documentType),
|
||||
[interestId, documentType],
|
||||
() => loadDraft(draftScopeId, documentType),
|
||||
[draftScopeId, documentType],
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<Step>(initialDraft?.step ?? 'select-file');
|
||||
@@ -275,7 +313,9 @@ function DialogBody({
|
||||
? 'Sales Contract'
|
||||
: documentType === 'eoi'
|
||||
? 'Expression of Interest'
|
||||
: 'Reservation Agreement';
|
||||
: documentType === 'reservation_agreement'
|
||||
? 'Reservation Agreement'
|
||||
: 'Document';
|
||||
|
||||
// Defaults endpoint - drives the developer/approver prefill.
|
||||
const { data: defaults } = useQuery<{ data: SigningDefaults }>({
|
||||
@@ -285,7 +325,7 @@ function DialogBody({
|
||||
|
||||
// Interest endpoint - used to prefill the client recipient when the
|
||||
// caller didn't supply one. Cached so the same dialog open/reopen
|
||||
// hits the cache.
|
||||
// hits the cache. Skipped entirely on the generic path (no interest).
|
||||
const { data: interestData } = useQuery<{
|
||||
data: { client: { fullName: string; email: string | null } };
|
||||
}>({
|
||||
@@ -294,7 +334,7 @@ function DialogBody({
|
||||
apiFetch<{ data: { client: { fullName: string; email: string | null } } }>(
|
||||
`/api/v1/interests/${interestId}`,
|
||||
),
|
||||
enabled: !clientPrefill,
|
||||
enabled: Boolean(interestId) && !clientPrefill,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -381,11 +421,11 @@ function DialogBody({
|
||||
fields.length > 0 ||
|
||||
invitationMessage.length > 0;
|
||||
if (!hasProgress) {
|
||||
clearDraft(interestId, documentType);
|
||||
clearDraft(draftScopeId, documentType);
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
saveDraft(interestId, documentType, {
|
||||
saveDraft(draftScopeId, documentType, {
|
||||
step,
|
||||
title,
|
||||
recipients,
|
||||
@@ -399,10 +439,10 @@ function DialogBody({
|
||||
return () => {
|
||||
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
|
||||
};
|
||||
}, [step, title, recipients, fields, invitationMessage, interestId, documentType]);
|
||||
}, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]);
|
||||
|
||||
function discardDraft() {
|
||||
clearDraft(interestId, documentType);
|
||||
clearDraft(draftScopeId, documentType);
|
||||
setTitle('');
|
||||
setRecipients([]);
|
||||
setFields([]);
|
||||
@@ -479,7 +519,23 @@ function DialogBody({
|
||||
})),
|
||||
),
|
||||
);
|
||||
const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, {
|
||||
// Generic envelopes go to the cross-cutting endpoint; the
|
||||
// entity / folder context piggybacks on the form so the file
|
||||
// row lands under the right system folder. Interest-scoped
|
||||
// flows keep their dedicated route so the pipeline-stage
|
||||
// advance + doc-status flip side effects fire.
|
||||
if (interestId) {
|
||||
if (documentType === 'generic') {
|
||||
throw new Error('Generic documentType requires interestId=null');
|
||||
}
|
||||
} else {
|
||||
if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id }));
|
||||
if (folderId) form.append('folderId', folderId);
|
||||
}
|
||||
const endpoint = interestId
|
||||
? `/api/v1/interests/${interestId}/upload-for-signing`
|
||||
: `/api/v1/upload-for-signing`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
@@ -506,11 +562,14 @@ function DialogBody({
|
||||
);
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
|
||||
void res;
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
|
||||
if (onCreated && res?.data?.documentId) {
|
||||
onCreated({ documentId: res.data.documentId });
|
||||
}
|
||||
// Clear the draft on successful submission - the in-flight upload
|
||||
// is now an actual document; the localStorage shouldn't keep its
|
||||
// shadow around.
|
||||
clearDraft(interestId, documentType);
|
||||
clearDraft(draftScopeId, documentType);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => toastError(err, 'Upload failed'),
|
||||
|
||||
Reference in New Issue
Block a user