From 5f17288af6e04f3b37389e2e8ce3198d0b04e4d7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 12 Jun 2025 17:46:03 +1000 Subject: [PATCH] fix: rebase --- .../document/document-processing-poll.tsx | 54 ++++++++++++++ .../general/document/document-status.tsx | 17 ++++- .../app/components/tables/documents-table.tsx | 9 ++- .../_authenticated+/admin+/documents.$id.tsx | 9 ++- .../admin+/documents._index.tsx | 8 +- .../t.$teamUrl+/documents.$id._index.tsx | 5 ++ .../t.$teamUrl+/documents._index.tsx | 6 +- .../documents.f.$folderId._index.tsx | 3 + .../_recipient+/sign.$token+/complete.tsx | 74 +++++++++++++++---- .../recipient/get-recipients-for-document.ts | 23 ++++++ packages/lib/utils/document.ts | 23 +++++- .../components/document/document-dialog.tsx | 4 +- 12 files changed, 210 insertions(+), 25 deletions(-) create mode 100644 apps/remix/app/components/general/document/document-processing-poll.tsx diff --git a/apps/remix/app/components/general/document/document-processing-poll.tsx b/apps/remix/app/components/general/document/document-processing-poll.tsx new file mode 100644 index 000000000..fa4c87ade --- /dev/null +++ b/apps/remix/app/components/general/document/document-processing-poll.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; + +import type { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import { useRevalidator } from 'react-router'; + +import { isDocumentBeingProcessed } from '@documenso/lib/utils/document'; + +type DocumentType = { + id: number; + status: DocumentStatus; + deletedAt: Date | null; + recipients: Array<{ + role: RecipientRole; + signingStatus: SigningStatus; + }>; +}; + +export type DocumentProcessingPollProps = { + documents?: DocumentType[] | DocumentType; +}; + +export const DocumentProcessingPoll = ({ documents }: DocumentProcessingPollProps) => { + const { revalidate } = useRevalidator(); + + useEffect(() => { + if (!documents) { + return; + } + + const documentArray = Array.isArray(documents) ? documents : [documents]; + + if (documentArray.length === 0) { + return; + } + + const hasProcessingDocuments = documentArray.some((document) => + isDocumentBeingProcessed(document), + ); + + if (!hasProcessingDocuments) { + return; + } + + const interval = setInterval(() => { + if (window.document.hasFocus()) { + void revalidate(); + } + }, 3000); + + return () => clearInterval(interval); + }, [documents, revalidate]); + + return null; +}; diff --git a/apps/remix/app/components/general/document/document-status.tsx b/apps/remix/app/components/general/document/document-status.tsx index 1a24f47f2..29d8fccef 100644 --- a/apps/remix/app/components/general/document/document-status.tsx +++ b/apps/remix/app/components/general/document/document-status.tsx @@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { CheckCircle2, Clock, File, XCircle } from 'lucide-react'; +import { CheckCircle2, Clock, File, Loader2, XCircle } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; @@ -15,6 +15,7 @@ type FriendlyStatus = { labelExtended: MessageDescriptor; icon?: LucideIcon; color: string; + animate?: boolean; }; export const FRIENDLY_STATUS_MAP: Record = { @@ -55,20 +56,31 @@ export const FRIENDLY_STATUS_MAP: Record }, }; +const PROCESSING_STATUS: FriendlyStatus = { + label: msg`Processing`, + labelExtended: msg`Document processing`, + icon: Loader2, + color: 'text-blue-600 dark:text-blue-300', + animate: true, +}; + export type DocumentStatusProps = HTMLAttributes & { status: ExtendedDocumentStatus; inheritColor?: boolean; + isProcessing?: boolean; }; export const DocumentStatus = ({ className, status, inheritColor, + isProcessing, ...props }: DocumentStatusProps) => { const { _ } = useLingui(); - const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status]; + const statusConfig = isProcessing ? PROCESSING_STATUS : FRIENDLY_STATUS_MAP[status]; + const { label, icon: Icon, color, animate } = statusConfig; return ( @@ -76,6 +88,7 @@ export const DocumentStatus = ({ )} diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index fa5be7d2d..986be31e1 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -9,7 +9,7 @@ import { match } from 'ts-pattern'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { isDocumentCompleted } from '@documenso/lib/utils/document'; +import { isDocumentBeingProcessed, isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; @@ -77,7 +77,12 @@ export const DocumentsTable = ({ { header: _(msg`Status`), accessorKey: 'status', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), size: 140, }, { diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx index db1b4d0e8..f58c4f492 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -6,6 +6,7 @@ import { DateTime } from 'luxon'; import { Link, redirect } from 'react-router'; import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { isDocumentBeingProcessed } from '@documenso/lib/utils/document'; import { trpc } from '@documenso/trpc/react'; import { Accordion, @@ -24,6 +25,7 @@ import { import { useToast } from '@documenso/ui/primitives/use-toast'; import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog'; +import { DocumentProcessingPoll } from '~/components/general/document/document-processing-poll'; import { DocumentStatus } from '~/components/general/document/document-status'; import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table'; @@ -69,7 +71,10 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component

{document.title}

- +
{document.deletedAt && ( @@ -162,6 +167,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
{document && } + +
); } diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx index 35640b28e..d8990386b 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx @@ -8,6 +8,7 @@ import { Link, useSearchParams } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { isDocumentBeingProcessed } from '@documenso/lib/utils/document'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; @@ -75,7 +76,12 @@ export default function AdminDocumentsPage() { { header: _(msg`Status`), accessorKey: 'status', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { header: _(msg`Owner`), diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index 6513bddc0..d5bacbd2b 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -10,6 +10,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { isDocumentBeingProcessed } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { Badge } from '@documenso/ui/primitives/badge'; @@ -23,6 +24,7 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information'; import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity'; import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients'; +import { DocumentProcessingPoll } from '~/components/general/document/document-processing-poll'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentStatus as DocumentStatusComponent, @@ -128,6 +130,7 @@ export default function DocumentPage() { @@ -241,6 +244,8 @@ export default function DocumentPage() { + + ); } diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 1e47253ad..ab3b342fa 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -3,8 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import { OrganisationType } from '@prisma/client'; import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { Link } from 'react-router'; +import { Link, useNavigate, useSearchParams } from 'react-router'; import { z } from 'zod'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; @@ -29,6 +28,7 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog'; import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog'; import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog'; import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper'; +import { DocumentProcessingPoll } from '~/components/general/document/document-processing-poll'; import { DocumentSearch } from '~/components/general/document/document-search'; import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentUploadDropzone } from '~/components/general/document/document-upload'; @@ -346,6 +346,8 @@ export default function DocumentsPage() { }} /> )} + + diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.f.$folderId._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.f.$folderId._index.tsx index 049903f0b..a89cce75b 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.f.$folderId._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.f.$folderId._index.tsx @@ -26,6 +26,7 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog'; import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog'; import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog'; import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper'; +import { DocumentProcessingPoll } from '~/components/general/document/document-processing-poll'; import { DocumentSearch } from '~/components/general/document/document-search'; import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentUploadDropzone } from '~/components/general/document/document-upload'; @@ -314,6 +315,8 @@ export default function DocumentsPage() { }} /> )} + + diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx index 5376f2af6..81589425f 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx @@ -3,8 +3,14 @@ import { useEffect } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Document, DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; -import { CheckCircle2, Clock8, FileSearch } from 'lucide-react'; +import { + type Document, + DocumentStatus, + FieldType, + RecipientRole, + SigningStatus, +} from '@prisma/client'; +import { CheckCircle2, Clock8, FileSearch, Loader2 } from 'lucide-react'; import { Link, useRevalidator } from 'react-router'; import { match } from 'ts-pattern'; @@ -16,10 +22,11 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-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 { getAllRecipientsByDocumentId } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { env } from '@documenso/lib/utils/env'; -import DocumentDialog from '@documenso/ui/components/document/document-dialog'; +import { DocumentDialog } from '@documenso/ui/components/document/document-dialog'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; @@ -50,9 +57,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw new Response('Not Found', { status: 404 }); } - const [fields, recipient] = await Promise.all([ + const [fields, recipient, allRecipients] = await Promise.all([ getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), + getAllRecipientsByDocumentId({ documentId: document.id }), ]); if (!recipient) { @@ -66,17 +74,26 @@ export async function loader({ params, request }: Route.LoaderArgs) { userId: user?.id, }); + const isDocumentWaitingForSignatureFromOthers = + allRecipients.length > 1 && + allRecipients.some( + (r) => r.role !== RecipientRole.CC && r.signingStatus !== SigningStatus.SIGNED, + ); + if (!isDocumentAccessValid) { return { isDocumentAccessValid: false, recipientEmail: recipient.email, + isDocumentWaitingForSignatureFromOthers, } as const; } const signatures = await getRecipientSignatures({ recipientId: recipient.id }); - const isExistingUser = await getUserByEmail({ email: recipient.email }) - .then((u) => !!u) - .catch(() => false); + const isExistingUser = recipient.email + ? await getUserByEmail({ email: recipient.email }) + .then((u) => !!u) + .catch(() => false) + : false; const recipientName = recipient.name || @@ -93,6 +110,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { signatures, document, recipient, + isDocumentWaitingForSignatureFromOthers, }; } @@ -110,10 +128,11 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp document, recipient, recipientEmail, + isDocumentWaitingForSignatureFromOthers, } = loaderData; if (!isDocumentAccessValid) { - return ; + return ; } return ( @@ -142,7 +161,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp {/* Card with recipient */} @@ -153,7 +172,11 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp {recipient.role === RecipientRole.APPROVER && Document Approved} - {match({ status: document.status, deletedAt: document.deletedAt }) + {match({ + status: document.status, + deletedAt: document.deletedAt, + waitingForOthers: isDocumentWaitingForSignatureFromOthers, + }) .with({ status: DocumentStatus.COMPLETED }, () => (
@@ -162,7 +185,15 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
)) - .with({ deletedAt: null }, () => ( + .with({ deletedAt: null, waitingForOthers: false }, () => ( +
+ + + Processing document... + +
+ )) + .with({ deletedAt: null, waitingForOthers: true }, () => (
@@ -179,7 +210,11 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
))} - {match({ status: document.status, deletedAt: document.deletedAt }) + {match({ + status: document.status, + deletedAt: document.deletedAt, + waitingForOthers: isDocumentWaitingForSignatureFromOthers, + }) .with({ status: DocumentStatus.COMPLETED }, () => (

@@ -187,7 +222,15 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp

)) - .with({ deletedAt: null }, () => ( + .with({ deletedAt: null, waitingForOthers: false }, () => ( +

+ + All parties have completed their actions. The document is now being finalized. You + will receive an Email copy once it is ready. + +

+ )) + .with({ deletedAt: null, waitingForOthers: true }, () => (

You will receive an Email copy of the signed document once everyone has signed. @@ -244,7 +287,10 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp

- + )} diff --git a/packages/lib/server-only/recipient/get-recipients-for-document.ts b/packages/lib/server-only/recipient/get-recipients-for-document.ts index 3cfd79e4d..bbe821c30 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-document.ts @@ -30,3 +30,26 @@ export const getRecipientsForDocument = async ({ return recipients; }; + +export interface GetAllRecipientsByDocumentIdOptions { + documentId: number; +} + +export const getAllRecipientsByDocumentId = async ({ + documentId, +}: GetAllRecipientsByDocumentIdOptions) => { + const recipients = await prisma.recipient.findMany({ + where: { + documentId, + }, + select: { + role: true, + signingStatus: true, + }, + orderBy: { + id: 'asc', + }, + }); + + return recipients; +}; diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts index 52ceca627..cd8a88323 100644 --- a/packages/lib/utils/document.ts +++ b/packages/lib/utils/document.ts @@ -1,8 +1,29 @@ import type { Document } from '@prisma/client'; -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; export const isDocumentCompleted = (document: Pick | DocumentStatus) => { const status = typeof document === 'string' ? document : document.status; return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED; }; + +export const isDocumentBeingProcessed = (document: { + status: DocumentStatus; + deletedAt: Date | null; + recipients: Array<{ + role: RecipientRole; + signingStatus: SigningStatus; + }>; +}) => { + if (document.status !== DocumentStatus.PENDING || document.deletedAt !== null) { + return false; + } + + const recipients = document.recipients.filter((r) => r.role !== RecipientRole.CC); + + if (recipients.length === 0) { + return false; + } + + return recipients.every((r) => r.signingStatus === SigningStatus.SIGNED); +}; diff --git a/packages/ui/components/document/document-dialog.tsx b/packages/ui/components/document/document-dialog.tsx index 5e039cbe4..c03bb5cf8 100644 --- a/packages/ui/components/document/document-dialog.tsx +++ b/packages/ui/components/document/document-dialog.tsx @@ -16,7 +16,7 @@ export type DocumentDialogProps = { /** * A dialog which renders the provided document. */ -export default function DocumentDialog({ trigger, documentData, ...props }: DocumentDialogProps) { +export const DocumentDialog = ({ trigger, documentData, ...props }: DocumentDialogProps) => { const [documentLoaded, setDocumentLoaded] = useState(false); const onDocumentLoad = () => { @@ -58,4 +58,4 @@ export default function DocumentDialog({ trigger, documentData, ...props }: Docu ); -} +};