diff --git a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts index f429b0a54..808d7259d 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -1,4 +1,4 @@ -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DateTime } from 'luxon'; import { kyselyPrisma, sql } from '@documenso/prisma'; @@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month'; export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { const qb = kyselyPrisma.$kysely - .selectFrom('Document') + .selectFrom('Envelope') .select(({ fn }) => [ - fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'), + fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'), fn.count('id').as('count'), fn .sum(fn.count('id')) // Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any - .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any)) + .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any)) .as('cume_count'), ]) - .where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) + .where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) + .where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`) .groupBy('month') .orderBy('month', 'desc') .limit(12); diff --git a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx index 9f82d8551..aee9167cc 100644 --- a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Document } from '@prisma/client'; import { useNavigate } from 'react-router'; import { trpc } from '@documenso/trpc/react'; @@ -22,10 +21,10 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminDocumentDeleteDialogProps = { - document: Document; + envelopeId: string; }; -export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => { +export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo return; } - await deleteDocument({ id: document.id, reason }); + await deleteDocument({ id: envelopeId, reason }); toast({ title: _(msg`Document deleted`), diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 57146ed9f..81ec5bdf4 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -57,14 +57,14 @@ export const DocumentDuplicateDialog = ({ const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = trpcReact.document.duplicate.useMutation({ - onSuccess: async ({ documentId }) => { + onSuccess: async ({ id }) => { toast({ title: _(msg`Document Duplicated`), description: _(msg`Your document has been successfully duplicated.`), duration: 5000, }); - await navigate(`${documentsPath}/${documentId}/edit`); + await navigate(`${documentsPath}/${id}/edit`); onOpenChange(false); }, }); diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index 401fb3529..c4c85c051 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({ }, ); - const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation(); + const { mutateAsync: updateDocument } = trpc.document.update.useMutation(); useEffect(() => { if (!open) { @@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({ const onSubmit = async (data: TMoveDocumentFormSchema) => { try { - await moveDocumentToFolder({ + await updateDocument({ documentId, - folderId: data.folderId ?? null, + data: { + folderId: data.folderId ?? null, + }, }); const documentsPath = formatDocumentsPath(team.url); diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index d93f29e84..d8c0a73ee 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -4,15 +4,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Recipient, SigningStatus } from '@prisma/client'; +import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client'; import { History } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; import * as z from 'zod'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar'; const FORM_ID = 'resend-email'; export type DocumentResendDialogProps = { - document: TDocumentRow; + document: Pick & { + user: Pick; + recipients: Recipient[]; + team: Pick | null; + }; recipients: Recipient[]; }; diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx new file mode 100644 index 000000000..a3aac4436 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -0,0 +1,449 @@ +import { useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { + DocumentDistributionMethod, + DocumentStatus, + EnvelopeType, + type Field, + FieldType, + type Recipient, + RecipientRole, +} from '@prisma/client'; +import { AnimatePresence, motion } from 'framer-motion'; +import { InfoIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { TEnvelope } from '@documenso/lib/types/envelope'; +import { formatSigningLink } from '@documenso/lib/utils/recipients'; +import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopeDistributeDialogProps = { + envelope: Pick & { + recipients: Recipient[]; + fields: Field[]; + }; + trigger?: React.ReactNode; +}; + +export const ZEnvelopeDistributeFormSchema = z.object({ + meta: z.object({ + emailId: z.string().nullable(), + emailReplyTo: z.preprocess( + (val) => (val === '' ? undefined : val), + z.string().email().optional(), + ), + subject: z.string(), + message: z.string(), + distributionMethod: z + .nativeEnum(DocumentDistributionMethod) + .optional() + .default(DocumentDistributionMethod.EMAIL), + }), +}); + +export type TEnvelopeDistributeFormSchema = z.infer; + +export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => { + const organisation = useCurrentOrganisation(); + + const recipients = envelope.recipients; + + const { toast } = useToast(); + const { t } = useLingui(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation(); + + const form = useForm({ + defaultValues: { + meta: { + emailId: envelope.documentMeta?.emailId ?? null, + emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined, + subject: envelope.documentMeta?.subject ?? '', + message: envelope.documentMeta?.message ?? '', + distributionMethod: + envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL, + }, + }, + resolver: zodResolver(ZEnvelopeDistributeFormSchema), + }); + + const { + handleSubmit, + setValue, + watch, + formState: { isSubmitting }, + } = form; + + const { data: emailData, isLoading: isLoadingEmails } = + trpc.enterprise.organisation.email.find.useQuery({ + organisationId: organisation.id, + perPage: 100, + }); + + const emails = emailData?.data || []; + + const distributionMethod = watch('meta.distributionMethod'); + + const everySignerHasSignature = useMemo( + () => + envelope.recipients + .filter((recipient) => recipient.role === RecipientRole.SIGNER) + .every((recipient) => + envelope.fields.some( + (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, + ), + ), + [envelope.recipients, envelope.fields], + ); + + const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { + try { + await distributeEnvelope({ envelopeId: envelope.id, meta }); + + toast({ + title: t`Envelope distributed`, + description: t`Your envelope has been distributed successfully.`, + duration: 5000, + }); + + setIsOpen(false); + } catch (err) { + toast({ + title: t`Something went wrong`, + description: t`This envelope could not be distributed at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + } + }; + + if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) { + return null; + } + + return ( + + {trigger} + + + + + Send Document + + + + Recipients will be able to sign the document once sent + + + {everySignerHasSignature ? ( +
+ +
+ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + setValue('meta.distributionMethod', value as DocumentDistributionMethod) + } + value={distributionMethod} + className="mb-2" + > + + + Email + + + None + + + + +
+ + {distributionMethod === DocumentDistributionMethod.EMAIL && ( + + +
+ {organisation.organisationClaim.flags.emailDomains && ( + ( + + + Email Sender + + + + + + + + )} + /> + )} + + ( + + + Reply To Email{' '} + (Optional) + + + + + + + + + )} + /> + + ( + + + Subject{' '} + (Optional) + + + + + + + + )} + /> + + ( + + + Message{' '} + (Optional) + + + + + + + + + + + +