diff --git a/.env.example b/.env.example index a8d782ecde..5ab159be23 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,11 @@ NEXT_PRIVATE_SMTP_FROM_NAME="Crove Sign" NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@sign.crove.com" # OPTIONAL: Support contact shown in transactional emails (password reset "contact us"). NEXT_PUBLIC_SUPPORT_EMAIL="help@dos.ai" +# OPTIONAL: Legal links shown on signup and recipient signing pages (self-host default hides them when unset). +NEXT_PUBLIC_TERMS_OF_SERVICE_URL="https://sign.crove.com/terms" +NEXT_PUBLIC_PRIVACY_POLICY_URL="https://sign.crove.com/privacy" +# OPTIONAL: Imprint link (shown alongside the legal links when set). +NEXT_PUBLIC_IMPRINT_URL= # OPTIONAL: Defines the service for nodemailer NEXT_PRIVATE_SMTP_SERVICE= # OPTIONAL: The API key to use for Resend.com diff --git a/apps/docs/content/docs/developers/embedding/css-variables.mdx b/apps/docs/content/docs/developers/embedding/css-variables.mdx index ae71859648..fd3df8bcdb 100644 --- a/apps/docs/content/docs/developers/embedding/css-variables.mdx +++ b/apps/docs/content/docs/developers/embedding/css-variables.mdx @@ -120,20 +120,25 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin ### Component Classes -| Class | Description | -| --------------------------------- | --------------------------------------------- | -| `.embed--Root` | Main container for the embedded experience | -| `.embed--DocumentContainer` | Container for the document and signing widget | -| `.embed--DocumentViewer` | Container for the document viewer | -| `.embed--DocumentWidget` | The signing widget container | -| `.embed--DocumentWidgetContainer` | Outer container for the signing widget | -| `.embed--DocumentWidgetHeader` | Header section of the signing widget | -| `.embed--DocumentWidgetContent` | Main content area of the signing widget | -| `.embed--DocumentWidgetForm` | Form section within the signing widget | -| `.embed--DocumentWidgetFooter` | Footer section of the signing widget | -| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn | -| `.embed--DocumentCompleted` | Completion screen after signing | -| `.field--FieldRootContainer` | Base container for document fields | +| Class | Description | +| ---------------------------------------- | --------------------------------------------- | +| `.embed--Root` | Main container for the embedded experience | +| `.embed--DocumentContainer` | Container for the document and signing widget | +| `.embed--DocumentViewer` | Container for the document viewer | +| `.embed--DocumentWidget` | The signing widget container | +| `.embed--DocumentWidgetContainer` | Outer container for the signing widget | +| `.embed--DocumentWidgetHeader` | Header section of the signing widget | +| `.embed--DocumentWidgetContent` | Main content area of the signing widget | +| `.embed--DocumentWidgetForm` | Form section within the signing widget | +| `.embed--DocumentWidgetFooter` | Footer section of the signing widget | +| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn | +| `.embed--DocumentCompleted` | Completion screen after signing | +| `.embed--DocumentCompletedCard` | Signature card on the completion screen | +| `.embed--DocumentCompletedTitle` | Title on the completion screen | +| `.embed--DocumentCompletedStatus` | Status line on the completion screen | +| `.embed--DocumentCompletedDescription` | Description text on the completion screen | +| `.embed--DocumentRejected` | Rejection screen after rejecting the document | +| `.field--FieldRootContainer` | Base container for document fields | ### Field Data Attributes @@ -144,6 +149,7 @@ Fields expose data attributes for state-based styling: | `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field | | `[data-inserted]` | `true`, `false` | Whether the field has been filled | | `[data-validate]` | `true`, `false` | Whether the field is being validated | +| `[data-readonly]` | `true`, `false` | Whether the field is read-only | ### Example @@ -155,7 +161,7 @@ Fields expose data attributes for state-based styling: /* Style filled fields */ .field--FieldRootContainer[data-inserted='true'] { - background-color: var(--primary); + background-color: hsl(var(--primary)); opacity: 0.2; } diff --git a/apps/docs/content/docs/self-hosting/configuration/database.mdx b/apps/docs/content/docs/self-hosting/configuration/database.mdx index a9cc1d93fb..b2e4ded075 100644 --- a/apps/docs/content/docs/self-hosting/configuration/database.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/database.mdx @@ -191,9 +191,6 @@ For manual deployments or troubleshooting: ```bash # Apply pending migrations npm run prisma:migrate-deploy - -# Or using npx directly -npx prisma migrate deploy ``` diff --git a/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx b/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx index 832bb0088e..14a66c912c 100644 --- a/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx +++ b/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx @@ -426,8 +426,9 @@ docker pull documenso/documenso: # Run migrations only docker run --rm \ -e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \ + -e NEXT_PRIVATE_DIRECT_DATABASE_URL="postgresql://user:password@host:5432/documenso" \ documenso/documenso: \ - npx prisma migrate deploy + npx prisma migrate deploy --schema ../../packages/prisma/schema.prisma ``` diff --git a/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx b/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx index a7bb62eb28..849dd8a3a4 100644 --- a/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx @@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({ toast({ title: t`Failed to read file`, - description: t`The file is not a valid PDF.`, + description: t`The file is not a valid PDF or is password protected.`, variant: 'destructive', }); } diff --git a/apps/remix/app/components/embed/embed-document-completed.tsx b/apps/remix/app/components/embed/embed-document-completed.tsx index be94ca76b5..893805d4d3 100644 --- a/apps/remix/app/components/embed/embed-document-completed.tsx +++ b/apps/remix/app/components/embed/embed-document-completed.tsx @@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { Trans } from '@lingui/react/macro'; import type { Signature } from '@prisma/client'; +import { CheckCircle2Icon } from 'lucide-react'; export type EmbedDocumentCompletedPageProps = { name?: string; @@ -10,12 +11,8 @@ export type EmbedDocumentCompletedPageProps = { export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => { return ( -
-

- Document Completed! -

- -
+
+
-

- - The document is now completed, please follow any instructions provided within the parent application. - +

+ Document Completed +

+ +
+ + + No further action is required + +
+ +

+ Please follow any instructions provided within the parent application.

); diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 739a5c8a6a..ff583ed226 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -55,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = { completedFields: DocumentField[]; metadata?: DocumentMeta | null; isCompleted?: boolean; + isRejected?: boolean; hidePoweredBy?: boolean; allowWhitelabelling?: boolean; allRecipients?: RecipientWithFields[]; @@ -70,6 +71,7 @@ export const EmbedSignDocumentV1ClientPage = ({ completedFields, metadata, isCompleted, + isRejected, hidePoweredBy = false, allowWhitelabelling = false, allRecipients = [], @@ -83,7 +85,9 @@ export const EmbedSignDocumentV1ClientPage = ({ const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); - const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED); + const [hasRejectedDocument, setHasRejectedDocument] = useState( + isRejected ?? recipient.signingStatus === SigningStatus.REJECTED, + ); const [selectedSignerId, setSelectedSignerId] = useState( allRecipients.length > 0 ? allRecipients[0].id : null, ); @@ -263,6 +267,44 @@ export const EmbedSignDocumentV1ClientPage = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!window.parent) { + return; + } + + if (hasRejectedDocument) { + window.parent.postMessage( + { + action: 'document-rejected', + data: { + token, + documentId, + recipientId: recipient.id, + }, + }, + '*', + ); + + return; + } + + if (hasCompletedDocument) { + window.parent.postMessage( + { + action: 'document-completed', + data: { + token, + documentId, + recipientId: recipient.id, + }, + }, + '*', + ); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (hasFinishedInit && hasDocumentLoaded && window.parent) { window.parent.postMessage( diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx index 55e7cb6d1d..8973a23df1 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -40,12 +40,18 @@ export const EmbedSignDocumentV2ClientPage = ({ const [isNameLocked, setIsNameLocked] = useState(false); const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email); + // The signing provider's envelope data isn't refreshed on revalidation. + const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); + const [hasRejectedDocument, setHasRejectedDocument] = useState(isRejected); + const onDocumentCompleted = (data: { token: string; documentId: number; envelopeId: string; recipientId: number; }) => { + setHasCompletedDocument(true); + if (window.parent) { window.parent.postMessage( { @@ -112,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({ recipientId: number; reason?: string; }) => { + setHasRejectedDocument(true); + if (window.parent) { window.parent.postMessage( { @@ -219,23 +227,26 @@ export const EmbedSignDocumentV2ClientPage = ({ } }, [isRejected, envelope.id, recipient.id, recipient.token]); - if (isRejected) { + if (hasRejectedDocument) { return ; } - if (isCompleted) { + if (hasCompletedDocument) { + const completedSignature = + recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null; + return ( file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, - `File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`, + msg`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`, + ) + .refine( + (file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), + msg`Only .jpg, .png, and .webp files are accepted`, ) - .refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') .nullish(), - brandingUrl: z.string().url().optional().or(z.literal('')), - brandingCompanyDetails: z.string().max(500).optional(), + brandingUrl: z.string().url(msg`Please enter a valid URL`).optional().or(z.literal('')), + brandingCompanyDetails: z.string().max(500, msg`Brand details must be less than 500 characters`).optional(), brandingColors: ZCssVarsSchema.default({}), - brandingCss: z.string().max(10_000).default(''), + brandingCss: z.string().max(10_000, msg`Custom CSS must be less than 10,000 characters`).default(''), }); export type TBrandingPreferencesFormSchema = z.infer; @@ -349,6 +361,8 @@ export function BrandingPreferencesForm({ )} + +
)} @@ -379,6 +393,8 @@ export function BrandingPreferencesForm({ )} + + )} /> @@ -413,6 +429,8 @@ export function BrandingPreferencesForm({ )} + + )} /> @@ -597,6 +615,8 @@ export function BrandingPreferencesForm({ Border radius size in REM units (e.g. 0.5rem). + + )} /> @@ -634,6 +654,8 @@ export function BrandingPreferencesForm({ shown after you save. + + )} /> diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 59d3e654c0..12e9255c19 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -1,6 +1,11 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png'; import { authClient } from '@documenso/auth/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { + NEXT_PUBLIC_IMPRINT_URL, + NEXT_PUBLIC_PRIVACY_POLICY_URL, + NEXT_PUBLIC_TERMS_OF_SERVICE_URL, +} from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; @@ -20,7 +25,8 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { TurnstileInstance } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile'; -import { useEffect, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { Fragment, useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; @@ -104,6 +110,14 @@ export const SignUpForm = ({ const isSubmitting = form.formState.isSubmitting; + const legalLinks: { href: string | undefined; label: ReactNode }[] = [ + { href: NEXT_PUBLIC_TERMS_OF_SERVICE_URL(), label: Terms of Service }, + { href: NEXT_PUBLIC_PRIVACY_POLICY_URL(), label: Privacy Policy }, + { href: NEXT_PUBLIC_IMPRINT_URL(), label: Imprint }, + ]; + + const availableLegalLinks = legalLinks.filter((link): link is { href: string; label: ReactNode } => !!link.href); + const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { try { let token: string | undefined; @@ -408,19 +422,32 @@ export const SignUpForm = ({ )} -

