mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
275 lines
9.0 KiB
TypeScript
275 lines
9.0 KiB
TypeScript
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<typeof ZBulkSendFormSchema>;
|
|
|
|
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<TBulkSendFormSchema>({
|
|
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 (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
{trigger ?? (
|
|
<Button>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
<Trans>Bulk Send via CSV</Trans>
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Bulk Send Template via CSV</Trans>
|
|
</DialogTitle>
|
|
|
|
<DialogDescription>
|
|
<Trans>
|
|
Upload a CSV file to create multiple documents from this template. Each row represents
|
|
one document with its recipient details.
|
|
</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
|
<div className="bg-muted/70 rounded-lg border p-4">
|
|
<h3 className="text-sm font-medium">
|
|
<Trans>CSV Structure</Trans>
|
|
</h3>
|
|
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
<Trans>
|
|
For each recipient, provide their email (required) and name (optional) in separate
|
|
columns. Download the template CSV below for the correct format.
|
|
</Trans>
|
|
</p>
|
|
|
|
<p className="mt-4 text-sm">
|
|
<Trans>Current recipients:</Trans>
|
|
</p>
|
|
|
|
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
|
{recipients.map((recipient, index) => (
|
|
<li key={index}>
|
|
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-y-2">
|
|
<Button onClick={onDownloadTemplate} variant="outline" type="button">
|
|
<Trans>Download Template CSV</Trans>
|
|
</Button>
|
|
|
|
<p className="text-muted-foreground text-xs">
|
|
<Trans>Pre-formatted CSV template with example data.</Trans>
|
|
</p>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="file"
|
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
{!value ? (
|
|
<Button asChild variant="outline" className="w-full">
|
|
<label className="cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept=".csv"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
onChange(file);
|
|
}
|
|
}}
|
|
disabled={form.formState.isSubmitting}
|
|
/>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
<Trans>Upload CSV</Trans>
|
|
</label>
|
|
</Button>
|
|
) : (
|
|
<div className="flex h-10 items-center rounded-md border px-3">
|
|
<div className="flex flex-1 items-center gap-2">
|
|
<FileIcon className="text-muted-foreground h-4 w-4" />
|
|
<span className="flex-1 truncate text-sm">{value.name}</span>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
className="text-destructive hover:text-destructive p-0 text-xs"
|
|
onClick={() => onChange(null)}
|
|
disabled={form.formState.isSubmitting}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">
|
|
<Trans>Remove</Trans>
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</FormControl>
|
|
|
|
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
|
|
|
<p className="text-muted-foreground text-xs">
|
|
<Trans>
|
|
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
|
template defaults.
|
|
</Trans>
|
|
</p>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="sendImmediately"
|
|
render={({ field }) => (
|
|
<FormItem className="flex items-center space-x-2">
|
|
<FormControl>
|
|
<div className="flex items-center">
|
|
<Checkbox
|
|
id="send-immediately"
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
|
|
<label
|
|
htmlFor="send-immediately"
|
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
|
>
|
|
<Trans>Send documents to recipients immediately</Trans>
|
|
</label>
|
|
</div>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<DialogFooter className="mt-4">
|
|
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
|
<Trans>Cancel</Trans>
|
|
</Button>
|
|
|
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
<Trans>Upload and Process</Trans>
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|