mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Compare commits
5 Commits
fix/leader
...
feat/dicta
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6942f9da | |||
| 8b82d22f9f | |||
| 00e402f4cb | |||
| 1e90ca45a6 | |||
| 4189a34de0 |
@ -86,12 +86,11 @@ You can also set the recipient's role, which determines their actions and permis
|
|||||||
Documenso has 4 roles for recipients with different permissions and actions.
|
Documenso has 4 roles for recipients with different permissions and actions.
|
||||||
|
|
||||||
| Role | Function | Action required | Signature |
|
| Role | Function | Action required | Signature |
|
||||||
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||||
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||||
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
|
|||||||
@ -16,13 +16,9 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
export type SigningVolume = {
|
export type SigningVolume = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
planId: string;
|
||||||
userId?: number | null;
|
|
||||||
teamId?: number | null;
|
|
||||||
isTeam: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardTableProps = {
|
type LeaderboardTableProps = {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
import { LeaderboardTable, type SigningVolume } from './data-table-leaderboard';
|
import { LeaderboardTable } from './data-table-leaderboard';
|
||||||
import { search } from './fetch-leaderboard.actions';
|
import { search } from './fetch-leaderboard.actions';
|
||||||
|
|
||||||
type AdminLeaderboardProps = {
|
type AdminLeaderboardProps = {
|
||||||
@ -32,7 +32,7 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
|
|||||||
const sortBy = searchParams.sortBy || 'signingVolume';
|
const sortBy = searchParams.sortBy || 'signingVolume';
|
||||||
const sortOrder = searchParams.sortOrder || 'desc';
|
const sortOrder = searchParams.sortOrder || 'desc';
|
||||||
|
|
||||||
const { leaderboard, totalPages } = await search({
|
const { leaderboard: signingVolume, totalPages } = await search({
|
||||||
search: searchString,
|
search: searchString,
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
@ -40,22 +40,14 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
|
|
||||||
...item,
|
|
||||||
name: item.name || '',
|
|
||||||
createdAt: item.createdAt || new Date(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center">
|
|
||||||
<h2 className="text-4xl font-semibold">
|
<h2 className="text-4xl font-semibold">
|
||||||
<Trans>Signing Volume</Trans>
|
<Trans>Signing Volume</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<LeaderboardTable
|
<LeaderboardTable
|
||||||
signingVolume={typedSigningVolume}
|
signingVolume={signingVolume}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
page={page}
|
page={page}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
|
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
@ -85,19 +85,6 @@ export const EditDocumentForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: setSigningOrderForDocument } =
|
|
||||||
trpc.document.setSigningOrderForDocument.useMutation({
|
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
|
||||||
onSuccess: (newData) => {
|
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
|
||||||
{
|
|
||||||
documentId: initialDocument.id,
|
|
||||||
},
|
|
||||||
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: ({ fields: newFields }) => {
|
onSuccess: ({ fields: newFields }) => {
|
||||||
@ -216,9 +203,12 @@ export const EditDocumentForm = ({
|
|||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
setSigningOrderForDocument({
|
updateDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
modifyNextSigner: data.modifyNextSigner,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
@ -391,6 +381,7 @@ export const EditDocumentForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
|
modifyNextSigner={document.documentMeta?.modifyNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
|||||||
@ -76,6 +76,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
? {
|
? {
|
||||||
...templateMeta,
|
...templateMeta,
|
||||||
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
modifyNextSigner: templateMeta.modifyNextSigner ?? false,
|
||||||
documentId: 0,
|
documentId: 0,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@ -15,7 +15,12 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
@ -40,6 +45,12 @@ export type SigningFormProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
|
isLastRecipient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SigningFormData = {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningForm = ({
|
export const SigningForm = ({
|
||||||
@ -50,6 +61,7 @@ export const SigningForm = ({
|
|||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
setSelectedSignerId,
|
setSelectedSignerId,
|
||||||
|
isLastRecipient,
|
||||||
}: SigningFormProps) => {
|
}: SigningFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -77,7 +89,7 @@ export const SigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
const { handleSubmit, formState } = useForm<SigningFormData>();
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
@ -102,20 +114,58 @@ export const SigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const completeDocument = async (
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: document.id,
|
||||||
|
authOptions,
|
||||||
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
signerId: recipient.id,
|
||||||
|
documentId: document.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: SigningFormData) => {
|
||||||
|
try {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
if (hasSignatureField && !signatureValid) {
|
||||||
return;
|
throw new Error('Please provide a valid signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
if (!isFieldsValid) {
|
||||||
return;
|
throw new Error('Please complete all required fields');
|
||||||
}
|
}
|
||||||
|
|
||||||
await completeDocument();
|
const nextSigner =
|
||||||
|
data.email && data.name
|
||||||
|
? {
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while completing the document. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@ -143,22 +193,6 @@ export const SigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
|
||||||
await completeDocumentWithToken({
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: document.id,
|
|
||||||
authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -208,12 +242,21 @@ export const SigningForm = ({
|
|||||||
|
|
||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await handleSubmit(async (formData) =>
|
||||||
|
onFormSubmit({ ...formData, ...nextSigner }),
|
||||||
|
)();
|
||||||
|
}}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
|
canModifyNextSigner={
|
||||||
|
document.documentMeta?.modifyNextSigner &&
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||||
|
!isLastRecipient
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -383,12 +426,21 @@ export const SigningForm = ({
|
|||||||
|
|
||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await handleSubmit(async (formData) =>
|
||||||
|
onFormSubmit({ ...formData, ...nextSigner }),
|
||||||
|
)();
|
||||||
|
}}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
|
canModifyNextSigner={
|
||||||
|
document.documentMeta?.modifyNextSigner &&
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||||
|
!isLastRecipient
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
|
|||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { getIsLastRecipient } from '@documenso/lib/server-only/recipient/get-is-last-recipient';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@ -44,7 +45,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, recipient, fields, completedFields] = await Promise.all([
|
const [document, recipient, fields, completedFields, isLastRecipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@ -53,6 +54,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getCompletedFieldsForToken({ token }),
|
getCompletedFieldsForToken({ token }),
|
||||||
|
getIsLastRecipient({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -169,6 +171,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
completedFields={completedFields}
|
completedFields={completedFields}
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
|
isLastRecipient={isLastRecipient}
|
||||||
/>
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
|
|||||||
@ -37,11 +37,6 @@ export const RecipientProvider = ({
|
|||||||
recipient,
|
recipient,
|
||||||
targetSigner = null,
|
targetSigner = null,
|
||||||
}: RecipientProviderProps) => {
|
}: RecipientProviderProps) => {
|
||||||
// console.log({
|
|
||||||
// recipient,
|
|
||||||
// targetSigner,
|
|
||||||
// isAssistantMode: !!targetSigner,
|
|
||||||
// });
|
|
||||||
return (
|
return (
|
||||||
<RecipientContext.Provider
|
<RecipientContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@ -1,18 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import type { Field } from '@documenso/prisma/client';
|
import type { Field } from '@documenso/prisma/client';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||||
|
|
||||||
@ -21,12 +39,26 @@ export type SignDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: (nextSigner?: { email?: string; name?: string }) => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
canModifyNextSigner?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignDialog = ({
|
const formSchema = z.object({
|
||||||
|
modifyNextSigner: z.boolean().default(false),
|
||||||
|
nextSigner: z
|
||||||
|
.object({
|
||||||
|
email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function SignDialog({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
fields,
|
fields,
|
||||||
@ -34,7 +66,9 @@ export const SignDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: SignDialogProps) => {
|
canModifyNextSigner = false,
|
||||||
|
}: SignDialogProps) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
@ -47,7 +81,37 @@ export const SignDialog = ({
|
|||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalSteps = 2;
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (step < totalSteps) {
|
||||||
|
setStep(step + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<TFormSchema>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TFormSchema) => {
|
||||||
|
try {
|
||||||
|
await fieldsValidated();
|
||||||
|
|
||||||
|
await onSignatureComplete({
|
||||||
|
email: data.nextSigner.email?.trim().toLowerCase(),
|
||||||
|
name: data.nextSigner.name?.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowDialog(false);
|
||||||
|
form.reset();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{!canModifyNextSigner ? (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -136,7 +200,10 @@ export const SignDialog = ({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!isComplete}
|
disabled={!isComplete}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSignatureComplete();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||||
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||||
@ -146,5 +213,204 @@ export const SignDialog = ({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
) : (
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) setStep(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
onClick={fieldsValidated}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="text-foreground text-base font-semibold">
|
||||||
|
<Trans>
|
||||||
|
Modify Next Signer <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="text-foreground text-xl font-semibold">
|
||||||
|
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
||||||
|
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
||||||
|
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)} className="flex flex-col gap-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="modifyNextSigner"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">
|
||||||
|
<Trans>Modify next signer details</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.watch('modifyNextSigner') && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nextSigner.email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Next Signer Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nextSigner.name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Next Signer Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
|
{role === RecipientRole.VIEWER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete viewing "
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.SIGNER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete signing "
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.APPROVER && (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete approving{' '}
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
"{documentTitle}"
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningDisclosure className="mt-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex justify-center space-x-1.5 max-sm:order-1">
|
||||||
|
{[...Array(totalSteps)].map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setStep(index + 1)}
|
||||||
|
className={cn(
|
||||||
|
'bg-primary h-1.5 w-1.5 rounded-full',
|
||||||
|
index + 1 === step ? 'bg-primary' : 'opacity-20',
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Go to step ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Button className="group" type="button" onClick={handleContinue}>
|
||||||
|
Next
|
||||||
|
<ArrowRight
|
||||||
|
className="-me-1 ms-2 opacity-60 transition-transform group-hover:translate-x-0.5"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={form.handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||||
|
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||||
|
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export type SigningPageViewProps = {
|
|||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
|
isLastRecipient: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningPageView = ({
|
export const SigningPageView = ({
|
||||||
@ -58,6 +59,7 @@ export const SigningPageView = ({
|
|||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
|
isLastRecipient,
|
||||||
}: SigningPageViewProps) => {
|
}: SigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
@ -159,6 +161,7 @@ export const SigningPageView = ({
|
|||||||
redirectUrl={documentMeta?.redirectUrl}
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
|
isLastRecipient={isLastRecipient}
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
console.log({ signature });
|
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
type GetSigningVolumeOptions = {
|
export type SigningVolume = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
signingVolume: number;
|
||||||
|
createdAt: Date;
|
||||||
|
planId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSigningVolumeOptions = {
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
@ -9,187 +17,85 @@ type GetSigningVolumeOptions = {
|
|||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSigningVolume = async ({
|
export async function getSigningVolume({
|
||||||
search = '',
|
search = '',
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
sortBy = 'signingVolume',
|
sortBy = 'signingVolume',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
}: GetSigningVolumeOptions) => {
|
}: GetSigningVolumeOptions) {
|
||||||
const validPage = Math.max(1, page);
|
const offset = Math.max(page - 1, 0) * perPage;
|
||||||
const validPerPage = Math.max(1, perPage);
|
|
||||||
const skip = (validPage - 1) * validPerPage;
|
|
||||||
|
|
||||||
const activeSubscriptions = await prisma.subscription.findMany({
|
let findQuery = kyselyPrisma.$kysely
|
||||||
where: {
|
.selectFrom('Subscription as s')
|
||||||
status: SubscriptionStatus.ACTIVE,
|
.leftJoin('User as u', 's.userId', 'u.id')
|
||||||
},
|
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||||
select: {
|
.leftJoin('Document as ud', (join) =>
|
||||||
id: true,
|
join
|
||||||
planId: true,
|
.onRef('u.id', '=', 'ud.userId')
|
||||||
userId: true,
|
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||||
teamId: true,
|
.on('ud.deletedAt', 'is', null)
|
||||||
createdAt: true,
|
.on('ud.teamId', 'is', null),
|
||||||
user: {
|
)
|
||||||
select: {
|
.leftJoin('Document as td', (join) =>
|
||||||
id: true,
|
join
|
||||||
name: true,
|
.onRef('t.id', '=', 'td.teamId')
|
||||||
email: true,
|
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||||
createdAt: true,
|
.on('td.deletedAt', 'is', null),
|
||||||
},
|
)
|
||||||
},
|
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||||
team: {
|
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||||
select: {
|
.where((eb) =>
|
||||||
id: true,
|
eb.or([
|
||||||
name: true,
|
eb('u.name', 'ilike', `%${search}%`),
|
||||||
teamEmail: {
|
eb('u.email', 'ilike', `%${search}%`),
|
||||||
select: {
|
eb('t.name', 'ilike', `%${search}%`),
|
||||||
email: true,
|
]),
|
||||||
},
|
)
|
||||||
},
|
.select([
|
||||||
createdAt: true,
|
's.id as id',
|
||||||
},
|
's.createdAt as createdAt',
|
||||||
},
|
's.planId as planId',
|
||||||
},
|
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
|
||||||
});
|
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
|
||||||
|
])
|
||||||
|
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
|
||||||
|
|
||||||
const userSubscriptionsMap = new Map();
|
switch (sortBy) {
|
||||||
const teamSubscriptionsMap = new Map();
|
case 'name':
|
||||||
|
findQuery = findQuery.orderBy('name', sortOrder);
|
||||||
activeSubscriptions.forEach((subscription) => {
|
break;
|
||||||
const isTeam = !!subscription.teamId;
|
case 'createdAt':
|
||||||
|
findQuery = findQuery.orderBy('createdAt', sortOrder);
|
||||||
if (isTeam && subscription.teamId) {
|
break;
|
||||||
if (!teamSubscriptionsMap.has(subscription.teamId)) {
|
case 'signingVolume':
|
||||||
teamSubscriptionsMap.set(subscription.teamId, {
|
findQuery = findQuery.orderBy('signingVolume', sortOrder);
|
||||||
id: subscription.id,
|
break;
|
||||||
planId: subscription.planId,
|
default:
|
||||||
teamId: subscription.teamId,
|
findQuery = findQuery.orderBy('signingVolume', 'desc');
|
||||||
name: subscription.team?.name || '',
|
|
||||||
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
|
|
||||||
createdAt: subscription.team?.createdAt,
|
|
||||||
isTeam: true,
|
|
||||||
subscriptionIds: [subscription.id],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
|
|
||||||
existingTeam.subscriptionIds.push(subscription.id);
|
|
||||||
}
|
|
||||||
} else if (subscription.userId) {
|
|
||||||
if (!userSubscriptionsMap.has(subscription.userId)) {
|
|
||||||
userSubscriptionsMap.set(subscription.userId, {
|
|
||||||
id: subscription.id,
|
|
||||||
planId: subscription.planId,
|
|
||||||
userId: subscription.userId,
|
|
||||||
name: subscription.user?.name || '',
|
|
||||||
email: subscription.user?.email || '',
|
|
||||||
createdAt: subscription.user?.createdAt,
|
|
||||||
isTeam: false,
|
|
||||||
subscriptionIds: [subscription.id],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const existingUser = userSubscriptionsMap.get(subscription.userId);
|
|
||||||
existingUser.subscriptionIds.push(subscription.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscriptions = [
|
|
||||||
...Array.from(userSubscriptionsMap.values()),
|
|
||||||
...Array.from(teamSubscriptionsMap.values()),
|
|
||||||
];
|
|
||||||
|
|
||||||
const filteredSubscriptions = search
|
|
||||||
? subscriptions.filter((sub) => {
|
|
||||||
const searchLower = search.toLowerCase();
|
|
||||||
return (
|
|
||||||
sub.name?.toLowerCase().includes(searchLower) ||
|
|
||||||
sub.email?.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: subscriptions;
|
|
||||||
|
|
||||||
const signingVolume = await Promise.all(
|
|
||||||
filteredSubscriptions.map(async (subscription) => {
|
|
||||||
let signingVolume = 0;
|
|
||||||
|
|
||||||
if (subscription.userId && !subscription.isTeam) {
|
|
||||||
const personalCount = await prisma.document.count({
|
|
||||||
where: {
|
|
||||||
userId: subscription.userId,
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
teamId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
signingVolume += personalCount;
|
|
||||||
|
|
||||||
const userTeams = await prisma.teamMember.findMany({
|
|
||||||
where: {
|
|
||||||
userId: subscription.userId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
teamId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userTeams.length > 0) {
|
|
||||||
const teamIds = userTeams.map((team) => team.teamId);
|
|
||||||
const teamCount = await prisma.document.count({
|
|
||||||
where: {
|
|
||||||
teamId: {
|
|
||||||
in: teamIds,
|
|
||||||
},
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
signingVolume += teamCount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.teamId) {
|
findQuery = findQuery.limit(perPage).offset(offset);
|
||||||
const teamCount = await prisma.document.count({
|
|
||||||
where: {
|
|
||||||
teamId: subscription.teamId,
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
signingVolume += teamCount;
|
const countQuery = kyselyPrisma.$kysely
|
||||||
}
|
.selectFrom('Subscription as s')
|
||||||
|
.leftJoin('User as u', 's.userId', 'u.id')
|
||||||
|
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||||
|
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||||
|
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([
|
||||||
|
eb('u.name', 'ilike', `%${search}%`),
|
||||||
|
eb('u.email', 'ilike', `%${search}%`),
|
||||||
|
eb('t.name', 'ilike', `%${search}%`),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||||
|
|
||||||
|
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...subscription,
|
leaderboard: results,
|
||||||
signingVolume,
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
};
|
};
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortedResults = [...signingVolume].sort((a, b) => {
|
|
||||||
if (sortBy === 'name') {
|
|
||||||
return sortOrder === 'asc'
|
|
||||||
? (a.name || '').localeCompare(b.name || '')
|
|
||||||
: (b.name || '').localeCompare(a.name || '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortBy === 'createdAt') {
|
|
||||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
||||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
||||||
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortOrder === 'asc'
|
|
||||||
? a.signingVolume - b.signingVolume
|
|
||||||
: b.signingVolume - a.signingVolume;
|
|
||||||
});
|
|
||||||
|
|
||||||
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedResults.length / validPerPage);
|
|
||||||
|
|
||||||
return {
|
|
||||||
leaderboard: paginatedResults,
|
|
||||||
totalPages,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export type CreateDocumentMetaOptions = {
|
|||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
|
modifyNextSigner?: boolean;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
modifyNextSigner,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
const document = await prisma.document.findFirst({
|
const document = await prisma.document.findFirst({
|
||||||
@ -98,6 +100,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
modifyNextSigner,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
subject,
|
subject,
|
||||||
@ -111,6 +114,7 @@ export const upsertDocumentMeta = async ({
|
|||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
modifyNextSigner,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,10 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
|
nextSigner?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||||
@ -51,10 +55,53 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const delegateNextSigner = async ({
|
||||||
|
documentId,
|
||||||
|
currentRecipientId,
|
||||||
|
nextSigner,
|
||||||
|
}: {
|
||||||
|
documentId: number;
|
||||||
|
currentRecipientId: number;
|
||||||
|
nextSigner: { email: string; name: string };
|
||||||
|
}) => {
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: { id: documentId },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: [{ signingOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRecipient = document.recipients.find((r) => r.id === currentRecipientId);
|
||||||
|
const nextRecipient = document.recipients.find(
|
||||||
|
(r) => r.signingOrder === (currentRecipient?.signingOrder ?? 0) + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nextRecipient) {
|
||||||
|
throw new Error('Next recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.recipient.update({
|
||||||
|
where: { id: nextRecipient.id },
|
||||||
|
data: {
|
||||||
|
email: nextSigner.email,
|
||||||
|
name: nextSigner.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextRecipient;
|
||||||
|
};
|
||||||
|
|
||||||
export const completeDocumentWithToken = async ({
|
export const completeDocumentWithToken = async ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
|
nextSigner,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
@ -112,6 +159,18 @@ export const completeDocumentWithToken = async ({
|
|||||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextSigner &&
|
||||||
|
document.documentMeta?.modifyNextSigner &&
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
|
||||||
|
) {
|
||||||
|
await delegateNextSigner({
|
||||||
|
documentId: document.id,
|
||||||
|
currentRecipientId: recipient.id,
|
||||||
|
nextSigner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.recipient.update({
|
await tx.recipient.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
46
packages/lib/server-only/recipient/get-is-last-recipient.ts
Normal file
46
packages/lib/server-only/recipient/get-is-last-recipient.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentSigningOrder, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type GetIsLastRecipientOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getIsLastRecipient({ token }: GetIsLastRecipientOptions) {
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: {
|
||||||
|
where: {
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
|
||||||
|
const unsignedRecipients = document.recipients.filter(
|
||||||
|
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsignedRecipients.length <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { recipients } = document;
|
||||||
|
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
|
||||||
|
|
||||||
|
if (currentRecipientIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentRecipientIndex === recipients.length - 1;
|
||||||
|
}
|
||||||
@ -55,6 +55,7 @@ export const ZDocumentSchema = DocumentSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
|
modifyNextSigner: true,
|
||||||
}).nullable(),
|
}).nullable(),
|
||||||
recipients: ZRecipientLiteSchema.array(),
|
recipients: ZRecipientLiteSchema.array(),
|
||||||
fields: ZFieldSchema.array(),
|
fields: ZFieldSchema.array(),
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TemplateMeta" ADD COLUMN "modifyNextSigner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -1,18 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
|
|
||||||
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
|
||||||
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Session" DROP COLUMN "expires",
|
|
||||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
ADD COLUMN "ipAddress" TEXT,
|
|
||||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
ADD COLUMN "userAgent" TEXT;
|
|
||||||
@ -270,9 +270,8 @@ model Account {
|
|||||||
scope String?
|
scope String?
|
||||||
id_token String? @db.Text
|
id_token String? @db.Text
|
||||||
session_state String?
|
session_state String?
|
||||||
password String?
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
@@unique([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
@ -281,13 +280,7 @@ model Session {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionToken String @unique
|
sessionToken String @unique
|
||||||
userId Int
|
userId Int
|
||||||
|
expires DateTime
|
||||||
ipAddress String?
|
|
||||||
userAgent String?
|
|
||||||
expiresAt DateTime
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,6 +390,7 @@ model DocumentMeta {
|
|||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
redirectUrl String?
|
redirectUrl String?
|
||||||
signingOrder DocumentSigningOrder @default(PARALLEL)
|
signingOrder DocumentSigningOrder @default(PARALLEL)
|
||||||
|
modifyNextSigner Boolean @default(false)
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
@ -666,6 +660,7 @@ model TemplateMeta {
|
|||||||
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
|
modifyNextSigner Boolean @default(false)
|
||||||
|
|
||||||
templateId Int @unique
|
templateId Int @unique
|
||||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -266,15 +266,14 @@ export const documentRouter = router({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*
|
|
||||||
* Todo: Refactor to updateDocument.
|
|
||||||
*/
|
*/
|
||||||
setSettingsForDocument: authenticatedProcedure
|
updateDocument: authenticatedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/update',
|
path: '/document/update',
|
||||||
summary: 'Update document',
|
summary: 'Update document',
|
||||||
|
description: 'Update an existing document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -286,9 +285,9 @@ export const documentRouter = router({
|
|||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
if (Object.values(meta).length > 0) {
|
if (Object.keys(meta).length > 0) {
|
||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
userId: ctx.user.id,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
documentId,
|
documentId,
|
||||||
subject: meta.subject,
|
subject: meta.subject,
|
||||||
@ -301,6 +300,7 @@ export const documentRouter = router({
|
|||||||
distributionMethod: meta.distributionMethod,
|
distributionMethod: meta.distributionMethod,
|
||||||
signingOrder: meta.signingOrder,
|
signingOrder: meta.signingOrder,
|
||||||
emailSettings: meta.emailSettings,
|
emailSettings: meta.emailSettings,
|
||||||
|
modifyNextSigner: meta.modifyNextSigner,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -251,6 +251,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
|
|||||||
language: ZDocumentMetaLanguageSchema.optional(),
|
language: ZDocumentMetaLanguageSchema.optional(),
|
||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
|
modifyNextSigner: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -437,13 +437,14 @@ export const recipientRouter = router({
|
|||||||
completeDocumentWithToken: procedure
|
completeDocumentWithToken: procedure
|
||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { token, documentId, authOptions } = input;
|
const { token, documentId, authOptions, nextSigner } = input;
|
||||||
|
|
||||||
return await completeDocumentWithToken({
|
return await completeDocumentWithToken({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
authOptions,
|
authOptions,
|
||||||
userId: ctx.user?.id,
|
userId: ctx.user?.id,
|
||||||
|
nextSigner,
|
||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -212,6 +212,12 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
|
nextSigner: z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export type AddSignersFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
signingOrder?: DocumentSigningOrder | null;
|
signingOrder?: DocumentSigningOrder | null;
|
||||||
|
modifyNextSigner?: boolean | null;
|
||||||
isDocumentEnterprise: boolean;
|
isDocumentEnterprise: boolean;
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
@ -59,6 +60,7 @@ export const AddSignersFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
modifyNextSigner,
|
||||||
isDocumentEnterprise,
|
isDocumentEnterprise,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
@ -107,6 +109,7 @@ export const AddSignersFormPartial = ({
|
|||||||
)
|
)
|
||||||
: defaultRecipients,
|
: defaultRecipients,
|
||||||
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
modifyNextSigner: modifyNextSigner ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -404,6 +407,35 @@ export const AddSignersFormPartial = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isSigningOrderSequential && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="modifyNextSigner"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
id="modifyNextSigner"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting || hasDocumentBeenSent}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="modifyNextSigner"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Modify next signer</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
sensors={[
|
sensors={[
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const ZAddSignersFormSchema = z
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
modifyNextSigner: z.boolean(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
|
|||||||
@ -183,12 +183,12 @@ const FormMessage = React.forwardRef<
|
|||||||
FormMessage.displayName = 'FormMessage';
|
FormMessage.displayName = 'FormMessage';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
|
||||||
Form,
|
Form,
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
|
||||||
FormField,
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useFormField,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user