- - By proceeding, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . - -

+ {availableLegalLinks.length > 0 && ( +

+ + By proceeding, you agree to our{' '} + {availableLegalLinks.map((link, index) => ( + + {index > 0 && ( + <> + {' '} + and{' '} + + )} + + {link.label} + + + ))} + . + +

+ )}
); diff --git a/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx b/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx new file mode 100644 index 0000000000..7e1476a775 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx @@ -0,0 +1,371 @@ +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Input } from '@documenso/ui/primitives/input'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { LucideIcon } from 'lucide-react'; +import { SearchIcon, UsersIcon } from 'lucide-react'; +import type { MouseEvent, ReactNode } from 'react'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; +import { formatRelativeDate } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */ +export type AnalyticsActivityRow = { + key: string | number; + avatar: { + imageId: string | null; + fallback: string; + }; + title: string; + subtitle?: string | null; + sent: number; + completed: number; + pending: number; + /** 0-100, null when nothing was sent. */ + completionRate: number | null; + lastActiveAt: Date | null; + /** When set the whole row navigates here and the title becomes a link. */ + href?: string; +}; + +export type AnalyticsActivityTableCardProps = { + query: AnalyticsQueryResult; + /** Rows derived from `query.data`; empty while loading. */ + rows: AnalyticsActivityRow[]; + /** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */ + rangeKey: string; + title: ReactNode; + description: ReactNode; + /** Header of the first column, e.g. "Member". */ + columnLabel: ReactNode; + /** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */ + renderSummary: (count: number, activeCount: number) => ReactNode; + /** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */ + renderShowing: (visibleCount: number, totalCount: number) => ReactNode; + emptyLabel: ReactNode; + emptyIcon?: LucideIcon; + /** Placeholder for the search input, e.g. "Search members". */ + searchPlaceholder: string; + /** Rendered when the search matches nothing, e.g. "No members match your search". */ + noSearchResultsLabel: ReactNode; + /** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */ + testIdPrefix: string; + className?: string; +}; + +export const AnalyticsActivityTableCard = ({ + query, + rows, + rangeKey, + title, + description, + columnLabel, + renderSummary, + renderShowing, + emptyLabel, + emptyIcon: EmptyIcon = UsersIcon, + searchPlaceholder, + noSearchResultsLabel, + testIdPrefix, + className, +}: AnalyticsActivityTableCardProps) => { + const { i18n } = useLingui(); + + // Tracks which window "Show all" was pressed for, so it resets whenever the window changes. + const [expandedRangeKey, setExpandedRangeKey] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const isExpanded = expandedRangeKey === rangeKey; + + const { data, isLoading, isError, refetch } = query; + + const activeCount = rows.filter((row) => row.sent > 0).length; + + const normalisedSearchTerm = searchTerm.trim().toLowerCase(); + const isSearching = normalisedSearchTerm.length > 0; + + // Search always shows every match; the preview limit only applies to the unfiltered list. + const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows; + const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT); + const hasHiddenRows = filteredRows.length > visibleRows.length; + + const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`; + + return ( + + +
+ {title} + + {description} +
+ + {data !== undefined && rows.length > 0 && ( +

+ {renderSummary(rows.length, activeCount)} +

+ )} +
+ + + {isError ? ( + + ) : isLoading || data === undefined ? ( +
    + {Array.from({ length: 4 }, (_, index) => ( +
  • + + +
    + + +
    + + + + + + +
  • + ))} +
+ ) : rows.length === 0 ? ( +
+
+
+ +

{emptyLabel}

+
+ ) : ( +
+
+
+ + {filteredRows.length === 0 ? ( +

+ {noSearchResultsLabel} +

+ ) : ( + <> + {/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */} +
+ + + + {columnLabel} + + Sent + + + Completed + + + Pending + + + Completion rate + + + Last active + + + + + + {visibleRows.map((row) => ( + + ))} + +
+
+ + {hasHiddenRows && ( +
+

+ {renderShowing(visibleRows.length, filteredRows.length)} +

+ + +
+ )} + + )} +
+ )} +
+
+ ); +}; + +type ActivityRowProps = { + row: AnalyticsActivityRow; + locale: string; + testIdPrefix: string; +}; + +const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => { + const { _ } = useLingui(); + const navigate = useNavigate(); + + const isActive = row.sent > 0; + + const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`; + + // Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout. + const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground'); + const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums'; + + // role="img" so the aria-label is valid (a bare span has no role that supports it). + const notAvailable = ( + + — + + ); + + /** + * The title link is the accessible target; clicking anywhere else on the row + * navigates too. Modifier clicks and clicks on the link itself are left to the + * browser so open-in-new-tab keeps working, and drag-selecting text does not + * navigate. + */ + const handleRowClick = (event: MouseEvent) => { + if (!row.href || event.defaultPrevented || event.button !== 0) { + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + if (event.target instanceof Element && event.target.closest('a')) { + return; + } + + if (window.getSelection()?.toString()) { + return; + } + + void navigate(row.href); + }; + + return ( + + {/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */} + +
+ + {row.avatar.imageId && } + {row.avatar.fallback} + + +
+ {row.href ? ( + + {row.title} + + ) : ( + + {row.title} + + )} + + {row.subtitle && {row.subtitle}} +
+
+
+ + + {row.sent.toLocaleString(locale)} + + + + {row.completed.toLocaleString(locale)} + + + + {row.pending.toLocaleString(locale)} + + + + {row.completionRate === null ? ( + notAvailable + ) : ( +
+ + )} + + + + {row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)} + + + ); +}; + +const ROW_PREVIEW_LIMIT = 8; + +const matchesSearch = (row: AnalyticsActivityRow, term: string) => { + return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term); +}; + +/** + * The table is pulled out to the card edge (-mx-6), so the outer cells get the + * card's px-6 gutter to line up with the header. "Last active" is hidden below + * md, so "Completion rate" takes the right gutter there. + */ +const FIRST_CELL_CLASS = '!pl-6'; +const LAST_CELL_CLASS = '!pr-6'; +const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4'; diff --git a/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx b/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx new file mode 100644 index 0000000000..f122afe393 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx @@ -0,0 +1,203 @@ +import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { BarChart3Icon } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics'; +import { getAnalyticsDateRangeDays } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsDocumentsOverTimeCardProps = { + range: AnalyticsRangeValue; + query: AnalyticsQueryResult; + className?: string; +}; + +type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket']; + +export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + // The backend decides the bucket, and it must match the points being rendered so + // the tick and tooltip formatting line up. Before data arrives it is guessed from + // the requested range. + const bucket: Bucket = data ? data.range.bucket : guessBucket(range); + + const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0; + + return ( + + +
+ + Documents created + + + {bucket === 'month' ? Monthly : Daily} +
+ + {data && ( +

+ + {data.total.toLocaleString(i18n.locale)} + {' '} + + total + +

+ )} +
+ + {/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */} + + {isError ? ( + + ) : isLoading || !data ? ( + + ) : data.total === 0 ? ( +
+
+
+ +

+ No documents created in this period +

+
+ ) : ( + + + + + formatTickLabel(value, bucket, i18n.locale)} + /> + + + + } + cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }} + /> + + + + + )} +
+
+ ); +}; + +type DocumentsOverTimeTooltipProps = { + active?: boolean; + payload?: Array<{ payload: { date: string; count: number } }>; + bucket: Bucket; + locale: string; +}; + +const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => { + const point = payload?.[0]?.payload; + + if (!active || !point) { + return null; + } + + const count = Number(point.count ?? 0); + + return ( +
+

{formatTooltipLabel(point.date, bucket, locale)}

+ +

+ +

+
+ ); +}; + +const CHART_HEIGHT = 240; + +const TARGET_DAILY_TICK_COUNT = 6; + +/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */ +const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92; + +const guessBucket = (range: AnalyticsRangeValue): Bucket => { + if (range.range === '12m') { + return 'month'; + } + + if (range.range === 'custom') { + return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day'; + } + + return 'day'; +}; + +/** + * Month buckets label every month (12 fit at the lg width) and let recharts drop + * overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels. + */ +const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => { + if (bucket === 'month') { + return 'preserveStartEnd'; + } + + if (pointCount <= TARGET_DAILY_TICK_COUNT) { + return 0; + } + + return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1); +}; + +const formatTickLabel = (date: string, bucket: Bucket, locale: string) => { + const parsed = DateTime.fromISO(date).setLocale(locale); + + if (bucket === 'month') { + return parsed.toLocaleString({ month: 'short' }); + } + + return parsed.toLocaleString({ month: 'short', day: 'numeric' }); +}; + +const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => { + const parsed = DateTime.fromISO(date).setLocale(locale); + + if (bucket === 'month') { + return parsed.toLocaleString({ month: 'long', year: 'numeric' }); + } + + return parsed.toLocaleString(DateTime.DATE_FULL); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx b/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx new file mode 100644 index 0000000000..727284d354 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx @@ -0,0 +1,17 @@ +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Trans } from '@lingui/react/macro'; + +/** + * Shown while the analytics route's `clientLoader` resolves the browser timezone + * during hydration. + */ +export const AnalyticsHydrateFallback = () => { + return ( +
+ + + Loading analytics + +
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx b/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx new file mode 100644 index 0000000000..4eeae63871 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx @@ -0,0 +1,45 @@ +import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { InfoIcon } from 'lucide-react'; + +import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics'; + +export type AnalyticsNoActivityAlertProps = { + range: TTeamAnalyticsRange; + /** Invoked when the user asks to widen the range to the last 12 months. */ + onShowLastYear: () => void; +}; + +export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => { + const { _ } = useLingui(); + + const canWidenRange = range !== '12m'; + + return ( + + + + + + {canWidenRange && ( + + )} + + + ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx b/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx new file mode 100644 index 0000000000..e7acfad18c --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx @@ -0,0 +1,214 @@ +import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { LucideIcon } from 'lucide-react'; +import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; + +import { AnalyticsStatCard } from './analytics-stat-card'; + +/** The part of the overview response shared by the team and organisation procedures. */ +export type AnalyticsOverviewData = Pick; + +/** + * The third card counts the scope's "entities" (team members, organisation teams) + * and how many of them were active in the period. + */ +export type AnalyticsOverviewEntityCard = { + icon: LucideIcon; + title: ReactNode; + /** Applied to the value element, e.g. `analytics-members`. */ + testId: string; + select: (data: TData) => { active: number; total: number }; +}; + +export type AnalyticsOverviewCardsProps = { + query: AnalyticsQueryResult; + entity: AnalyticsOverviewEntityCard; +}; + +export const AnalyticsOverviewCards = ({ + query, + entity, +}: AnalyticsOverviewCardsProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + const entityCounts = data ? entity.select(data) : null; + + const formatNumber = (value: number) => value.toLocaleString(i18n.locale); + + const sharedProps = { + isLoading: isLoading || !data, + isError, + onRetry: refetch, + }; + + return ( +
+ Documents sent} + value={data ? formatNumber(data.sent.current) : null} + badge={data ? : null} + description={vs. previous period} + testId="analytics-sent" + /> + + Completion rate} + value={data ? formatRate(data.completionRate.rate) : null} + badge={ + data ? ( + + ) : null + } + description={of sent documents completed} + testId="analytics-completion-rate" + /> + + + {formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '} + inactive + + ) : null + } + testId={entity.testId} + /> +
+ ); +}; + +type SentDeltaBadgeProps = { + current: number; + previous: number; +}; + +const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => { + if (previous === 0 && current === 0) { + return null; + } + + if (previous === 0) { + return ( + + New + + ); + } + + const delta = Math.round(((current - previous) / previous) * 100); + + // Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display. + const label = + delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`; + + return ( + + {label} + + ); +}; + +type CompletionRateDeltaBadgeProps = { + rate: number | null; + previousRate: number | null; +}; + +const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => { + if (rate === null || previousRate === null) { + return null; + } + + // Compare the rounded values so the delta always agrees with the displayed rate. + const delta = Math.round(rate) - Math.round(previousRate); + + return ( + + {formatSignedNumber(delta)}% + + ); +}; + +type DeltaTone = 'positive' | 'negative' | 'zero' | 'new'; + +type DeltaBadgeProps = { + tone: DeltaTone; + testId: string; + children: ReactNode; +}; + +const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => { + const DeltaIcon = DELTA_TONE_ICONS[tone]; + + return ( + + {DeltaIcon && + ); +}; + +const DELTA_TONE_CLASSES: Record = { + positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + negative: 'bg-red-500/10 text-red-600 dark:text-red-400', + zero: 'bg-muted text-muted-foreground', + new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', +}; + +const MAX_DISPLAYED_DELTA_PERCENT = 999; + +const DELTA_TONE_ICONS: Record = { + positive: ArrowUpRightIcon, + negative: ArrowDownRightIcon, + zero: null, + new: null, +}; + +const formatRate = (rate: number | null) => { + if (rate === null) { + return '—'; + } + + return `${Math.round(rate)}%`; +}; + +const formatSignedNumber = (value: number) => { + if (value > 0) { + return `+${value}`; + } + + return String(value); +}; + +const getDeltaTone = (delta: number): DeltaTone => { + if (delta > 0) { + return 'positive'; + } + + if (delta < 0) { + return 'negative'; + } + + return 'zero'; +}; diff --git a/apps/remix/app/components/general/analytics/analytics-page-header.tsx b/apps/remix/app/components/general/analytics/analytics-page-header.tsx new file mode 100644 index 0000000000..667d0c85b5 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-page-header.tsx @@ -0,0 +1,67 @@ +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { ReactNode } from 'react'; + +import type { AnalyticsRangeValue } from '~/utils/analytics'; +import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics'; + +import { AnalyticsRangePicker } from './analytics-range-picker'; + +export type AnalyticsPageHeaderProps = { + avatarImageId: string | null; + /** The team or organisation name. */ + name: string; + range: AnalyticsRangeValue; + onRangeChange: (range: AnalyticsRangeValue) => void; + /** Rendered before the range picker, e.g. a link to a related analytics page. */ + actions?: ReactNode; + className?: string; +}; + +export const AnalyticsPageHeader = ({ + avatarImageId, + name, + range, + onRangeChange, + actions, + className, +}: AnalyticsPageHeaderProps) => { + const { _, i18n } = useLingui(); + + const rangeLabel = + range.range === 'custom' + ? formatAnalyticsDateRange(range.from, range.to, i18n.locale) + : _(ANALYTICS_RANGE_LABELS[range.range]); + + return ( +
+
+ + {avatarImageId && } + {name.slice(0, 1)} + + +
+

+ Analytics +

+ +

+ + Usage overview for {name} · {rangeLabel} + +

+
+
+ +
+ {actions} + + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-query-error.tsx b/apps/remix/app/components/general/analytics/analytics-query-error.tsx new file mode 100644 index 0000000000..41dd480309 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-query-error.tsx @@ -0,0 +1,37 @@ +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type AnalyticsQueryErrorProps = { + onRetry: () => Promise; + className?: string; +}; + +export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => { + const [isRetrying, setIsRetrying] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + }; + + return ( + + + + This data could not be loaded. + + + + + + ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-range-picker.tsx b/apps/remix/app/components/general/analytics/analytics-range-picker.tsx new file mode 100644 index 0000000000..ff2ed33a43 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-range-picker.tsx @@ -0,0 +1,264 @@ +import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; +import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Button } from '@documenso/ui/primitives/button'; +import type { CalendarProps } from '@documenso/ui/primitives/calendar'; +import { Calendar } from '@documenso/ui/primitives/calendar'; +import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { DateTime } from 'luxon'; +import { useRef, useState } from 'react'; + +import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics'; +import { + ANALYTICS_PRESET_RANGES, + ANALYTICS_RANGE_LABELS, + formatAnalyticsDate, + formatAnalyticsDateRange, + getAnalyticsDateRangeDays, +} from '~/utils/analytics'; + +export type AnalyticsRangePickerProps = { + value: AnalyticsRangeValue; + onValueChange: (value: AnalyticsRangeValue) => void; +}; + +/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */ +type DraftRange = { + from: Date | undefined; + to: Date | undefined; +}; + +/** A single react-day-picker matcher, e.g. `{ after: Date }`. */ +type DayMatcher = Exclude; + +/** + * A preset select with a "Custom range…" item that opens a two month range + * calendar anchored to the select. The custom window is only committed when + * "Apply" is pressed. + */ +export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => { + const { _, i18n } = useLingui(); + const { width } = useWindowSize(); + + const triggerRef = useRef(null); + const contentRef = useRef(null); + + const [isPickerOpen, setIsPickerOpen] = useState(false); + const [draft, setDraft] = useState(); + + const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1; + + const today = DateTime.local().startOf('day'); + + const openPicker = () => { + setDraft( + value.range === 'custom' + ? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() } + : undefined, + ); + + setIsPickerOpen(true); + }; + + const closePicker = () => { + setIsPickerOpen(false); + setDraft(undefined); + }; + + const handleSelectValueChange = (nextValue: string) => { + if (nextValue === CUSTOM_RANGE_VALUE) { + openPicker(); + + return; + } + + const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue); + + if (!preset) { + return; + } + + onValueChange({ range: preset }); + }; + + /** + * Picking a day starts a new window unless one end is already pending, in which + * case it completes it. This replaces react-day-picker's default, which extends + * a completed window instead of starting over. + */ + const handleDaySelect = (_nextRange: unknown, day: Date) => { + if (draft?.from && !draft.to) { + setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day }); + + return; + } + + setDraft({ from: day, to: undefined }); + }; + + const handleApply = () => { + if (!draft?.from || !draft.to) { + return; + } + + onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) }); + + closePicker(); + }; + + // Only the last year (plus a day) up to today is selectable. + const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK); + const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }]; + + // Open on the month of the pending window (or today), keeping the current month + // as the right-most one so no fully disabled future month is shown. + const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month'); + const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 }); + const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate(); + + const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null; + const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null; + const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0; + + const customLabel = + value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined; + + return ( + { + if (!open) { + closePicker(); + } + }} + > + {/* + * The select never holds "custom" as its value so choosing "Custom range…" always + * fires a change, letting an active custom window be adjusted. The trigger shows + * the formatted window through the placeholder instead. + */} + + + { + if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) { + return; + } + + event.preventDefault(); + + const content = contentRef.current; + const firstTabbable = content?.querySelector(TABBABLE_SELECTOR); + + (firstTabbable ?? content)?.focus(); + }} + // There is no popover trigger element, so hand focus back to the select. + onCloseAutoFocus={(event) => { + event.preventDefault(); + triggerRef.current?.focus(); + }} + > +
+ +
+ +
+

