feat: assistant role (#1588)
Introduces the ability for users with the **Assistant** role to prefill fields on behalf of other signers. Assistants can fill in various field types such as text, checkboxes, dates, and more, streamlining the document preparation process before it reaches the final signers.
This commit is contained in:
committed by
David Nguyen
parent
3e106c1a2d
commit
c0ae68c28b
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
@@ -11,6 +13,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -18,8 +21,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||
|
||||
@@ -29,6 +35,8 @@ export type DocumentSigningFormProps = {
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
@@ -37,20 +45,35 @@ export const DocumentSigningForm = ({
|
||||
fields,
|
||||
redirectUrl,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
}: DocumentSigningFormProps) => {
|
||||
const { user } = useOptionalSession();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { user } = useOptionalSession();
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
|
||||
useRequiredDocumentSigningContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||
defaultValues: {
|
||||
selectedSignerId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, formState } = useForm();
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
@@ -65,7 +88,11 @@ export const DocumentSigningForm = ({
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
}, [fieldsRequiringValidation]);
|
||||
|
||||
const uninsertedRecipientFields = useMemo(() => {
|
||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||
}, [fieldsRequiringValidation, recipient]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
@@ -86,12 +113,31 @@ export const DocumentSigningForm = ({
|
||||
}
|
||||
|
||||
await completeDocument();
|
||||
};
|
||||
|
||||
// Reauth is currently not required for completing the document.
|
||||
// await executeActionAuthProcedure({
|
||||
// onReauthFormSubmit: completeDocument,
|
||||
// actionTarget: 'DOCUMENT',
|
||||
// });
|
||||
const onAssistantFormSubmit = () => {
|
||||
if (uninsertedRecipientFields.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAssistantConfirmDialogSubmit = async () => {
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsAssistantSubmitting(false);
|
||||
setIsConfirmationDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||
@@ -115,7 +161,7 @@ export const DocumentSigningForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
<div
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
||||
{
|
||||
@@ -123,7 +169,6 @@ export const DocumentSigningForm = ({
|
||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
|
||||
},
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
@@ -131,17 +176,13 @@ export const DocumentSigningForm = ({
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
||||
</h3>
|
||||
|
||||
{recipient.role === RecipientRole.VIEWER ? (
|
||||
@@ -178,91 +219,185 @@ export const DocumentSigningForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||
<>
|
||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
Complete the fields for the following signers. Once reviewed, they will inform
|
||||
you if any modifications are needed.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border my-4" />
|
||||
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||
<Controller
|
||||
name="selectedSignerId"
|
||||
control={assistantForm.control}
|
||||
rules={{ required: 'Please select a signer' }}
|
||||
render={({ field }) => (
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
setSelectedSignerId?.(Number(value));
|
||||
}}
|
||||
>
|
||||
{allRecipients
|
||||
.filter((r) => r.fields.length > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
id={`${assistantSignersId}-${r.id}`}
|
||||
value={r.id.toString()}
|
||||
className="after:absolute after:inset-0"
|
||||
/>
|
||||
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
className="inline-flex items-start"
|
||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
||||
>
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={isAssistantSubmitting}
|
||||
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
|
||||
>
|
||||
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AssistantConfirmationDialog
|
||||
hasUninsertedFields={uninsertedFields.length > 0}
|
||||
isOpen={isConfirmationDialogOpen}
|
||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||
isSubmitting={isAssistantSubmitting}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (signatureValid) {
|
||||
setSignature(value);
|
||||
}
|
||||
}}
|
||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={async () => navigate(-1)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
disabled={!isRecipientsTurn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (signatureValid) {
|
||||
setSignature(value);
|
||||
}
|
||||
}}
|
||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={async () => navigate(-1)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
disabled={!isRecipientsTurn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user