diff --git a/apps/console/src/components/documents/BulkExportDialog.tsx b/apps/console/src/components/documents/BulkExportDialog.tsx new file mode 100644 index 000000000..54495558d --- /dev/null +++ b/apps/console/src/components/documents/BulkExportDialog.tsx @@ -0,0 +1,164 @@ +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + useDialogRef, + Spinner, + Checkbox, +} from "@probo/ui"; +import { type ReactNode, useImperativeHandle, forwardRef } from "react"; +import { z } from "zod"; +import { useFormWithSchema } from "/hooks/useFormWithSchema"; +import { sprintf } from "@probo/helpers"; + +const bulkExportSchema = z.object({ + withWatermark: z.boolean(), + watermarkEmail: z.string().optional().or(z.literal("")), + withSignatures: z.boolean(), +}).refine((data) => { + if (data.withWatermark && (!data.watermarkEmail || data.watermarkEmail === "")) { + return false; + } + if (data.withWatermark && data.watermarkEmail && !z.string().email().safeParse(data.watermarkEmail).success) { + return false; + } + return true; +}, { + message: "Please enter a valid email address", + path: ["watermarkEmail"], +}); + +type BulkExportFormData = z.infer; + +type Props = { + children: ReactNode; + onExport: (options: BulkExportFormData) => void; + isLoading?: boolean; + defaultEmail?: string; + selectedCount: number; +}; + +export type BulkExportDialogRef = { + open: () => void; + close: () => void; +}; + +export const BulkExportDialog = forwardRef( + ({ children, onExport, isLoading = false, defaultEmail = "", selectedCount }, ref) => { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + + const { register, handleSubmit, formState, watch, setValue } = useFormWithSchema( + bulkExportSchema, + { + defaultValues: { + withWatermark: false, + watermarkEmail: defaultEmail, + withSignatures: true, + }, + } + ); + + const watchWatermark = watch("withWatermark"); + const watchSignatures = watch("withSignatures"); + + useImperativeHandle(ref, () => ({ + open: () => dialogRef.current?.open(), + close: () => dialogRef.current?.close(), + })); + + const onSubmit = handleSubmit((data) => { + const options = { + ...data, + watermarkEmail: data.withWatermark ? data.watermarkEmail : undefined, + }; + onExport(options); + dialogRef.current?.close(); + }); + + return ( + <> +
dialogRef.current?.open()}>{children}
+ +
+ +
+
+ setValue("withSignatures", checked)} + /> +
+ +

+ {__("Show signature information and approval details in the PDFs")} +

+
+
+ +
+ setValue("withWatermark", checked)} + /> +
+ +

+ {__("Add confidential watermark with email and timestamp to all PDFs")} +

+
+
+ + {watchWatermark && ( +
+ +
+ )} +
+ +
+

+ {__("The documents will be exported as individual PDFs in a ZIP file. You will receive an email when the export is ready for download.")} +

+
+
+ + + +
+
+ + ); + } +); diff --git a/apps/console/src/components/documents/PdfDownloadDialog.tsx b/apps/console/src/components/documents/PdfDownloadDialog.tsx new file mode 100644 index 000000000..8d55aa25d --- /dev/null +++ b/apps/console/src/components/documents/PdfDownloadDialog.tsx @@ -0,0 +1,141 @@ +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + useDialogRef, + Spinner, + Checkbox, +} from "@probo/ui"; +import { type ReactNode, useImperativeHandle, forwardRef } from "react"; +import { z } from "zod"; +import { useFormWithSchema } from "/hooks/useFormWithSchema"; + +const pdfDownloadSchema = z.object({ + withWatermark: z.boolean(), + watermarkEmail: z.string().email("Please enter a valid email address").optional().or(z.literal("")), + withSignatures: z.boolean(), +}); + +type PdfDownloadFormData = z.infer; + +type Props = { + children: ReactNode; + onDownload: (options: PdfDownloadFormData) => void; + isLoading?: boolean; + defaultEmail?: string; +}; + +export type PdfDownloadDialogRef = { + open: () => void; + close: () => void; +}; + +export const PdfDownloadDialog = forwardRef( + ({ children, onDownload, isLoading = false, defaultEmail = "" }, ref) => { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + + const { register, handleSubmit, formState, watch, setValue } = useFormWithSchema( + pdfDownloadSchema, + { + defaultValues: { + withWatermark: false, + watermarkEmail: defaultEmail, + withSignatures: true, + }, + } + ); + + const watchWatermark = watch("withWatermark"); + const watchSignatures = watch("withSignatures"); + + useImperativeHandle(ref, () => ({ + open: () => dialogRef.current?.open(), + close: () => dialogRef.current?.close(), + })); + + const onSubmit = handleSubmit((data) => { + const options = { + ...data, + watermarkEmail: data.withWatermark ? data.watermarkEmail : undefined, + }; + onDownload(options); + dialogRef.current?.close(); + }); + + return ( + <> +
dialogRef.current?.open()}>{children}
+ +
+ +
+
+ setValue("withSignatures", checked)} + /> +
+ +

+ {__("Show signature information and approval details in the PDF")} +

+
+
+ +
+ setValue("withWatermark", checked)} + /> +
+ +