+ {draftFrom && draftTo ? ( + <> + {formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '} + + + ) : draftFrom ? ( + Pick an end date + ) : ( + Pick a start date + )} +

+ +
+ + + +
+
+
+
+ ); +}; + +const CUSTOM_RANGE_VALUE = 'custom'; + +/** Tailwind `sm` breakpoint; two months are shown from here up. */ +const SM_BREAKPOINT = 640; + +/** First element the popover should focus: the calendar's month navigation, then the days. */ +const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]'; + +const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({ + value, + label: ANALYTICS_RANGE_LABELS[value], +})); diff --git a/apps/remix/app/components/general/analytics/analytics-stat-card.tsx b/apps/remix/app/components/general/analytics/analytics-stat-card.tsx new file mode 100644 index 0000000000..b0f3ba8145 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-stat-card.tsx @@ -0,0 +1,63 @@ +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsStatCardProps = { + icon: LucideIcon; + title: ReactNode; + value: ReactNode; + description: ReactNode; + badge?: ReactNode; + isLoading: boolean; + isError: boolean; + onRetry: () => Promise; + testId: string; +}; + +export const AnalyticsStatCard = ({ + icon: Icon, + title, + value, + description, + badge, + isLoading, + isError, + onRetry, + testId, +}: AnalyticsStatCardProps) => { + return ( + + +
+

