From 071475769c2c7965a2c0eaeb6a549ab99516350b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 12 Feb 2024 17:30:23 +1100 Subject: [PATCH] feat: add document page view --- .../[id]/document-page-view-button.tsx | 110 ++++++++ .../[id]/document-page-view-dropdown.tsx | 160 +++++++++++ .../[id]/document-page-view-information.tsx | 71 +++++ .../documents/[id]/document-page-view.tsx | 251 ++++++++++++++---- .../documents/[id]/edit-document.tsx | 37 ++- .../[id]/edit/document-edit-page-view.tsx | 121 +++++++++ .../(dashboard)/documents/[id]/edit/page.tsx | 11 + .../_action-items/resend-document.tsx | 2 +- .../documents/data-table-action-button.tsx | 2 +- .../documents/data-table-action-dropdown.tsx | 2 +- .../documents/duplicate-document-dialog.tsx | 2 +- .../(dashboard)/documents/upload-document.tsx | 2 +- .../templates/data-table-templates.tsx | 2 +- .../t/[teamUrl]/documents/[id]/edit/page.tsx | 21 ++ .../components/formatter/document-status.tsx | 2 +- packages/lib/constants/recipient-roles.ts | 2 +- .../document/get-document-by-id.ts | 13 + packages/ui/primitives/badge.tsx | 16 +- 18 files changed, 755 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/edit/page.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx new file mode 100644 index 000000000..334089a5f --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx @@ -0,0 +1,110 @@ +'use client'; + +import Link from 'next/link'; + +import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DocumentPageViewButtonProps = { + document: Document & { + User: Pick; + Recipient: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + + if (!session) { + return null; + } + + const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); + + const isRecipient = !!recipient; + const isPending = document.status === DocumentStatus.PENDING; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; + + const documentsPath = formatDocumentsPath(document.team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query({ + id: document.id, + teamId: team?.id, + }); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + throw new Error('No document available'); + } + + await downloadPDF({ documentData, fileName: documentWithData.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } + }; + + return match({ + isRecipient, + isPending, + isComplete, + isSigned, + }) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .with({ isComplete: false }, () => ( + + )) + .with({ isComplete: true }, () => ( + + )) + .otherwise(() => null); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx new file mode 100644 index 000000000..3e108aed5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { ResendDocumentActionItem } from '../_action-items/resend-document'; +import { DeleteDocumentDialog } from '../delete-document-dialog'; +import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; + +export type DocumentPageViewDropdownProps = { + document: Document & { + User: Pick; + Recipient: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = document.User.id === session.user.id; + const isDraft = document.status === DocumentStatus.DRAFT; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isDocumentDeletable = isOwner; + const isCurrentTeamDocument = team && document.team?.url === team.url; + + const documentsPath = formatDocumentsPath(team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query({ + id: document.id, + teamId: team?.id, + }); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: document.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } + }; + + const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + + return ( + + + + + + + Action + + {(isOwner || isCurrentTeamDocument) && !isComplete && ( + + + + Edit + + + )} + + {isComplete && ( + + + Download + + )} + + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + + Delete + + + Share + + + + ( + e.preventDefault()}> +
+ {loading ? : } + Share Signing Card +
+
+ )} + /> +
+ + {isDocumentDeletable && ( + + )} + {isDuplicateDialogOpen && ( + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx new file mode 100644 index 000000000..00bbe0d83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useMemo } from 'react'; + +import { DateTime } from 'luxon'; + +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { useLocale } from '@documenso/lib/client-only/providers/locale'; +import type { Document, Recipient, User } from '@documenso/prisma/client'; + +export type DocumentPageViewInformationProps = { + userId: number; + document: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DocumentPageViewInformation = ({ + document, + userId, +}: DocumentPageViewInformationProps) => { + const isMounted = useIsMounted(); + const { locale } = useLocale(); + + const documentInformation = useMemo(() => { + let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy'); + let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative(); + + if (!isMounted) { + createdValue = DateTime.fromJSDate(document.createdAt) + .setLocale(locale) + .toFormat('MMMM d, yyyy'); + + lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative(); + } + + return [ + { + description: 'Uploaded by', + value: userId === document.userId ? 'You' : document.User.name ?? document.User.email, + }, + { + description: 'Created', + value: createdValue, + }, + { + description: 'Last modified', + value: lastModifiedValue, + }, + ]; + }, [isMounted, document, locale, userId]); + + return ( +
+

Information

+ +
    + {documentInformation.map((item) => ( +
  • + {item.description} + {item.value} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index 6759d91ac..c821bfac8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -1,22 +1,42 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { ChevronLeft, Users2 } from 'lucide-react'; +import { + CheckIcon, + ChevronLeft, + Clock, + MailIcon, + MailOpenIcon, + PenIcon, + PlusIcon, + Users2, +} from 'lucide-react'; +import { match } from 'ts-pattern'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; -import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { DocumentStatus } from '~/components/formatter/document-status'; +import { + DocumentStatus as DocumentStatusComponent, + FRIENDLY_STATUS_MAP, +} from '~/components/formatter/document-status'; + +import { DocumentPageViewButton } from './document-page-view-button'; +import { DocumentPageViewDropdown } from './document-page-view-dropdown'; +import { DocumentPageViewInformation } from './document-page-view-information'; export type DocumentPageViewProps = { params: { @@ -67,65 +87,196 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const [recipients, fields] = await Promise.all([ - getRecipientsForDocument({ - documentId, - userId: user.id, - }), - getFieldsForDocument({ - documentId, - userId: user.id, - }), - ]); + const recipients = await getRecipientsForDocument({ + documentId, + userId: user.id, + }); + + const documentWithRecipients = { + ...document, + Recipient: recipients, + }; return (
- + Documents -

- {document.title} -

+
+

+ {document.title} +

-
- +
+ - {recipients.length > 0 && ( -
- + {recipients.length > 0 && ( +
+ - - {recipients.length} Recipient(s) - -
- )} + + {recipients.length} Recipient(s) + +
+ )} +
- {document.status !== InternalDocumentStatus.COMPLETED && ( - - )} +
+ + + + + - {document.status === InternalDocumentStatus.COMPLETED && ( -
- +
+
+
+
+

+ Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()} +

+ + +
+ +

+ {match(document.status) + .with( + DocumentStatus.COMPLETED, + () => 'This document has been signed by all recipients', + ) + .with( + DocumentStatus.DRAFT, + () => 'This document is currently a draft and has not been sent', + ) + .with(DocumentStatus.PENDING, () => { + const pendingRecipients = recipients.filter( + (recipient) => recipient.signingStatus === 'NOT_SIGNED', + ); + + return `Waiting on ${pendingRecipients.length} recipient${ + pendingRecipients.length > 1 ? 's' : '' + }`; + }) + .exhaustive()} +

+ +
+ +
+
+ + {/* Document information section. */} + + + {/* Recipients section. */} +
+
+

Recipients

+ + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
+ +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    + } + secondaryText={ +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    + } + /> + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} +
  • + ))} +
+
+
- )} +
); }; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 813458062..fe278486e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -2,10 +2,16 @@ import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; -import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { + type DocumentData, + type DocumentMeta, + DocumentStatus, + type Field, + type Recipient, + type User, +} from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -49,12 +55,9 @@ export const EditDocumentForm = ({ documentRootPath, }: EditDocumentFormProps) => { const { toast } = useToast(); - const router = useRouter(); - // controlled stepper state - const [step, setStep] = useState( - document.status === DocumentStatus.DRAFT ? 'title' : 'signers', - ); + const router = useRouter(); + const searchParams = useSearchParams(); const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); @@ -86,6 +89,24 @@ export const EditDocumentForm = ({ }, }; + const [step, setStep] = useState(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined; + + let initialStep: EditDocumentStep = + document.status === DocumentStatus.DRAFT ? 'title' : 'signers'; + + if ( + searchParamStep && + documentFlow[searchParamStep] !== undefined && + !(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields')) + ) { + initialStep = searchParamStep; + } + + return initialStep; + }); + const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { try { // Custom invocation server action diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx new file mode 100644 index 000000000..87b3738bb --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -0,0 +1,121 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft, Users2 } from 'lucide-react'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Team } from '@documenso/prisma/client'; +import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; + +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; + +export type DocumentEditPageViewProps = { + params: { + id: string; + }; + team?: Team; +}; + +export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => { + const { id } = params; + + const documentId = Number(id); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const document = await getDocumentById({ + id: documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null); + + if (!document || !document.documentData) { + redirect(documentRootPath); + } + + if (document.status === InternalDocumentStatus.COMPLETED) { + redirect(`${documentRootPath}/${documentId}`); + } + + const { documentData, documentMeta } = document; + + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + + const [recipients, fields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + userId: user.id, + }), + getFieldsForDocument({ + documentId, + userId: user.id, + }), + ]); + + return ( +
+ + + Documents + + +

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + {recipients.length} Recipient(s) + +
+ )} +
+ + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx new file mode 100644 index 000000000..6c613a287 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx @@ -0,0 +1,11 @@ +import { DocumentEditPageView } from './document-edit-page-view'; + +export type DocumentPageProps = { + params: { + id: string; + }; +}; + +export default function DocumentEditPage({ params }: DocumentPageProps) { + return ; +} diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx index e8e3d6130..ff2291c54 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -119,7 +119,7 @@ export const ResendDocumentActionItem = ({ - +

Who do you want to remind?

diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 78ffd0b3b..455f50be5 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, () => (