+ {__("Add confidential watermark with email and timestamp")} +

+
+
+ + {watchWatermark && ( +
+ +
+ )} +
+
+ + + +
+
+ + ); + } +); diff --git a/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts index fdd52f8a0..896544b28 100644 --- a/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<9b84adc0253186c6f0a5f47837d83d7f>> * @lightSyntaxTransform * @nogrep */ @@ -11,6 +11,9 @@ import { ConcreteRequest } from 'relay-runtime'; export type BulkExportDocumentsInput = { documentIds: ReadonlyArray; + watermarkEmail?: string | null | undefined; + withSignatures: boolean; + withWatermark: boolean; }; export type DocumentGraphBulkExportDocumentsMutation$variables = { input: BulkExportDocumentsInput; diff --git a/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx b/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx index ba9d4344b..eacf88bbd 100644 --- a/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx @@ -4,6 +4,7 @@ import { loadQuery, useFragment, usePreloadedQuery, + useLazyLoadQuery, } from "react-relay"; import type { DocumentGraphNodeQuery } from "/hooks/graph/__generated__/DocumentGraphNodeQuery.graphql"; import { @@ -18,6 +19,7 @@ import type { } from "./__generated__/DocumentDetailPageDocumentFragment.graphql"; import type { DocumentDetailPageExportPDFMutation } from "./__generated__/DocumentDetailPageExportPDFMutation.graphql"; import type { DocumentDetailPageUpdateMutation } from "./__generated__/DocumentDetailPageUpdateMutation.graphql"; +import type { DocumentDetailPageUserEmailQuery } from "./__generated__/DocumentDetailPageUserEmailQuery.graphql"; import { useTranslate } from "@probo/i18n"; import { ActionDropdown, @@ -54,6 +56,7 @@ import { useParams, } from "react-router"; import UpdateVersionDialog from "./dialogs/UpdateVersionDialog"; +import { PdfDownloadDialog, type PdfDownloadDialogRef } from "/components/documents/PdfDownloadDialog"; import { useRef, useState } from "react"; import type { NodeOf } from "/types.ts"; import clsx from "clsx"; @@ -185,6 +188,16 @@ const documentUpdateSchema = z.object({ documentType: z.enum(documentTypes), }); +const UserEmailQuery = graphql` + query DocumentDetailPageUserEmailQuery { + viewer { + user { + email + } + } + } +`; + export default function DocumentDetailPage(props: Props) { const { versionId } = useParams<{ versionId?: string }>(); const node = usePreloadedQuery(documentNodeQuery, props.queryRef).node; @@ -223,6 +236,9 @@ export default function DocumentDetailPage(props: Props) { errorMessage: __("Failed to generate PDF. Please try again."), } ); + + const userEmailData = useLazyLoadQuery(UserEmailQuery, {}); + const defaultEmail = userEmailData.viewer.user.email; const [updateDocument, isUpdatingDocument] = useMutationWithToasts( updateDocumentMutation, { @@ -360,11 +376,16 @@ export default function DocumentDetailPage(props: Props) { ); }; - const handleDownloadPdf = () => { + const handleDownloadPdf = (options: { withWatermark: boolean; withSignatures: boolean; watermarkEmail?: string }) => { + const input = { + documentVersionId: currentVersion.id, + withWatermark: options.withWatermark, + withSignatures: options.withSignatures, + ...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }), + }; + exportDocumentVersionPDF({ - variables: { - input: { documentVersionId: currentVersion.id }, - }, + variables: { input }, onCompleted: (data) => { if (data.exportDocumentVersionPDF?.data) { const link = window.document.createElement("a"); @@ -379,6 +400,7 @@ export default function DocumentDetailPage(props: Props) { }; const updateDialogRef = useRef<{ open: () => void }>(null); + const pdfDownloadDialogRef = useRef(null); const controlsCount = document.controlsInfo.totalCount; const urlPrefix = versionId ? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}` @@ -391,6 +413,14 @@ export default function DocumentDetailPage(props: Props) { document={document} connectionId={versionConnectionId} /> + + {null} +
)} pdfDownloadDialogRef.current?.open()} icon={IconArrowDown} disabled={isExporting} > diff --git a/apps/console/src/pages/organizations/documents/DocumentsPage.tsx b/apps/console/src/pages/organizations/documents/DocumentsPage.tsx index 55b4b3255..5fc4e5dcf 100644 --- a/apps/console/src/pages/organizations/documents/DocumentsPage.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentsPage.tsx @@ -26,8 +26,10 @@ import { useFragment, usePaginationFragment, usePreloadedQuery, + useLazyLoadQuery, type PreloadedQuery, } from "react-relay"; +import { useRef } from "react"; import { graphql } from "relay-runtime"; import type { DocumentGraphListQuery } from "/hooks/graph/__generated__/DocumentGraphListQuery.graphql"; import { @@ -45,6 +47,8 @@ import type { DocumentsPageRowFragment$key } from "./__generated__/DocumentsPage import { SortableTable, SortableTh } from "/components/SortableTable"; import { PublishDocumentsDialog } from "./dialogs/PublishDocumentsDialog.tsx"; import { SignatureDocumentsDialog } from "./dialogs/SignatureDocumentsDialog.tsx"; +import { BulkExportDialog, type BulkExportDialogRef } from "/components/documents/BulkExportDialog"; +import type { DocumentsPageUserEmailQuery } from "./__generated__/DocumentsPageUserEmailQuery.graphql"; const documentsFragment = graphql` fragment DocumentsPageListFragment on Organization @@ -81,6 +85,16 @@ type Props = { queryRef: PreloadedQuery; }; +const UserEmailQuery = graphql` + query DocumentsPageUserEmailQuery { + viewer { + user { + email + } + } + } +`; + export default function DocumentsPage(props: Props) { const { __ } = useTranslate(); @@ -88,6 +102,9 @@ export default function DocumentsPage(props: Props) { documentsQuery, props.queryRef ).organization; + + const userEmailData = useLazyLoadQuery(UserEmailQuery, {}); + const defaultEmail = userEmailData.viewer.user.email; const pagination = usePaginationFragment( documentsFragment, organization as DocumentsPageListFragment$key @@ -99,9 +116,10 @@ export default function DocumentsPage(props: Props) { const connectionId = pagination.data.documents.__id; const [sendSigningNotifications] = useSendSigningNotificationsMutation(); const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation(); - const [bulkExportDocuments] = useBulkExportDocumentsMutation(); + const [bulkExportDocuments, isBulkExporting] = useBulkExportDocumentsMutation(); const { list: selection, toggle, clear, reset } = useList([]); const confirm = useConfirm(); + const bulkExportDialogRef = useRef(null); usePageTitle(__("Documents")); @@ -136,11 +154,16 @@ export default function DocumentsPage(props: Props) { ); }; - const handleBulkExport = () => { + const handleBulkExport = (options: { withWatermark: boolean; withSignatures: boolean; watermarkEmail?: string }) => { + const input = { + documentIds: selection, + withWatermark: options.withWatermark, + withSignatures: options.withSignatures, + ...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }), + }; + bulkExportDocuments({ - variables: { - input: { documentIds: selection }, - }, + variables: { input }, }).then(() => { clear(); }); @@ -216,14 +239,21 @@ export default function DocumentsPage(props: Props) { {__("Request signature")} - + +