{title}

+ +
+ + {isError ? ( + + ) : isLoading ? ( +
+ + +
+ ) : ( + <> +
+

+ {value} +

+ + {badge} +
+ +

{description}

+ + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx b/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx new file mode 100644 index 0000000000..38bd260e82 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx @@ -0,0 +1,198 @@ +import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsStatusBreakdownCardProps = { + query: AnalyticsQueryResult; + className?: string; +}; + +export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => { + const { _, i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : []; + + return ( + + +
+ + Status breakdown + + + + Documents created in this period + +
+ + {data && ( +

+ + {data.total.toLocaleString(i18n.locale)} + {' '} + + total + +

+ )} +
+ + + {isError ? ( + + ) : isLoading || !data ? ( +
+ + +
+ {STATUS_ROWS.slice(0, 3).map((row) => ( + + ))} +
+
+ ) : data.total === 0 ? ( +
+ + +

+ No documents in this period +

+
+ ) : ( +
+ + +
    + {rows.map((row) => ( +
  • +
    +
    + +
    + + {row.count.toLocaleString(i18n.locale)} + + {row.percent}% +
    +
  • + ))} +
+
+ )} +
+
+ ); +}; + +type StatusBarProps = { + segments: Array<{ key: string; percent: number; color: string }>; + label: string; +}; + +/** + * Stacked horizontal bar. Segment widths come from the largest-remainder + * percentages so they always add up to the full width; an empty list renders + * the muted track on its own. + */ +const StatusBar = ({ segments, label }: StatusBarProps) => { + return ( +
+ {segments.map((segment) => ( +
+ ))} +
+ ); +}; + +type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled'; + +type StatusRow = { + key: StatusKey; + label: MessageDescriptor; + color: string; +}; + +/** + * Single source of truth for status colours so the bar and the legend cannot drift. + */ +const STATUS_ROWS: StatusRow[] = [ + { key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' }, + { key: 'pending', label: msg`Pending`, color: '#f59e0b' }, + { key: 'rejected', label: msg`Rejected`, color: '#ef4444' }, + { key: 'cancelled', label: msg`Cancelled`, color: '#f97316' }, + { key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' }, +]; + +/** + * Assign integer percentages to the non-zero rows using largest-remainder + * allocation so the values always sum to exactly 100, with every non-zero row + * shown as at least 1%. + */ +const allocatePercentages = (rows: T[]): Array => { + const visibleRows = rows.filter((row) => row.count > 0); + const total = visibleRows.reduce((sum, row) => sum + row.count, 0); + + if (total === 0) { + return []; + } + + const allocations = visibleRows.map((row, index) => { + const exact = (row.count / total) * 100; + const floored = Math.floor(exact); + + return { index, percent: floored, remainder: exact - floored }; + }); + + let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0); + + const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index); + + for (const allocation of byRemainder) { + if (remaining <= 0) { + break; + } + + allocation.percent += 1; + remaining -= 1; + } + + // Every non-zero row must display at least 1%; take the difference from the largest rows. + const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index); + + for (const allocation of allocations) { + if (allocation.percent > 0) { + continue; + } + + allocation.percent = 1; + + const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1); + + if (donor) { + donor.percent -= 1; + } + } + + return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent })); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx b/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx new file mode 100644 index 0000000000..9f48374192 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx @@ -0,0 +1,145 @@ +import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { FileTextIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; +import { formatRelativeDate } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +/** The template shape shared by the team and organisation procedures. */ +export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number]; + +export type AnalyticsTemplateUsageCardProps = { + query: AnalyticsQueryResult<{ templates: TTemplate[] }>; + /** Where the template title links to. Return null to render a plain title. */ + getTemplateHref: (template: TTemplate) => string | null; + /** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */ + renderTemplateMeta?: (template: TTemplate) => ReactNode; + /** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */ + templatesHref?: string; + className?: string; +}; + +export const AnalyticsTemplateUsageCard = ({ + query, + getTemplateHref, + renderTemplateMeta, + templatesHref, + className, +}: AnalyticsTemplateUsageCardProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + return ( + + + + Template usage + + + + Documents created from templates + + + + + {isError ? ( + + ) : isLoading || !data ? ( +
    + {Array.from({ length: 3 }, (_, index) => ( +
  • + + + +
    + + +
    + + +
  • + ))} +
+ ) : data.templates.length === 0 ? ( +
+
+
+ +

+ No documents were created from templates in this period +

+ + {templatesHref && ( + + )} +
+ ) : ( +
    + {data.templates.map((template, index) => { + const href = template.title === null ? null : getTemplateHref(template); + const meta = renderTemplateMeta?.(template); + + return ( +
  1. + + +
    +
    + +
    + {template.title === null ? ( + + Unavailable template + + ) : href !== null ? ( + + {template.title} + + ) : ( + {template.title} + )} + + {(meta || template.updatedAt !== null) && ( + + {meta} + {meta && template.updatedAt !== null && ' · '} + {template.updatedAt !== null && ( + Updated {formatRelativeDate(template.updatedAt, i18n.locale)} + )} + + )} +
    + + + + +
  2. + ); + })} +
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 7a53ff2492..559fd4fd77 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -597,7 +597,7 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => { {hasValidSearch ? ( isVisibleCountCapped ? ( - {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + ) : ( ) diff --git a/apps/remix/app/components/general/app-nav-mobile.tsx b/apps/remix/app/components/general/app-nav-mobile.tsx index b65273f630..f895ef68c5 100644 --- a/apps/remix/app/components/general/app-nav-mobile.tsx +++ b/apps/remix/app/components/general/app-nav-mobile.tsx @@ -1,5 +1,8 @@ import { authClient } from '@documenso/auth/client'; +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations'; +import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; @@ -23,6 +26,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps const { organisations } = useSession(); const currentTeam = useOptionalCurrentTeam(); + const currentOrganisation = useOptionalCurrentOrganisation(); const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery( { @@ -38,18 +42,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps }; const menuNavigationLinks = useMemo(() => { - let teamUrl = currentTeam?.url || null; + const navigationTeam = + currentTeam ?? + (organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null); - if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) { - teamUrl = organisations[0].teams[0].url; - } - - if (!teamUrl) { + if (!navigationTeam) { return [ { href: '/inbox', text: t`Inbox`, }, + ...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole) + ? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }] + : []), { href: '/settings/profile', text: t`Settings`, @@ -57,6 +62,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps ]; } + const teamUrl = navigationTeam.url; + return [ { href: `/t/${teamUrl}/documents`, @@ -70,12 +77,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps href: '/inbox', text: t`Inbox`, }, + ...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole) + ? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }] + : []), { href: '/settings/profile', text: t`Settings`, }, ]; - }, [currentTeam, organisations]); + }, [currentTeam, currentOrganisation, organisations, t]); return ( diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 179d3306a6..4fafb4b50e 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -11,6 +11,7 @@ import { import type { TTemplate } from '@documenso/lib/types/template'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, @@ -223,27 +224,10 @@ export const DirectTemplateSigningForm = ({ return undefined; } - const sortedRecipients = template.recipients.sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: template.recipients, + currentRecipientId: directRecipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); return ( @@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({ fields={localFields} fieldsValidated={fieldsValidated} recipient={directRecipient} - allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner} + allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)} defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} />
diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index f5508f727b..b8caa4a83f 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -102,8 +102,10 @@ export const DocumentSigningCompleteDialog = ({ const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner); + const form = useForm({ - resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, + resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, defaultValues: { name: defaultNextSigner?.name ?? '', email: defaultNextSigner?.email ?? '', @@ -324,7 +326,7 @@ export const DocumentSigningCompleteDialog = ({
- {allowDictateNextSigner && defaultNextSigner && ( + {canDictateNextSigner && (
Promise; isSubmitting: boolean; fieldsValidated: () => void; - nextRecipient?: RecipientWithFields; + /** + * The dictatable next recipient, decided server-side. Only their identity + * is needed — for the dictation flag and the prefilled inputs. + */ + nextRecipient?: Pick; }; export const DocumentSigningForm = ({ @@ -84,6 +88,10 @@ export const DocumentSigningForm = ({ return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); }, [fieldsRequiringValidation, recipient]); + const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner); + + const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined; + const localFieldsValidated = () => { setValidateUninsertedFields(true); fieldsValidated(); @@ -151,10 +159,8 @@ export const DocumentSigningForm = ({ completeDocument({ nextSigner, accessAuthOptions }) } recipient={recipient} - allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
@@ -223,8 +229,8 @@ export const DocumentSigningForm = ({ onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onConfirm={handleAssistantConfirmDialogSubmit} isSubmitting={isAssistantSubmitting} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
) : ( @@ -291,10 +297,8 @@ export const DocumentSigningForm = ({ }) } recipient={recipient} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 4fa8077a05..9ed38fbf1d 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -22,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Trans } from '@lingui/react/macro'; -import type { Field } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -60,6 +60,12 @@ export type DocumentSigningPageViewV1Props = { completedFields: CompletedField[]; isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; + /** + * The dictatable next recipient, computed server-side over the FULL + * recipient list — must not be re-derived from the role-scoped + * `allRecipients`. + */ + nextRecipient?: Pick; branding: DocumentSigningBranding; includeSenderDetails: boolean; }; @@ -71,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({ completedFields, isRecipientsTurn, allRecipients = [], + nextRecipient, includeSenderDetails, branding, }: DocumentSigningPageViewV1Props) => { @@ -133,34 +140,6 @@ export const DocumentSigningPageViewV1 = ({ const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; - const nextRecipient = useMemo(() => { - if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') { - return undefined; - } - - const sortedRecipients = [...allRecipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; - }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; - }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); const hasPendingFields = pendingFields.length > 0; diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index f0b00fec99..631123b6a5 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -6,6 +6,8 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; +import { isRecipientBefore } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; @@ -236,12 +238,16 @@ export const EnvelopeSigningProvider = ({ }, [envelopeData.recipient.fields]); /** - * Assistant recipients are those that have a signing order after the assistant. + * Assistant recipients are those positioned strictly after the assistant — + * never their own group peers. */ - const assistantRecipients = - recipient.role === RecipientRole.ASSISTANT - ? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0)) - : []; + const assistantRecipients = useMemo(() => { + if (recipient.role !== RecipientRole.ASSISTANT) { + return []; + } + + return envelope.recipients.filter((r) => isRecipientBefore(recipient, r)); + }, [envelope.recipients, recipient]); /** * Assistant fields are those fulfill all of the following: @@ -249,12 +255,11 @@ export const EnvelopeSigningProvider = ({ * - After the assistant signing order * - Are not signature fields */ - const assistantFields = - recipient.role === RecipientRole.ASSISTANT - ? assistantRecipients - .filter((r) => r.signingStatus !== SigningStatus.SIGNED) - .flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE)) - : []; + const assistantFields = useMemo(() => { + return assistantRecipients + .filter((r) => r.signingStatus !== SigningStatus.SIGNED) + .flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE)); + }, [assistantRecipients]); /** * The recipient that the assistant has currently selected to sign on behalf of. @@ -269,7 +274,7 @@ export const EnvelopeSigningProvider = ({ const selectedAssistantRecipientFields = useMemo(() => { return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id); - }, [recipientFields, selectedAssistantRecipient]); + }, [assistantFields, selectedAssistantRecipient]); /** * Fields that have been completed by other recipients. @@ -290,32 +295,14 @@ export const EnvelopeSigningProvider = ({ .filter((field) => field.inserted); const nextRecipient = useMemo(() => { - if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { + if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { return null; } - const sortedRecipients = [...envelope.recipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: envelope.recipients, + currentRecipientId: recipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); const signField = async ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index b3173b547b..fbe513120f 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -1,42 +1,30 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; -import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { + updateEditorSigners, + ZEditorRecipientsFormSchema, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; -import { - isAssistantLastSigner, - isCcRecipient, - normalizeRecipientSigningOrders, - canRecipientBeModified as utilCanRecipientBeModified, -} from '@documenso/lib/utils/recipients'; -import { trpc } from '@documenso/trpc/react'; -import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; -import { - RecipientAutoCompleteInput, - type RecipientAutoCompleteOption, -} from '@documenso/ui/components/recipient/recipient-autocomplete-input'; -import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; +import { normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients'; import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input } from '@documenso/ui/primitives/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd'; import { plural } from '@lingui/core/macro'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; -import { motion } from 'framer-motion'; -import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; +import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useWatch } from 'react-hook-form'; import { useRevalidator, useSearchParams } from 'react-router'; @@ -45,7 +33,8 @@ import { isDeepEqual } from 'remeda'; import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog'; import { useCurrentTeam } from '~/providers/team'; -import { useCspNonce } from '~/utils/nonce'; + +import { RecipientStepList } from './recipient-step-list'; export const EnvelopeEditorRecipientForm = () => { const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } = @@ -53,9 +42,7 @@ export const EnvelopeEditorRecipientForm = () => { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); - const cspNonce = useCspNonce(); - const { t } = useLingui(); const { toast } = useToast(); const { remaining } = useLimits(); const { sessionData } = useOptionalSession(); @@ -63,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => { const user = sessionData?.user; const [searchParams, setSearchParams] = useSearchParams(); - const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); // AI recipient detection dialog state @@ -109,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => { }); }; - const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); - - const $sensorApi = useRef(null); const isFirstRender = useRef(true); - const { recipients, fields } = envelope; - - const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( - { - query: debouncedRecipientSearchQuery, - }, - { - enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, - retry: false, - }, - ); - - const recipientSuggestions = recipientSuggestionsData?.results || []; + const { recipients } = envelope; const { form } = editorRecipients; @@ -163,15 +134,16 @@ export const EnvelopeEditorRecipientForm = () => { }, [watchedSigners]); const normalizeSigningOrders = (signers: typeof watchedSigners) => { - return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); + return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); }; - const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length; - - const { fields: signers, remove: removeSigner } = useFieldArray({ + // Keep a mounted field array for `signers` so react-hook-form reconciles + // whole-array `setValue` calls atomically. Without it, reordering the array + // leaves stale partial entries in watched values (missing email/name/role), + // which breaks validation and the autosave sync. + useFieldArray({ control, name: 'signers', - keyName: 'nativeId', }); const emptySignerIndex = watchedSigners.findIndex( @@ -185,39 +157,22 @@ export const EnvelopeEditorRecipientForm = () => { const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName); + // Note: Watched signer entries can be transiently partial while react-hook-form + // re-registers reordered array fields, so guard optional access here. const isUserAlreadyARecipient = watchedSigners.some( - (signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(), + (signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(), ); const hasDocumentBeenSent = recipients.some( (recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT, ); - const canRecipientBeModified = (recipientId?: number) => { - if (envelope.type === EnvelopeType.TEMPLATE) { - return true; - } - - if (recipientId === undefined) { - return true; - } - - const recipient = recipients.find((recipient) => recipient.id === recipientId); - - if (!recipient) { - return false; - } - - return utilCanRecipientBeModified(recipient, fields); - }; + const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId); const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); if (shouldFocus) { const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); @@ -235,20 +190,17 @@ export const EnvelopeEditorRecipientForm = () => { email: '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: activeRecipientCount + 1, + signingOrder: undefined, }); }; const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => { const currentSigners = form.getValues('signers'); - let nextSigningOrder = - currentSigners.length > 0 ? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1 : 1; - // If the only signer is the default empty signer lets just replace it with the detected recipients if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) { - form.setValue( - 'signers', + updateEditorSigners( + form, detectedRecipients.map((recipient, index) => ({ formId: nanoid(12), name: recipient.name, @@ -257,10 +209,6 @@ export const EnvelopeEditorRecipientForm = () => { actionAuth: [], signingOrder: index + 1, })), - { - shouldValidate: true, - shouldDirty: true, - }, ); return; @@ -281,16 +229,11 @@ export const EnvelopeEditorRecipientForm = () => { email: recipient.email, role: recipient.role, actionAuth: [], - signingOrder: nextSigningOrder, + signingOrder: undefined, }); - - nextSigningOrder += 1; } - form.setValue('signers', normalizeSigningOrders(currentSigners), { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, normalizeSigningOrders(currentSigners)); toast({ title: plural(detectedRecipients.length, { @@ -304,32 +247,6 @@ export const EnvelopeEditorRecipientForm = () => { }); }; - const onRemoveSigner = (index: number) => { - const signer = signers[index]; - - if (!canRecipientBeModified(signer.id)) { - toast({ - title: t`Cannot remove signer`, - description: t`This signer has already signed the document.`, - variant: 'destructive', - }); - - return; - } - - const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); - if (formStateIndex !== -1) { - removeSigner(formStateIndex); - - const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); - - form.setValue('signers', normalizeSigningOrders(updatedSigners), { - shouldValidate: true, - shouldDirty: true, - }); - } - }; - const onAddSelfSigner = () => { if (emptySignerIndex !== -1) { setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', { @@ -350,7 +267,7 @@ export const EnvelopeEditorRecipientForm = () => { email: currentEditorEmail ?? '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: activeRecipientCount + 1, + signingOrder: undefined, }, true, ); @@ -359,142 +276,6 @@ export const EnvelopeEditorRecipientForm = () => { } }; - const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => { - setValue(`signers.${index}.email`, suggestion.email, { - shouldValidate: true, - shouldDirty: true, - }); - setValue(`signers.${index}.name`, suggestion.name || '', { - shouldValidate: true, - shouldDirty: true, - }); - }; - - const onDragEnd = useCallback( - async (result: DropResult) => { - if (!result.destination) { - return; - } - - const items = Array.from(watchedSigners); - const [reorderedSigner] = items.splice(result.source.index, 1); - - // Find next valid position - let insertIndex = result.destination.index; - while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) { - insertIndex++; - } - - items.splice(insertIndex, 0, reorderedSigner); - - const updatedSigners = normalizeSigningOrders(items); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - - await form.trigger('signers'); - }, - [form, canRecipientBeModified, watchedSigners, toast], - ); - - const handleRoleChange = useCallback( - (index: number, role: RecipientRole) => { - const currentSigners = form.getValues('signers'); - const signingOrder = form.getValues('signingOrder'); - - // Handle parallel to sequential conversion for assistants - if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { - form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { - shouldValidate: true, - shouldDirty: true, - }); - toast({ - title: t`Signing order is enabled.`, - description: t`You cannot add assistants when signing order is disabled.`, - variant: 'destructive', - }); - return; - } - - const updatedSigners = normalizeSigningOrders( - currentSigners.map((signer, idx) => ({ - ...signer, - role: idx === index ? role : signer.role, - })), - ); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - }, - [form, toast, canRecipientBeModified], - ); - - const handleSigningOrderChange = useCallback( - (index: number, newOrderString: string) => { - const trimmedOrderString = newOrderString.trim(); - if (!trimmedOrderString) { - return; - } - - const newOrder = Number(trimmedOrderString); - if (!Number.isInteger(newOrder) || newOrder < 1) { - return; - } - - const currentSigners = form.getValues('signers'); - const signer = currentSigners[index]; - - if (isCcRecipient(signer)) { - return; - } - - const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s)); - const ccSigners = currentSigners.filter((s) => isCcRecipient(s)); - const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId); - - if (currentSigningOrderIndex === -1) { - return; - } - - const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1); - const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length); - nonCcSigners.splice(newPosition, 0, reorderedSigner); - - const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - }, - [form, canRecipientBeModified, toast], - ); - const handleSigningOrderDisable = useCallback(() => { setShowSigningOrderConfirmation(false); @@ -506,10 +287,8 @@ export const EnvelopeEditorRecipientForm = () => { })), ); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { shouldValidate: true, shouldDirty: true, @@ -537,14 +316,17 @@ export const EnvelopeEditorRecipientForm = () => { const { data } = validatedFormValues; - // Weird edge case where the whole envelope is created via API - // with no signing order. If they come to this page it will show an error - // since they aren't equal and the recipient is no longer editable. + // Locked recipients hold persisted values the server refuses to rewrite, + // e.g. an envelope created via API with no signing order where a recipient + // has already signed. Restore their PERSISTED order so form normalization + // drift never submits a "changed" locked recipient the server rejects. const envelopeRecipients = data.signers.map((recipient) => { if (!canRecipientBeModified(recipient.id)) { + const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id); + return { ...recipient, - signingOrder: recipient.signingOrder, + signingOrder: persistedRecipient?.signingOrder ?? undefined, }; } return recipient; @@ -570,7 +352,7 @@ export const EnvelopeEditorRecipientForm = () => { signer.email !== recipient.email || signer.name !== recipient.name || signer.role !== recipient.role || - signer.signingOrder !== recipient.signingOrder || + (signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) || !isDeepEqual(signerActionAuth, recipientActionAuth) ); }); @@ -590,7 +372,7 @@ export const EnvelopeEditorRecipientForm = () => { }, [formValues]); const recipientCountLimit = organisation.organisationClaim.recipientCount; - const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; + const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit; return ( @@ -646,7 +428,7 @@ export const EnvelopeEditorRecipientForm = () => { type="button" className="flex-1" size="sm" - disabled={isSubmitting || signers.length >= remaining.recipients} + disabled={isSubmitting || watchedSigners.length >= remaining.recipients} onClick={() => onAddSigner()} > @@ -796,288 +578,7 @@ export const EnvelopeEditorRecipientForm = () => { )} - { - $sensorApi.current = api; - }, - ]} - > - - {(provided) => ( -
- {signers.map((signer, index) => { - const isDirectRecipient = - envelope.type === EnvelopeType.TEMPLATE && - envelope.directLink !== null && - signer.id === envelope.directLink.directTemplateRecipientId; - - return ( - - {(provided, snapshot) => ( -
- -
- {isSigningOrderSequential && isCcRecipient(signer) && ( -
- )} - - {isSigningOrderSequential && !isCcRecipient(signer) && ( - ( - - - - { - field.onChange(e); - handleSigningOrderChange(index, e.target.value); - }} - onBlur={(e) => { - field.onBlur(); - handleSigningOrderChange(index, e.target.value); - }} - disabled={ - snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) - } - /> - - - - )} - /> - )} - - ( - - {!showAdvancedSettings && index === 0 && ( - - Email - - )} - - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - field.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - data-testid="signer-email-input" - maxLength={254} - /> - - - - - )} - /> - - ( - - {!showAdvancedSettings && index === 0 && ( - - Name - - )} - - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - field.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - maxLength={255} - /> - - - - - )} - /> - - ( - - - { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole); - }} - disabled={ - snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) - } - /> - - - - - )} - /> - - -
- - {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( - ( - - - - - - - - )} - /> - )} - -
- )} - - ); - })} - - {provided.placeholder} -
- )} - - + void; + onRemove: (signerIndex: number) => void; + onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void; + onSearchQueryChange: (query: string) => void; +}; + +const RecipientRowInner = ({ + signerIndex, + signer, + isSequential, + isInputDisabled, + canBeModified, + isRemoveDisabled, + showAdvancedSettings, + dragHandleProps, + recipientSuggestions, + isLoadingSuggestions, + onRoleChange, + onRemove, + onAutoCompleteSelect, + onSearchQueryChange, +}: RecipientRowProps) => { + const { t } = useLingui(); + + const { envelope, editorConfig } = useCurrentEnvelopeEditor(); + const organisation = useCurrentOrganisation(); + + const form = useFormContext(); + + const { isSubmitting } = form.formState; + + const isDirectRecipient = + envelope.type === EnvelopeType.TEMPLATE && + envelope.directLink !== null && + signer.id === envelope.directLink.directTemplateRecipientId; + + const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified; + + const rowErrors = form.formState.errors.signers?.[signerIndex]; + + return ( +
+
+ {isSequential && !isCcRecipient(signer) && ( + + + + )} + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + data-testid="signer-email-input" + maxLength={254} + /> + + + + + )} + /> + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + maxLength={255} + /> + + + + + )} + /> + + ( + + + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + onRoleChange(signerIndex, value as RecipientRole); + }} + disabled={isFieldDisabled} + /> + + + + + )} + /> + + +
+ + {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( + ( + + + + + + + + )} + /> + )} +
+ ); +}; + +/** + * Memoized: rows contain heavy inputs (autocomplete, role select) and would + * otherwise re-render on every drag state change, making drags feel sluggish. + * All callback props are stable (useCallback in the list) and `signer` object + * identities only change when form values actually change. + */ +export const RecipientRow = memo(RecipientRowInner); diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx new file mode 100644 index 0000000000..579684deaf --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx @@ -0,0 +1,261 @@ +import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import type { RecipientStep } from '@documenso/lib/utils/recipient-groups'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd'; +import { Draggable, Droppable } from '@hello-pangea/dnd'; +import { Plural, Trans } from '@lingui/react/macro'; +import { GripVerticalIcon, Users2Icon } from 'lucide-react'; + +import { RecipientRow, type RecipientRowProps } from './recipient-row'; + +type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; + +export type DraggingType = 'STEP' | 'RECIPIENT' | null; + +/** + * Skips the drop animation. The post-drop state update re-sorts and renumbers + * the groups anyway, so gliding to the predicted slot first makes every drop + * feel like it settles twice — snapping hands control to the real re-render + * immediately instead. + */ +const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + if (!snapshot.isDropAnimating) { + return provided.draggableProps.style; + } + + return { + ...provided.draggableProps.style, + transitionDuration: '0.001s', + }; +}; + +export type RecipientStepCardSharedRowProps = Pick< + RecipientRowProps, + | 'showAdvancedSettings' + | 'recipientSuggestions' + | 'isLoadingSuggestions' + | 'onRoleChange' + | 'onRemove' + | 'onAutoCompleteSelect' + | 'onSearchQueryChange' +>; + +export type RecipientStepCardProps = { + stepIndex: number; + step: RecipientStep; + isLastStep: boolean; + draggableProvided: DraggableProvided; + draggableSnapshot: DraggableStateSnapshot; + draggingType: DraggingType; + /** + * Whether recipients may be combined into signing groups. False on CSC + * (AES/QES) instances, where every signing recipient must hold a distinct + * step. Constant for the session, so disabling the drop-zone with it does + * not violate the "never toggle `isDropDisabled` mid-drag" constraint. + */ + isGroupingEnabled: boolean; + isStepLocked: boolean; + isRemoveDisabled: boolean; + flatIndexByFormId: Map; + canSignerBeModified: (signer: TEditorSigner) => boolean; + isSubmitting: boolean; + onUngroup: (stepIndex: number) => void; + rowProps: RecipientStepCardSharedRowProps; +}; + +/** + * The drop-zone strip rendered above each group card (and below the last one) + * that receives recipient-row drops. Invisible until a dragged row hovers it, + * then it shows a full-width green line marking the insertion point. + * + * Notes: + * - It lives INSIDE the step's Draggable so it shifts together with the card + * while groups are being reordered — a static strip between draggables + * would stay behind while the cards around it are displaced, making group + * drags look broken. + * - Its `droppableId` must stay STABLE while mounted (anchored to a formId, + * never a positional index): @hello-pangea/dnd does not support changing + * ids on mounted droppables/draggables, which silently breaks them. + * - `type="RECIPIENT"` already scopes it to recipient-row drags, and + * `isDropDisabled` must not be toggled based on the active drag, as + * @hello-pangea/dnd snapshots it at drag start (before state updates land). + * - It must keep a CONSTANT size: droppable geometry is captured when a drag + * starts, so resizing during the drag would leave the visible strip and the + * actual hit area in different places. Only colors may change mid-drag. + */ +const RecipientStepGap = ({ droppableId }: { droppableId: string }) => ( + + {(provided, snapshot) => ( +
+
+ {provided.placeholder} +
+ )} + +); + +export const RecipientStepCard = ({ + stepIndex, + step, + isLastStep, + draggableProvided, + draggableSnapshot, + draggingType, + isGroupingEnabled, + isStepLocked, + isRemoveDisabled, + flatIndexByFormId, + canSignerBeModified, + isSubmitting, + onUngroup, + rowProps, +}: RecipientStepCardProps) => { + const isGroup = step.members.length > 1; + const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor); + + const stepLabel = step.order ?? stepIndex + 1; + + // All droppable ids are anchored to the first member's formId (never a + // positional index) so they stay stable while cards are reordered — + // @hello-pangea/dnd does not support changing ids on mounted elements. + const stepAnchor = step.members[0].formId; + + return ( +
+ + + + {(droppableProvided, droppableSnapshot) => { + const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver; + const isHighlighted = isCombineTarget || isJoinTarget; + + return ( +
+ {isHighlighted && ( + + + Release to group + + )} + +
+ + + + + + Group {stepLabel} + + + {isGroup && ( + <> + + + + + + + + )} +
+ + {step.members.map((member, memberIndex) => { + const signerIndex = flatIndexByFormId.get(member.formId) ?? -1; + const canBeModified = canSignerBeModified(member); + + return ( + + {(memberProvided, memberSnapshot) => ( +
+ +
+ )} +
+ ); + })} + + {droppableProvided.placeholder} +
+ ); + }} +
+ + {isLastStep && } +
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx new file mode 100644 index 0000000000..9c4f69ff3f --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx @@ -0,0 +1,398 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { + type TEditorRecipientsFormSchema, + updateEditorSigners, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { + extractRecipientToNewStep, + getLastLockedStepIndex, + groupRecipientsBySigningOrder, + isSigningOrderFrozen, + mergeSteps, + moveRecipientToStep, + normalizeGroupedSigningOrders, + reorderStep, + ungroupStep, +} from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients'; +import { trpc } from '@documenso/trpc/react'; +import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import type { BeforeCapture, DropResult } from '@hello-pangea/dnd'; +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; +import { useCallback, useMemo, useState } from 'react'; + +import { useCspNonce } from '~/utils/nonce'; + +import { RecipientRow } from './recipient-row'; +import { type DraggingType, RecipientStepCard } from './recipient-step-card'; + +type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; + +export type RecipientStepListProps = { + showAdvancedSettings: boolean; +}; + +export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const cspNonce = useCspNonce(); + + const { envelope, editorRecipients, isEmbedded, isCscMode } = useCurrentEnvelopeEditor(); + const { form } = editorRecipients; + + // Signing groups are an SES feature: TSP (AES/QES) signatures must be + // strictly sequential, so on CSC instances the group affordances (card + // combine, row-to-card join) are disabled while step reordering and + // ungrouping of invalid API-created state stay available. + const isGroupingEnabled = !isCscMode; + + const [draggingType, setDraggingType] = useState(null); + const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); + + const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); + + const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( + { + query: debouncedRecipientSearchQuery, + }, + { + enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, + retry: false, + }, + ); + + const recipientSuggestions = recipientSuggestionsData?.results || []; + + const watchedSigners = form.watch('signers'); + const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; + const { isSubmitting } = form.formState; + + const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]); + + // Signing is sequential, so anyone who has already acted is at or before the + // current step. Those steps hold persisted orders that cannot be rewritten, + // so ordering is locked up to and including the last of them; everything + // after can still be rearranged freely. + const lastLockedStepIndex = useMemo( + () => getLastLockedStepIndex(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)), + [steps, envelope], + ); + + const isOrderingFrozen = useMemo( + () => isSigningOrderFrozen(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)), + [steps, envelope], + ); + + const isRemoveDisabled = watchedSigners.length === 1; + + const flatIndexByFormId = useMemo( + () => new Map(watchedSigners.map((signer, index) => [signer.formId, index])), + [watchedSigners], + ); + + const canSignerBeModified = useCallback( + (signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id), + [envelope], + ); + + const applySigners = useCallback( + (updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => { + const { warnWhenAssistantLast = true } = options; + + updateEditorSigners(form, updatedSigners); + + if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) { + toast({ + title: t`Warning: Assistant as last signer`, + description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + }); + } + + void form.trigger('signers'); + }, + [form, t, toast], + ); + + const handleRoleChange = useCallback( + (signerIndex: number, role: RecipientRole) => { + const currentSigners = form.getValues('signers'); + const signingOrder = form.getValues('signingOrder'); + + if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { + shouldValidate: true, + shouldDirty: true, + }); + + toast({ + title: t`Signing order is enabled.`, + description: t`You cannot add assistants when signing order is disabled.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + currentSigners.map((signer, index) => ({ + ...signer, + role: index === signerIndex ? role : signer.role, + })), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleRemove = useCallback( + (signerIndex: number) => { + const signer = form.getValues('signers')[signerIndex]; + + if (!signer) { + return; + } + + if (!canSignerBeModified(signer)) { + toast({ + title: t`Cannot remove signer`, + description: t`This signer has already signed the document.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + form.getValues('signers').filter((s) => s.formId !== signer.formId), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: false }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleUngroup = useCallback( + (stepIndex: number) => { + applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified)); + }, + [form, canSignerBeModified, applySigners], + ); + + const handleAutoCompleteSelect = useCallback( + (signerIndex: number, suggestion: RecipientAutoCompleteOption) => { + form.setValue(`signers.${signerIndex}.email`, suggestion.email, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', { + shouldValidate: true, + shouldDirty: true, + }); + }, + [form], + ); + + const onBeforeCapture = useCallback((before: BeforeCapture) => { + setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT'); + }, []); + + const onDragEnd = useCallback( + (result: DropResult) => { + setDraggingType(null); + + const currentSigners = form.getValues('signers'); + + // Drag-and-drop ids are anchored to the first member's formId so they + // stay stable across reorders; resolve them back to step indexes here. + const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners); + + const findStepIndexByAnchor = (anchorFormId: string) => + currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId); + + if (result.type === 'STEP') { + if (result.combine) { + // Unreachable while combining is disabled, but kept as a guard so a + // stray combine result can never form a group on a CSC envelope. + if (!isGroupingEnabled) { + return; + } + + const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length)); + + if (targetStepIndex === -1) { + return; + } + + applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified)); + + return; + } + + if (result.destination) { + applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified)); + } + + return; + } + + if (result.type === 'RECIPIENT' && result.destination) { + const formId = result.draggableId.slice('recipient-'.length); + const { droppableId } = result.destination; + + if (droppableId === 'gap-end') { + applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('gap-')) { + const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length)); + + if (insertStepIndex === -1) { + return; + } + + applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('step-members-')) { + // Unreachable while the card drop-zones are disabled, but kept as a + // guard so a stray drop can never form a group on a CSC envelope. + if (!isGroupingEnabled) { + return; + } + + const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length)); + + if (targetStepIndex === -1) { + return; + } + + applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified)); + } + } + }, + [form, canSignerBeModified, applySigners, isGroupingEnabled], + ); + + const sharedRowProps = { + showAdvancedSettings, + recipientSuggestions, + isLoadingSuggestions: isLoading, + onRoleChange: handleRoleChange, + onRemove: handleRemove, + onAutoCompleteSelect: handleAutoCompleteSelect, + onSearchQueryChange: setRecipientSearchQuery, + }; + + return ( +
+ {!showAdvancedSettings && !isSequential && ( +
+ + Email + + + Name + + +
+ )} + + {!isSequential ? ( +
+ {watchedSigners.map((signer, index) => ( + + ))} +
+ ) : ( + <> + + + {(provided) => ( +
+ {steps.map((step, stepIndex) => { + const isStepLocked = isOrderingFrozen || stepIndex <= lastLockedStepIndex; + + return ( + + {(draggableProvided, draggableSnapshot) => ( + + )} + + ); + })} + + {provided.placeholder} +
+ )} +
+
+ + {ccRecipients.length > 0 && ( +
+ + Receives Copy + + + {ccRecipients.map((signer) => ( +
+ +
+ ))} +
+ )} + + )} +
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx index 88ab7f8f4c..946eb6ae4c 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx @@ -17,6 +17,7 @@ import { match } from 'ts-pattern'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { BrandingLogo } from '~/components/general/branding-logo'; +import { PublicLegalLinks } from '~/components/general/public-legal-links'; import { BrandingLogoIcon } from '../branding-logo-icon'; import { DocumentSigningRejectDialog } from '../document-signing/document-signing-reject-dialog'; @@ -68,8 +69,10 @@ export const EnvelopeSignerHeader = () => {
{/* Right side - Desktop content */} -
-

+

+ + +

diff --git a/apps/remix/app/components/general/filter-pill.tsx b/apps/remix/app/components/general/filter-pill.tsx index ae1d69f753..a786bd8f10 100644 --- a/apps/remix/app/components/general/filter-pill.tsx +++ b/apps/remix/app/components/general/filter-pill.tsx @@ -31,6 +31,8 @@ type FilterPillCommonProps = { enableSearch?: boolean; searchPlaceholder?: string; loading?: boolean; + /** Whether the selection can be removed. Defaults to true. */ + clearable?: boolean; testId?: string; }; @@ -61,7 +63,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps; * selections followed by a "+N more" chip. */ export const FilterPill = (props: FilterPillProps) => { - const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props; + const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props; const [open, setOpen] = useState(false); @@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => { return; } - props.onChange(nextValue === props.value ? null : nextValue); + props.onChange(nextValue === props.value && clearable ? null : nextValue); setOpen(false); }; @@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => { ))} - {hasSelection && ( + {hasSelection && clearable && ( <> diff --git a/apps/remix/app/components/general/metric-card.tsx b/apps/remix/app/components/general/metric-card.tsx index 14ae660353..3659e62aa0 100644 --- a/apps/remix/app/components/general/metric-card.tsx +++ b/apps/remix/app/components/general/metric-card.tsx @@ -7,9 +7,10 @@ export type CardMetricProps = { value?: string | number; className?: string; children?: React.ReactNode; + testId?: string; }; -export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => { +export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => { return (
{children || ( -

+

{typeof value === 'number' ? value.toLocaleString('en-US') : value}

)} diff --git a/apps/remix/app/components/general/org-menu-switcher.tsx b/apps/remix/app/components/general/org-menu-switcher.tsx index 92064f723d..b031fa0cc1 100644 --- a/apps/remix/app/components/general/org-menu-switcher.tsx +++ b/apps/remix/app/components/general/org-menu-switcher.tsx @@ -6,9 +6,13 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/ import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { isAdmin } from '@documenso/lib/utils/is-admin'; -import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { + canAccessOrganisationAnalytics, + canExecuteOrganisationAction, + formatOrganisationAnalyticsPath, +} from '@documenso/lib/utils/organisations'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog'; import { cn } from '@documenso/ui/lib/utils'; @@ -62,6 +66,13 @@ export const OrgMenuSwitcher = () => { const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole); + // Team analytics take precedence when in a team context, the team page links to organisation analytics. + const analyticsPath = canAccessTeamSettings + ? formatAnalyticsPath(currentTeam.url) + : currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole) + ? formatOrganisationAnalyticsPath(currentOrganisation.url) + : null; + // Use hovered org for teams display if available, // otherwise use current team's org if in a team, // finally fallback to selected org @@ -271,6 +282,14 @@ export const OrgMenuSwitcher = () => { + {analyticsPath && ( + + + Analytics + + + )} + { + const termsUrl = NEXT_PUBLIC_TERMS_OF_SERVICE_URL(); + const privacyUrl = NEXT_PUBLIC_PRIVACY_POLICY_URL(); + const imprintUrl = NEXT_PUBLIC_IMPRINT_URL(); + + return ( +
+ {termsUrl && ( + + Terms of Service + + )} + + {privacyUrl && ( + + Privacy Policy + + )} + + {imprintUrl && ( + + Imprint + + )} +
+ ); +}; diff --git a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx index d3233bacf8..17d46ec433 100644 --- a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx +++ b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx @@ -12,7 +12,7 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react import { useMemo } from 'react'; import { Link, useSearchParams } from 'react-router'; -type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount'; +type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'teamCount' | 'totalCount'; type OrderByDirection = 'asc' | 'desc'; const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => { @@ -21,6 +21,7 @@ const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefine value === 'emailCount' || value === 'apiCount' || value === 'emailReports' || + value === 'teamCount' || value === 'totalCount' ) { return value; @@ -178,6 +179,11 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga accessorKey: 'originalClaimId', cell: ({ row }) => {row.original.originalClaimId ?? '—'}, }, + { + header: () => sortableHeader(t`Teams`, 'teamCount'), + accessorKey: 'teamCount', + cell: ({ row }) => row.original.teamCount, + }, { header: t`Period`, accessorKey: 'period', @@ -240,6 +246,9 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga + + + diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index f56ba5b294..c603947393 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -150,7 +150,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {