diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx new file mode 100644 index 000000000..d210550c6 --- /dev/null +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -0,0 +1,274 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +const ZBulkSendFormSchema = z.object({ + file: z.instanceof(File), + sendImmediately: z.boolean().default(false), +}); + +type TBulkSendFormSchema = z.infer; + +export type TemplateBulkSendDialogProps = { + templateId: number; + recipients: Array<{ email: string; name?: string | null }>; + trigger?: React.ReactNode; + onSuccess?: () => void; +}; + +export const TemplateBulkSendDialog = ({ + templateId, + recipients, + trigger, + onSuccess, +}: TemplateBulkSendDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZBulkSendFormSchema), + defaultValues: { + sendImmediately: false, + }, + }); + + const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + + const onDownloadTemplate = () => { + const headers = recipients.flatMap((_, index) => [ + `recipient_${index + 1}_email`, + `recipient_${index + 1}_name`, + ]); + + const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']); + + const csv = [headers.join(','), exampleRow.join(',')].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + + const a = Object.assign(document.createElement('a'), { + href: url, + download: 'template.csv', + }); + + a.click(); + + window.URL.revokeObjectURL(url); + }; + + const onSubmit = async (values: TBulkSendFormSchema) => { + try { + const csv = await values.file.text(); + + await uploadBulkSend({ + templateId, + teamId: team?.id, + csv: csv, + sendImmediately: values.sendImmediately, + }); + + toast({ + title: _(msg`Success`), + description: _( + msg`Your bulk send has been initiated. You will receive an email notification upon completion.`, + ), + }); + + form.reset(); + onSuccess?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'Failed to upload CSV. Please check the file format and try again.', + variant: 'destructive', + }); + } + }; + + return ( + + + {trigger ?? ( + + )} + + + + + + Bulk Send Template via CSV + + + + + Upload a CSV file to create multiple documents from this template. Each row represents + one document with its recipient details. + + + + +
+ +
+

+ CSV Structure +

+ +

+ + For each recipient, provide their email (required) and name (optional) in separate + columns. Download the template CSV below for the correct format. + +

+ +

+ Current recipients: +

+ +
    + {recipients.map((recipient, index) => ( +
  • + {recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email} +
  • + ))} +
+
+ +
+ + +

+ Pre-formatted CSV template with example data. +

+
+ + ( + + + {!value ? ( + + ) : ( +
+
+ + {value.name} +
+ + +
+ )} +
+ + {error &&

{error.message}

} + +

+ + Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use + template defaults. + +

+
+ )} + /> + + ( + + +
+ + + +
+
+
+ )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx index c2d26e2ea..ef63bebef 100644 --- a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Trans } from '@lingui/react/macro'; import type { Recipient, Template, TemplateDirectLink } from '@prisma/client'; -import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react'; +import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react'; import { Link } from 'react-router'; import { useSession } from '@documenso/lib/client-only/providers/session'; @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; +import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog'; import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog'; import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog'; import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog'; @@ -89,6 +90,17 @@ export const TemplatesTableActionDropdown = ({ )} + + + Bulk Send via CSV + + } + /> + setDeleteDialogOpen(true)} diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx index bc8148429..7bd5074d5 100644 --- a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx @@ -10,6 +10,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog'; import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper'; import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog'; import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields'; @@ -119,6 +120,8 @@ export default function TemplatePage() {
+ +