mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 10:25:00 +10:00
feat: signing reminders (#1749)
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -14,7 +14,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
recipient: TRecipientLite;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@@ -135,17 +136,10 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
id={envelope.id}
|
||||
@@ -207,6 +201,12 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
open={isSaveAsTemplateDialogOpen}
|
||||
onOpenChange={setSaveAsTemplateDialogOpen}
|
||||
/>
|
||||
|
||||
<EnvelopeRenameDialog
|
||||
id={envelope.id}
|
||||
initialTitle={envelope.title}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
@@ -11,6 +10,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -29,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentRecipientLinkCopyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const DocumentRecipientLinkCopyDialog = ({
|
||||
@@ -88,7 +88,7 @@ export const DocumentRecipientLinkCopyDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="text-muted-foreground divide-y rounded-lg border">
|
||||
<ul className="divide-y rounded-lg border text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
||||
+56
-2
@@ -13,7 +13,7 @@ import {
|
||||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { BellRingIcon, InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DOCUMENT_SIGNATURE_TYPES,
|
||||
} from '@documenso/lib/constants/document';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import {
|
||||
TemplateTypeSelect,
|
||||
TemplateTypeTooltip,
|
||||
@@ -145,10 +147,11 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -157,6 +160,12 @@ const tabs = [
|
||||
icon: SettingsIcon,
|
||||
description: msg`Configure document settings and options before sending.`,
|
||||
},
|
||||
{
|
||||
id: 'reminders',
|
||||
title: msg`Reminders`,
|
||||
icon: BellRingIcon,
|
||||
description: msg`Configure signing reminder settings for the document.`,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: msg`Email`,
|
||||
@@ -222,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -270,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
subject,
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
@@ -300,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -381,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
@@ -751,6 +767,44 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
)}
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{ activeTab: 'reminders', settings: { allowConfigureReminders: true } },
|
||||
() => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Signing Reminders</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Configure when and how often reminder emails are sent to
|
||||
recipients who have not yet completed signing. Uses the team
|
||||
default when set to inherit.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
|
||||
() => (
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { sortBy } from 'remeda';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
|
||||
export interface EnvelopeRecipientSelectorProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TEnvelopeRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
fields: Field[];
|
||||
align?: 'center' | 'end' | 'start';
|
||||
}
|
||||
@@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
@@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
|
||||
|
||||
interface EnvelopeRecipientSelectorCommandProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TEnvelopeRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
fields: Field[];
|
||||
placeholder?: string;
|
||||
}
|
||||
@@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
@@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
|
||||
[(r) => r.id, 'asc'],
|
||||
),
|
||||
] as [RecipientRole, Recipient[]],
|
||||
] as [RecipientRole, TEnvelopeRecipientLite[]],
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
@@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
@@ -247,7 +248,11 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
);
|
||||
};
|
||||
|
||||
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
|
||||
const extractRecipientLabel = (
|
||||
recipient: TEnvelopeRecipientLite,
|
||||
recipients: TEnvelopeRecipientLite[],
|
||||
i18n: I18n,
|
||||
) => {
|
||||
if (recipient.name && recipient.email) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentStatus, type Recipient } from '@prisma/client';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
@@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
@@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
@@ -98,7 +99,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient: Recipient) => (
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
@@ -122,7 +123,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
@@ -137,7 +138,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
@@ -152,7 +153,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
||||
const renderStackAvatars = (recipients: Recipient[]) => {
|
||||
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
|
||||
const renderStackAvatars = (recipients: TRecipientLite[]) => {
|
||||
const zIndex = 50;
|
||||
const itemsToRender = recipients.slice(0, 5);
|
||||
const remainingItems = recipients.length - itemsToRender.length;
|
||||
|
||||
return itemsToRender.map((recipient: Recipient, index: number) => {
|
||||
return itemsToRender.map((recipient, index: number) => {
|
||||
const first = index === 0;
|
||||
|
||||
if (index === 4 && remainingItems > 0) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
envelopeId: string;
|
||||
templateRootPath: string;
|
||||
readOnly?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user