mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 11:12:06 +10:00
feat: generate recipients and fields based on recipients
This commit is contained in:
@ -137,13 +137,13 @@ const enforceMinimumFieldDimensions = (params: {
|
||||
};
|
||||
|
||||
const processAllPagesWithAI = async (params: {
|
||||
documentDataId: string;
|
||||
envelopeId: string;
|
||||
onProgress: (current: number, total: number) => void;
|
||||
}): Promise<{
|
||||
fieldsPerPage: Map<number, TDetectedFormField[]>;
|
||||
errors: Map<number, Error>;
|
||||
}> => {
|
||||
const { documentDataId, onProgress } = params;
|
||||
const { envelopeId, onProgress } = params;
|
||||
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||
const errors = new Map<number, Error>();
|
||||
|
||||
@ -156,7 +156,7 @@ const processAllPagesWithAI = async (params: {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ documentId: documentDataId }),
|
||||
body: JSON.stringify({ envelopeId }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
@ -359,10 +359,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
setProcessingProgress(null);
|
||||
|
||||
try {
|
||||
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
|
||||
if (!currentEnvelopeItem) {
|
||||
toast({
|
||||
title: t`Warning`,
|
||||
description: t`Please select a recipient before adding fields.`,
|
||||
title: t`Error`,
|
||||
description: t`No document selected. Please reload the page and try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@ -378,7 +378,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
|
||||
const { fieldsPerPage, errors } = await processAllPagesWithAI({
|
||||
documentDataId: currentEnvelopeItem.documentDataId,
|
||||
envelopeId: envelope.id,
|
||||
onProgress: (current, total) => {
|
||||
setProcessingProgress({ current, total });
|
||||
},
|
||||
@ -412,6 +412,22 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
|
||||
const fieldType = detected.label as FieldType;
|
||||
const resolvedRecipientId =
|
||||
envelope.recipients.find(
|
||||
(recipient) => recipient.id === detected.recipientId,
|
||||
)?.id ??
|
||||
editorFields.selectedRecipient?.id ??
|
||||
envelope.recipients[0]?.id;
|
||||
|
||||
if (!resolvedRecipientId) {
|
||||
console.warn(
|
||||
'Skipping detected field because no recipient could be resolved',
|
||||
{
|
||||
detectedRecipientId: detected.recipientId,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
editorFields.addField({
|
||||
@ -422,7 +438,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
recipientId: editorFields.selectedRecipient.id,
|
||||
recipientId: resolvedRecipientId,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||
});
|
||||
totalAdded++;
|
||||
|
||||
@ -27,7 +27,14 @@ import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-rou
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentAiPromptDialog } from '~/components/dialogs/document-ai-prompt-dialog';
|
||||
import { DocumentAiRecipientsDialog } from '~/components/dialogs/document-ai-recipients-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import {
|
||||
type RecipientForCreation,
|
||||
analyzeRecipientsFromDocument,
|
||||
ensureRecipientEmails,
|
||||
} from '~/utils/analyze-ai-recipients';
|
||||
|
||||
export interface EnvelopeDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
@ -52,6 +59,11 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
|
||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||
const [showAiRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
|
||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||
|
||||
const userTimezone =
|
||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||
@ -60,6 +72,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||
|
||||
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
|
||||
|
||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||
|
||||
@ -108,14 +121,18 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Show AI prompt dialog for documents
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShowAiRecipientsDialog(false);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowAiPromptDialog(true);
|
||||
} else {
|
||||
// Templates - navigate immediately
|
||||
const pathPrefix = formatTemplatesPath(team.url);
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
}
|
||||
|
||||
const pathPrefix =
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -201,6 +218,115 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToEnvelopeEditor = () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathPrefix = formatDocumentsPath(team.url);
|
||||
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
|
||||
};
|
||||
|
||||
const handleAiAccept = async () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recipients = await analyzeRecipientsFromDocument(uploadedDocumentId);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
toast({
|
||||
title: t`No recipients detected`,
|
||||
description: t`You can add recipients manually in the editor`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
throw new Error('NO_RECIPIENTS_DETECTED');
|
||||
}
|
||||
|
||||
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
|
||||
|
||||
setPendingRecipients(recipientsWithEmails);
|
||||
setShouldNavigateAfterPromptClose(false);
|
||||
setShowAiPromptDialog(false);
|
||||
setShowAiRecipientsDialog(true);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
|
||||
const parsedError = AppError.parseError(error);
|
||||
|
||||
toast({
|
||||
title: t`Failed to analyze recipients`,
|
||||
description: parsedError.userMessage || t`You can add recipients manually in the editor`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiSkip = () => {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowAiPromptDialog(false);
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsCancel = () => {
|
||||
setShowAiRecipientsDialog(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRecipients({
|
||||
envelopeId: uploadedDocumentId,
|
||||
data: recipientsToCreate,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowAiRecipientsDialog(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
} catch (error) {
|
||||
const parsedError = AppError.parseError(error);
|
||||
|
||||
toast({
|
||||
title: t`Failed to add recipients`,
|
||||
description: parsedError.userMessage || t`Please review the recipients and try again`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptDialogOpenChange = (open: boolean) => {
|
||||
setShowAiPromptDialog(open);
|
||||
|
||||
if (open) {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && shouldNavigateAfterPromptClose) {
|
||||
navigateToEnvelopeEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
@ -267,6 +393,27 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentAiPromptDialog
|
||||
open={showAiPromptDialog}
|
||||
onOpenChange={handlePromptDialogOpenChange}
|
||||
onAccept={handleAiAccept}
|
||||
onSkip={handleAiSkip}
|
||||
/>
|
||||
|
||||
<DocumentAiRecipientsDialog
|
||||
open={showAiRecipientsDialog}
|
||||
recipients={pendingRecipients}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleRecipientsCancel();
|
||||
} else {
|
||||
setShowAiRecipientsDialog(true);
|
||||
}
|
||||
}}
|
||||
onCancel={handleRecipientsCancel}
|
||||
onSubmit={handleRecipientsConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -27,7 +27,14 @@ import {
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentAiPromptDialog } from '~/components/dialogs/document-ai-prompt-dialog';
|
||||
import { DocumentAiRecipientsDialog } from '~/components/dialogs/document-ai-recipients-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import {
|
||||
type RecipientForCreation,
|
||||
analyzeRecipientsFromDocument,
|
||||
ensureRecipientEmails,
|
||||
} from '~/utils/analyze-ai-recipients';
|
||||
|
||||
export type EnvelopeUploadButtonProps = {
|
||||
className?: string;
|
||||
@ -55,8 +62,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
|
||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||
const [showAiRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
|
||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||
|
||||
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (organisation.subscription && remaining.documents === 0) {
|
||||
@ -108,16 +121,29 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
// Show AI prompt dialog for documents only
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShowAiRecipientsDialog(false);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowAiPromptDialog(true);
|
||||
|
||||
toast({
|
||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||
description:
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? t`Your document has been uploaded successfully.`
|
||||
: t`Your template has been uploaded successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
toast({
|
||||
title: t`Document uploaded`,
|
||||
description: t`Your document has been uploaded successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
// Templates - navigate immediately
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: t`Template uploaded`,
|
||||
description: t`Your template has been uploaded successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -169,6 +195,114 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToEnvelopeEditor = () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathPrefix = formatDocumentsPath(team.url);
|
||||
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
|
||||
};
|
||||
|
||||
const handleAiAccept = async () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recipients = await analyzeRecipientsFromDocument(uploadedDocumentId);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
toast({
|
||||
title: t`No recipients detected`,
|
||||
description: t`You can add recipients manually in the editor`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
throw new Error('NO_RECIPIENTS_DETECTED');
|
||||
}
|
||||
|
||||
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
|
||||
|
||||
setPendingRecipients(recipientsWithEmails);
|
||||
setShouldNavigateAfterPromptClose(false);
|
||||
setShowAiPromptDialog(false);
|
||||
setShowAiRecipientsDialog(true);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error && err.message === 'NO_RECIPIENTS_DETECTED')) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: t`Failed to analyze recipients`,
|
||||
description: error.userMessage || t`You can add recipients manually in the editor`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiSkip = () => {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowAiPromptDialog(false);
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsCancel = () => {
|
||||
setShowAiRecipientsDialog(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
};
|
||||
|
||||
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRecipients({
|
||||
envelopeId: uploadedDocumentId,
|
||||
data: recipientsToCreate,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowAiRecipientsDialog(false);
|
||||
setPendingRecipients(null);
|
||||
navigateToEnvelopeEditor();
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: t`Failed to add recipients`,
|
||||
description: error.userMessage || t`Please review the recipients and try again`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptDialogOpenChange = (open: boolean) => {
|
||||
setShowAiPromptDialog(open);
|
||||
|
||||
if (open) {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && shouldNavigateAfterPromptClose) {
|
||||
navigateToEnvelopeEditor();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<TooltipProvider>
|
||||
@ -201,6 +335,27 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DocumentAiPromptDialog
|
||||
open={showAiPromptDialog}
|
||||
onOpenChange={handlePromptDialogOpenChange}
|
||||
onAccept={handleAiAccept}
|
||||
onSkip={handleAiSkip}
|
||||
/>
|
||||
|
||||
<DocumentAiRecipientsDialog
|
||||
open={showAiRecipientsDialog}
|
||||
recipients={pendingRecipients}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleRecipientsCancel();
|
||||
} else {
|
||||
setShowAiRecipientsDialog(true);
|
||||
}
|
||||
}}
|
||||
onCancel={handleRecipientsCancel}
|
||||
onSubmit={handleRecipientsConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user