mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
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:
@@ -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>;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user