Merge remote-tracking branch 'origin/main' into pr-2711

# Conflicts:
#	apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx
#	apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx
#	packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
This commit is contained in:
ephraimduncan
2026-07-02 06:48:23 +00:00
585 changed files with 38080 additions and 6339 deletions
@@ -0,0 +1,179 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Trans, useLingui } from '@lingui/react/macro';
import { EditIcon, MoreHorizontalIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { EmailTransportDeleteDialog } from '../dialogs/email-transport-delete-dialog';
import { EmailTransportSendTestDialog } from '../dialogs/email-transport-send-test-dialog';
import { EmailTransportUpdateDialog } from '../dialogs/email-transport-update-dialog';
export const AdminEmailTransportsTable = () => {
const { t, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Name`,
accessorKey: 'name',
},
{
header: t`Type`,
accessorKey: 'type',
},
{
header: t`From`,
cell: ({ row }) => `${row.original.fromName} <${row.original.fromAddress}>`,
},
{
header: t`Used by claims`,
cell: ({ row }) => row.original._count.subscriptionClaims + row.original._count.organisationClaims,
},
{
header: t`Created`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<EmailTransportUpdateDialog
transport={row.original}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</div>
</DropdownMenuItem>
}
/>
<EmailTransportSendTestDialog
transportId={row.original.id}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send test</Trans>
</div>
</DropdownMenuItem>
}
/>
<EmailTransportDeleteDialog
transportId={row.original.id}
transportName={row.original.name}
subscriptionClaimCount={row.original._count.subscriptionClaims}
organisationClaimCount={row.original._count.organisationClaims}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-2 w-6 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -0,0 +1,269 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useLingui } from '@lingui/react/macro';
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 OrderByDirection = 'asc' | 'desc';
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
if (
value === 'documentCount' ||
value === 'emailCount' ||
value === 'apiCount' ||
value === 'emailReports' ||
value === 'totalCount'
) {
return value;
}
return undefined;
};
const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
return value === 'asc' ? 'asc' : 'desc';
};
/**
* Number of days to divide the period's usage by to get a per-day average.
*
* For the in-progress (current) month we divide by today's UTC day-of-month so the
* average reflects elapsed days only. For a fully-elapsed past month we divide by the
* total number of days in that month.
*/
const getPeriodDivisor = (period: string): number => {
if (period === currentMonthlyPeriod()) {
return new Date().getUTCDate();
}
const [yearStr, monthStr] = period.split('-');
const year = Number(yearStr);
const month = Number(monthStr);
if (Number.isNaN(year) || Number.isNaN(month)) {
return new Date().getUTCDate();
}
// Day 0 of the following month resolves to the last day of `month`.
return new Date(Date.UTC(year, month, 0)).getUTCDate();
};
export type OrganisationStatsDisplayMode = 'usage' | 'quotas' | 'averages';
type AdminOrganisationStatsTableProps = {
displayMode?: OrganisationStatsDisplayMode;
};
export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrganisationStatsTableProps) => {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
// Default to the current month.
const period = searchParams?.get('period') ?? currentMonthlyPeriod();
const claimId = searchParams?.get('claimId') || undefined;
const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined);
const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined);
const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
period,
claimId,
orderByColumn,
orderByDirection,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const handleColumnSort = (column: OrderByColumn) => {
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
// Use the functional updater so we merge onto the latest params. Reading the
// captured `searchParams` here would drop filters (e.g. claimId) that changed
// after this handler was memoised into the column definitions.
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
next.set('orderByColumn', column);
next.set('orderByDirection', nextDirection);
next.set('page', '1');
return next;
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
const divisor = getPeriodDivisor(period);
const formatPerDay = (used: number) => {
const perDay = divisor > 0 ? used / divisor : 0;
const rounded = Math.round(perDay * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
};
const renderUsageCell = (used: number, quota: number | null) => {
if (displayMode === 'averages') {
return formatPerDay(used);
}
if (displayMode === 'quotas') {
return (
<span>
{used}/{quota === null ? '∞' : quota}
</span>
);
}
return <span>{used}</span>;
};
const sortableHeader = (label: string, column: OrderByColumn) => (
<button
type="button"
className="flex cursor-pointer items-center whitespace-nowrap"
onClick={() => handleColumnSort(column)}
>
{label}
{orderByColumn === column ? (
orderByDirection === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDownIcon className="ml-2 h-4 w-4" />
)}
</button>
);
return [
{
header: t`Organisation`,
accessorKey: 'organisationName',
cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.organisationId}`} className="text-sm hover:underline">
{row.original.organisationName}
</Link>
),
},
{
header: t`Claim`,
accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground text-sm">{row.original.originalClaimId ?? '—'}</span>,
},
{
header: t`Period`,
accessorKey: 'period',
cell: ({ row }) => <span className="text-sm">{row.original.period}</span>,
},
{
header: () => sortableHeader(t`Documents`, 'documentCount'),
accessorKey: 'documentCount',
cell: ({ row }) => renderUsageCell(row.original.documentCount, row.original.documentQuota),
},
{
header: () => sortableHeader(t`Emails`, 'emailCount'),
accessorKey: 'emailCount',
cell: ({ row }) => renderUsageCell(row.original.emailCount, row.original.emailQuota),
},
{
header: () => sortableHeader(t`API`, 'apiCount'),
accessorKey: 'apiCount',
cell: ({ row }) => renderUsageCell(row.original.apiCount, row.original.apiQuota),
},
{
header: () => sortableHeader(t`Reports`, 'emailReports'),
accessorKey: 'emailReports',
cell: ({ row }) => row.original.emailReports,
},
{
header: () => sortableHeader(t`Total`, 'totalCount'),
accessorKey: 'totalCount',
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
// `searchParams` must be a dependency: `handleColumnSort` closes over `setSearchParams`,
// whose functional updater is bound to the `searchParams` captured at creation time.
// Without this, changing a filter (e.g. claimId) wouldn't refresh the memoised handler,
// and sorting would merge onto stale params and drop the active filter.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, orderByColumn, orderByDirection, period, displayMode, searchParams]);
return (
<div>
<DataTable
columns={columns}
data={results.data}
rowClassName="text-sm"
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 5,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-32 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -86,7 +86,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
@@ -3,7 +3,7 @@ import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/documen
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { formatDocumentsPath, isMemberManagerOrAbove } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@@ -25,18 +25,21 @@ import {
EyeIcon,
FileOutputIcon,
FolderInput,
History,
Loader,
MoreHorizontal,
Pencil,
Share,
Trash2,
XCircle,
} from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -74,6 +77,12 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
// Cancelling a document is restricted server-side to the document owner or a
// privileged team member (ADMIN/MANAGER). Mirror that here so plain MEMBERs
// don't see a Cancel action that would fail on the server.
const isPrivilegedTeamMember = isMemberManagerOrAbove(team.currentTeamRole);
const canCancelDocument = isOwner || isPrivilegedTeamMember;
const { canTitleBeChanged } = getEnvelopeItemPermissions(
{
completedAt: row.completedAt,
@@ -87,8 +96,6 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn">
@@ -105,7 +112,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
recipient?.role !== RecipientRole.CC &&
recipient?.role !== RecipientRole.ASSISTANT && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
@@ -126,7 +133,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
<Trans>Approve</Trans>
</>
)}
</Link>
</a>
</DropdownMenuItem>
)}
@@ -184,11 +191,23 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
</DropdownMenuItem>
)}
{/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem> */}
{canCancelDocument && isPending && (
<EnvelopeCancelDialog
id={row.envelopeId}
title={row.title}
onCancel={async () => {
await trpcUtils.document.findDocumentsInternal.invalidate();
}}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<XCircle className="mr-2 h-4 w-4" />
<Trans>Cancel</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<EnvelopeDeleteDialog
id={row.envelopeId}
@@ -224,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
/>
)}
<DocumentResendDialog document={row} recipients={nonSignedRecipients} />
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={{
id: row.envelopeId,
status: row.status,
type: EnvelopeType.DOCUMENT,
recipients: row.recipients,
}}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={row.id}
@@ -1,7 +1,7 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
@@ -24,6 +24,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.CANCELLED, () => ({
title: msg`Nothing cancelled`,
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@@ -29,7 +29,7 @@ export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
onMoveDocument?: (envelopeId: string) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
@@ -117,7 +117,7 @@ export const DocumentsTable = ({
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown
row={row.original}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined}
/>
</div>
),
@@ -1,6 +1,6 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
export type EnvelopesTableBulkActionBarProps = {
@@ -8,6 +8,7 @@ export type EnvelopesTableBulkActionBarProps = {
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
onClearSelection: () => void;
};
@@ -16,6 +17,7 @@ export const EnvelopesTableBulkActionBar = ({
onDownloadClick,
onMoveClick,
onDeleteClick,
onCancelClick,
onClearSelection,
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
@@ -76,6 +78,19 @@ export const EnvelopesTableBulkActionBar = ({
</Button>
)}
{onCancelClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Trans>Cancel</Trans>
</Button>
)}
<Button
type="button"
variant="ghost"
@@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useMemo, useTransition } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -200,7 +200,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -220,7 +220,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
@@ -207,7 +207,7 @@ export const OrganisationInsightsTable = ({
<SummaryCard
icon={TrendingUp}
title={_(msg`Documents Completed`)}
value={insights.summary.volumeThisPeriod}
value={`${insights.summary.volumeThisPeriod}/${insights.summary.documentsThisPeriod}`}
/>
</div>
)}
@@ -269,7 +269,7 @@ const SummaryCard = ({
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
value: number | string;
subtitle?: string;
}) => (
<div className="flex items-start gap-x-2 rounded-lg border bg-card px-4 py-3">
@@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
@@ -86,6 +86,39 @@ export const TeamMembersTable = () => {
);
const columns = useMemo(() => {
// A member is a direct team member when they belong to one of the team's
// INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or
// custom group and cannot be managed directly from this team.
const isMemberPartOfInternalTeamGroup = (memberId: string) =>
groups.some(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === memberId),
);
// Determine why a member can't be removed from the team (if at all). The delete
// dialog uses this to explain the reason instead of attempting a removal that
// would fail.
const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => {
if (organisation.ownerUserId === member.userId) {
return 'TEAM_OWNER';
}
if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) {
return 'HIGHER_ROLE';
}
if (memberAccessTeamGroup !== undefined) {
return 'INHERIT_MEMBER_ENABLED';
}
if (!isMemberPartOfInternalTeamGroup(member.id)) {
return 'INHERITED_MEMBER';
}
return null;
};
return [
{
header: _(msg`Team Member`),
@@ -111,15 +144,7 @@ export const TeamMembersTable = () => {
},
{
header: _(msg`Source`),
cell: ({ row }) => {
const internalTeamGroupFound = groups.find(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === row.original.id),
);
return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`);
},
cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)),
},
{
header: _(msg`Actions`),
@@ -161,16 +186,9 @@ export const TeamMembersTable = () => {
memberId={row.original.id}
memberName={row.original.name ?? ''}
memberEmail={row.original.email}
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
disableReason={getDeleteDisableReason(row.original)}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
organisation.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
}
title={_(msg`Remove team member`)}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} title={_(msg`Remove team member`)}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
@@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
export type TemplatesTableActionDropdownProps = {
row: {
@@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({
onDelete,
}: TemplatesTableActionDropdownProps) => {
const trpcUtils = trpcReact.useUtils();
const navigate = useNavigate();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
@@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({
)}
</DropdownMenuContent>
<TemplateMoveToFolderDialog
templateId={row.id}
templateTitle={row.title}
isOpen={isMoveToFolderDialogOpen}
<EnvelopesBulkMoveDialog
envelopeIds={[row.envelopeId]}
envelopeType={EnvelopeType.TEMPLATE}
open={isMoveToFolderDialogOpen}
onOpenChange={setMoveToFolderDialogOpen}
currentFolderId={row.folderId}
currentFolderId={row.folderId ?? undefined}
onSuccess={(folderId) => navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)}
/>
<EnvelopeRenameDialog
@@ -1,6 +1,7 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -21,8 +22,8 @@ export const UserBillingOrganisationsTable = () => {
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
}, [organisations]);
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
return match(status)
const getSubscriptionStatusDisplay = (organisation: (typeof billingOrganisations)[number]) => {
return match(organisation.subscription?.status)
.with(SubscriptionStatus.ACTIVE, () => ({
label: t({ message: `Active`, context: `Subscription status` }),
variant: 'default' as const,
@@ -35,10 +36,19 @@ export const UserBillingOrganisationsTable = () => {
label: t({ message: `Inactive`, context: `Subscription status` }),
variant: 'neutral' as const,
}))
.otherwise(() => ({
label: t({ message: `Free`, context: `Subscription status` }),
variant: 'neutral' as const,
}));
.otherwise(() => {
if (isOrganisationPendingPayment(organisation)) {
return {
label: t({ message: `Free (Pending)`, context: `Subscription status` }),
variant: 'warning' as const,
};
}
return {
label: t({ message: `Free`, context: `Subscription status` }),
variant: 'neutral' as const,
};
});
};
const columns = useMemo(() => {
@@ -62,9 +72,7 @@ export const UserBillingOrganisationsTable = () => {
header: t`Subscription Status`,
accessorKey: 'subscription',
cell: ({ row }) => {
const subscription = row.original.subscription;
const status = subscription?.status;
const { label, variant } = getSubscriptionStatusDisplay(status);
const { label, variant } = getSubscriptionStatusDisplay(row.original);
return <Badge variant={variant}>{label}</Badge>;
},