feat: add organisations (#1820)

This commit is contained in:
David Nguyen
2025-06-10 11:49:52 +10:00
committed by GitHub
parent 0b37f19641
commit e6dc237ad2
631 changed files with 37616 additions and 25695 deletions

View File

@ -0,0 +1,199 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
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 { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
export const AdminClaimsTable = () => {
const { t } = useLingui();
const { toast } = useToast();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.claims.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: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`ID`,
accessorKey: 'id',
maxSize: 50,
cell: ({ row }) => (
<CopyTextButton
value={row.original.id}
onCopySuccess={() => toast({ title: t`ID copied to clipboard` })}
/>
),
},
{
header: t`Name`,
accessorKey: 'name',
cell: ({ row }) => (
<Link to={`/admin/organisations?query=claim:${row.original.id}`}>
{row.original.name}
</Link>
),
},
{
header: t`Allowed teams`,
accessorKey: 'teamCount',
cell: ({ row }) => {
if (row.original.teamCount === 0) {
return <p className="text-muted-foreground">{t`Unlimited`}</p>;
}
return <p className="text-muted-foreground">{row.original.teamCount}</p>;
},
},
{
header: t`Feature Flags`,
cell: ({ row }) => {
const flags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter(
({ key }) => row.original.flags[key],
);
if (flags.length === 0) {
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
}
return (
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
{flags.map(({ key, label }) => (
<li key={key}>{label}</li>
))}
</ul>
);
},
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<ClaimUpdateDialog
claim={row.original}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Update</Trans>
</div>
</DropdownMenuItem>
}
/>
<ClaimDeleteDialog
claimId={row.original.id}
claimName={row.original.name}
claimLocked={row.original.locked}
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-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 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>
);
};

View File

@ -35,7 +35,6 @@ type AdminDashboardUsersTableProps = {
totalPages: number;
perPage: number;
page: number;
individualPriceIds: string[];
};
export const AdminDashboardUsersTable = ({
@ -43,7 +42,6 @@ export const AdminDashboardUsersTable = ({
totalPages,
perPage,
page,
individualPriceIds,
}: AdminDashboardUsersTableProps) => {
const { _ } = useLingui();
@ -74,17 +72,6 @@ export const AdminDashboardUsersTable = ({
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: _(msg`Subscription`),
accessorKey: 'subscription',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.subscriptions ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: _(msg`Documents`),
accessorKey: 'documents',
@ -107,7 +94,7 @@ export const AdminDashboardUsersTable = ({
},
},
] satisfies DataTableColumnDef<(typeof users)[number]>[];
}, [individualPriceIds]);
}, []);
useEffect(() => {
startTransition(() => {

View File

@ -14,13 +14,9 @@ import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
name: string;
email: string;
signingVolume: number;
createdAt: Date;
planId: string;
userId?: number | null;
teamId?: number | null;
isTeam: boolean;
};
type LeaderboardTableProps = {

View File

@ -0,0 +1,210 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
CreditCardIcon,
ExternalLinkIcon,
MoreHorizontalIcon,
SettingsIcon,
UserIcon,
} from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
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';
type AdminOrganisationsTableOptions = {
ownerUserId?: number;
memberUserId?: number;
showOwnerColumn?: boolean;
hidePaginationUntilOverflow?: boolean;
};
export const AdminOrganisationsTable = ({
ownerUserId,
memberUserId,
showOwnerColumn = true,
hidePaginationUntilOverflow,
}: AdminOrganisationsTableOptions) => {
const { t, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.organisation.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
ownerUserId,
memberUserId,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Organisation`,
accessorKey: 'name',
cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.id}`}>{row.original.name}</Link>
),
},
{
header: t`Created At`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Owner`,
accessorKey: 'owner',
cell: ({ row }) => (
<Link to={`/admin/users/${row.original.owner.id}`}>{row.original.owner.name}</Link>
),
},
{
header: t`Status`,
cell: ({ row }) => (
<Badge variant="neutral">
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
</Badge>
),
},
{
header: t`Subscription`,
cell: ({ row }) =>
row.original.subscription ? (
<Link
to={`https://dashboard.stripe.com/subscriptions/${row.original.subscription.planId}`}
target="_blank"
className="flex flex-row items-center gap-2"
>
{SUBSCRIPTION_STATUS_MAP[row.original.subscription.status]}
<ExternalLinkIcon className="h-4 w-4" />
</Link>
) : (
'None'
),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={`/admin/organisations/${row.original.id}`}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Manage</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/admin/users/${row.original.owner.id}`}>
<UserIcon className="mr-2 h-4 w-4" />
<Trans>View owner</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!row.original.customerId} asChild>
<Link to={`https://dashboard.stripe.com/customers/${row.original.customerId}`}>
<CreditCardIcon className="mr-2 h-4 w-4" />
<Trans>Stripe</Trans>
{!row.original.customerId && <span>&nbsp;(N/A)</span>}
</Link>
</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}
columnVisibility={{
owner: showOwnerColumn,
status: memberUserId !== undefined,
}}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-2 w-6 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) =>
!hidePaginationUntilOverflow || 1 > table.getPageCount() ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
</DataTable>
</div>
);
};

View File

@ -15,7 +15,7 @@ import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type DocumentsTableActionButtonProps = {
row: TDocumentRow;
@ -26,7 +26,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const { toast } = useToast();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
@ -39,10 +39,8 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const documentsPath = formatDocumentsPath(team?.url);
const formatPath = row.folderId
? `${documentsPath}/f/${row.folderId}/${row.id}/edit`
: `${documentsPath}/${row.id}/edit`;
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.id}/edit`;
const onDownloadClick = async () => {
try {

View File

@ -14,7 +14,6 @@ import {
FolderInput,
Loader,
MoreHorizontal,
MoveRight,
Pencil,
Share,
Trash2,
@ -39,10 +38,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type DocumentsTableActionDropdownProps = {
row: TDocumentRow;
@ -54,14 +52,13 @@ export const DocumentsTableActionDropdown = ({
onMoveDocument,
}: DocumentsTableActionDropdownProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
@ -74,10 +71,8 @@ export const DocumentsTableActionDropdown = ({
const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url);
const formatPath = row.folderId
? `${documentsPath}/f/${row.folderId}/${row.id}/edit`
: `${documentsPath}/${row.id}/edit`;
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.id}/edit`;
const onDownloadClick = async () => {
try {
@ -193,14 +188,6 @@ export const DocumentsTableActionDropdown = ({
<Trans>Duplicate</Trans>
</DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */}
{!team && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
{onMoveDocument && (
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
<FolderInput className="mr-2 h-4 w-4" />
@ -259,16 +246,9 @@ export const DocumentsTableActionDropdown = ({
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
<DocumentMoveDialog
documentId={row.id}
open={isMoveDialogOpen}
onOpenChange={setMoveDialogOpen}
/>
<DocumentDuplicateDialog
id={row.id}
open={isDuplicateDialogOpen}

View File

@ -3,7 +3,6 @@ import { Trans } from '@lingui/react/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
@ -18,18 +17,20 @@ export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilte
const isMounted = useIsMounted();
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
const senderIds = (searchParams?.get('senderIds') ?? '')
.split(',')
.filter((value) => value !== '');
const { data, isLoading } = trpc.team.getTeamMembers.useQuery({
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const comboBoxOptions = (data ?? []).map((member) => ({
label: member.user.name ?? member.user.email,
value: member.user.id,
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newSenderIds: number[]) => {
const onChange = (newSenderIds: string[]) => {
if (!pathname) {
return;
}

View File

@ -7,7 +7,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
export type DataTableTitleProps = {
row: TDocumentRow;
teamUrl?: string;
teamUrl: string;
};
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
@ -19,7 +19,7 @@ export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
const documentsPath = formatDocumentsPath(teamUrl);
return match({
isOwner,

View File

@ -19,7 +19,7 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from './documents-table-action-button';
@ -42,7 +42,7 @@ export const DocumentsTable = ({
}: DocumentsTableProps) => {
const { _, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@ -167,7 +167,7 @@ export const DocumentsTable = ({
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl?: string;
teamUrl: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
@ -179,10 +179,8 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
const formatPath = row.folderId
? `${documentsPath}/f/${row.folderId}/${row.id}`
: `${documentsPath}/${row.id}`;
const documentsPath = formatDocumentsPath(teamUrl);
const formatPath = `${documentsPath}/${row.id}`;
return match({
isOwner,

View File

@ -0,0 +1,273 @@
import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
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 { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
export const InboxTable = () => {
const { _, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
page: page || 1,
perPage: perPage || 10,
});
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => (
<span className="block max-w-[10rem] truncate font-medium md:max-w-[20rem]">
{row.original.title}
</span>
),
},
{
id: 'sender',
header: _(msg`Sender`),
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.recipients}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
},
{
header: _(msg`Actions`),
cell: ({ row }) => <InboxTableActionButton row={row.original} />,
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<div className="relative">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: team !== undefined,
}}
error={{
enable: isLoadingError || false,
}}
emptyState={
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
<p>
<Trans>Documents that require your attention will appear here</Trans>
</p>
</div>
}
skeleton={{
enable: isLoading || false,
rows: 5,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell className="py-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-10 w-24 rounded" />
</TableCell>
</>
),
}}
>
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>
);
};
export type InboxTableActionButtonProps = {
row: TFindInboxResponse['data'][number];
};
export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isPending = row.status === DocumentStatusEnum.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
if (!recipient) {
return null;
}
const onDownloadClick = async () => {
try {
const document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
throw Error('No document available');
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({
isPending,
isComplete,
isSigned,
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<PencilIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircleIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>View</Trans>
</>
))}
</Link>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>View</Trans>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
))
.otherwise(() => <div></div>);
};

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { File } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
@ -11,22 +11,23 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
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';
export type TeamSettingsBillingInvoicesTableProps = {
teamId: number;
export type OrganisationBillingInvoicesTableProps = {
organisationId: string;
subscriptionExists: boolean;
};
export const TeamSettingsBillingInvoicesTable = ({
teamId,
}: TeamSettingsBillingInvoicesTableProps) => {
export const OrganisationBillingInvoicesTable = ({
organisationId,
subscriptionExists,
}: OrganisationBillingInvoicesTableProps) => {
const { _ } = useLingui();
const { data, isLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery(
const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery(
{
teamId,
organisationId,
},
{
placeholderData: (previousData) => previousData,
@ -43,7 +44,7 @@ export const TeamSettingsBillingInvoicesTable = ({
};
const results = {
data: data?.data ?? [],
data: data || [],
perPage: 100,
currentPage: 1,
totalPages: 1,
@ -58,13 +59,8 @@ export const TeamSettingsBillingInvoicesTable = ({
<div className="flex max-w-xs items-center gap-2">
<File className="h-6 w-6" />
<div className="flex flex-col text-sm">
<span className="text-foreground/80 font-semibold">
{DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
</span>
<span className="text-muted-foreground">
<Plural value={row.original.quantity} one="# Seat" other="# Seats" />
</span>
<div className="text-foreground/80 text-sm font-semibold">
{DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
</div>
</div>
),
@ -73,10 +69,10 @@ export const TeamSettingsBillingInvoicesTable = ({
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => {
const { status, paid } = row.original;
const { status } = row.original;
if (!status) {
return paid ? <Trans>Paid</Trans> : <Trans>Unpaid</Trans>;
return 'N/A';
}
return status.charAt(0).toUpperCase() + status.slice(1);
@ -94,9 +90,9 @@ export const TeamSettingsBillingInvoicesTable = ({
<Button
variant="outline"
asChild
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
disabled={typeof row.original.hosted_invoice_url !== 'string'}
>
<Link to={row.original.hostedInvoicePdf ?? ''} target="_blank">
<Link to={row.original.hosted_invoice_url ?? ''} target="_blank">
<Trans>View</Trans>
</Link>
</Button>
@ -104,9 +100,9 @@ export const TeamSettingsBillingInvoicesTable = ({
<Button
variant="outline"
asChild
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
disabled={typeof row.original.invoice_pdf !== 'string'}
>
<Link to={row.original.invoicePdf ?? ''} target="_blank">
<Link to={row.original.invoice_pdf ?? ''} target="_blank">
<Trans>Download</Trans>
</Link>
</Button>
@ -116,6 +112,10 @@ export const TeamSettingsBillingInvoicesTable = ({
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
if (results.data.length === 0 && !subscriptionExists) {
return null;
}
return (
<DataTable
columns={columns}
@ -157,7 +157,7 @@ export const TeamSettingsBillingInvoicesTable = ({
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
{/* {(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />} */}
</DataTable>
);
};

View File

@ -0,0 +1,149 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType } from '@prisma/client';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
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 { OrganisationGroupDeleteDialog } from '../dialogs/organisation-group-delete-dialog';
export const OrganisationGroupsDataTable = () => {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.organisation.group.find.useQuery(
{
organisationId: organisation.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
types: [OrganisationGroupType.CUSTOM],
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Group`),
accessorKey: 'name',
},
{
header: _(msg`Role`),
accessorKey: 'organisationRole',
cell: ({ row }) => _(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
},
{
header: _(msg`Members`),
accessorKey: 'members',
cell: ({ row }) => row.original.members.length,
},
{
header: _(msg`Assigned Teams`),
accessorKey: 'teams',
cell: ({ row }) => row.original.teams.length,
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>Manage</Link>
</Button>
<OrganisationGroupDeleteDialog
organisationGroupId={row.original.id}
organisationGroupName={row.original.name ?? ''}
trigger={
<Button variant="destructive" title={_(msg`Remove organisation group`)}>
<Trans>Delete</Trans>
</Button>
}
/>
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<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="w-1/2 py-4 pr-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-6 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
);
};

View File

@ -3,11 +3,13 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { History, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -25,32 +27,31 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export const TeamSettingsMemberInvitesTable = () => {
export const OrganisationMemberInvitesTable = () => {
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { _, i18n } = useLingui();
const { toast } = useToast();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery(
const { data, isLoading, isLoadingError } = trpc.organisation.member.invite.find.useQuery(
{
teamId: team.id,
organisationId: organisation.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
status: OrganisationMemberInviteStatus.PENDING,
},
{
placeholderData: (previousData) => previousData,
},
);
const { mutateAsync: resendTeamMemberInvitation } =
trpc.team.resendTeamMemberInvitation.useMutation({
const { mutateAsync: resendOrganisationMemberInvitation } =
trpc.organisation.member.invite.resend.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -66,8 +67,8 @@ export const TeamSettingsMemberInvitesTable = () => {
},
});
const { mutateAsync: deleteTeamMemberInvitations } =
trpc.team.deleteTeamMemberInvitations.useMutation({
const { mutateAsync: deleteOrganisationMemberInvitations } =
trpc.organisation.member.invite.deleteMany.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -100,7 +101,7 @@ export const TeamSettingsMemberInvitesTable = () => {
const columns = useMemo(() => {
return [
{
header: _(msg`Team Member`),
header: _(msg`Organisation Member`),
cell: ({ row }) => {
return (
<AvatarWithText
@ -116,7 +117,7 @@ export const TeamSettingsMemberInvitesTable = () => {
{
header: _(msg`Role`),
accessorKey: 'role',
cell: ({ row }) => _(TEAM_MEMBER_ROLE_MAP[row.original.role]) ?? row.original.role,
cell: ({ row }) => _(ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
},
{
header: _(msg`Invited At`),
@ -138,8 +139,8 @@ export const TeamSettingsMemberInvitesTable = () => {
<DropdownMenuItem
onClick={async () =>
resendTeamMemberInvitation({
teamId: team.id,
resendOrganisationMemberInvitation({
organisationId: organisation.id,
invitationId: row.original.id,
})
}
@ -150,8 +151,8 @@ export const TeamSettingsMemberInvitesTable = () => {
<DropdownMenuItem
onClick={async () =>
deleteTeamMemberInvitations({
teamId: team.id,
deleteOrganisationMemberInvitations({
organisationId: organisation.id,
invitationIds: [row.original.id],
})
}
@ -201,7 +202,11 @@ export const TeamSettingsMemberInvitesTable = () => {
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
);
};

View File

@ -3,14 +3,16 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType } from '@prisma/client';
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@ -26,22 +28,21 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { TeamMemberDeleteDialog } from '~/components/dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '~/components/dialogs/team-member-update-dialog';
import { useCurrentTeam } from '~/providers/team';
import { OrganisationMemberDeleteDialog } from '~/components/dialogs/organisation-member-delete-dialog';
import { OrganisationMemberUpdateDialog } from '~/components/dialogs/organisation-member-update-dialog';
export const TeamSettingsMembersDataTable = () => {
export const OrganisationMembersDataTable = () => {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
const { data, isLoading, isLoadingError } = trpc.organisation.member.find.useQuery(
{
teamId: team.id,
organisationId: organisation.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
@ -68,20 +69,20 @@ export const TeamSettingsMembersDataTable = () => {
const columns = useMemo(() => {
return [
{
header: _(msg`Team Member`),
header: _(msg`Organisation Member`),
cell: ({ row }) => {
const avatarFallbackText = row.original.user.name
? extractInitials(row.original.user.name)
: row.original.user.email.slice(0, 1).toUpperCase();
const avatarFallbackText = row.original.name
? extractInitials(row.original.name)
: row.original.email.slice(0, 1).toUpperCase();
return (
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={avatarFallbackText}
primaryText={
<span className="text-foreground/80 font-semibold">{row.original.user.name}</span>
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
}
secondaryText={row.original.user.email}
secondaryText={row.original.email}
/>
);
},
@ -90,15 +91,20 @@ export const TeamSettingsMembersDataTable = () => {
header: _(msg`Role`),
accessorKey: 'role',
cell: ({ row }) =>
team.ownerUserId === row.original.userId
organisation.ownerUserId === row.original.userId
? _(msg`Owner`)
: _(TEAM_MEMBER_ROLE_MAP[row.original.role]),
: _(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[row.original.currentOrganisationRole]),
},
{
header: _(msg`Member Since`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: _(msg`Groups`),
cell: ({ row }) =>
row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
@ -112,20 +118,23 @@ export const TeamSettingsMembersDataTable = () => {
<Trans>Actions</Trans>
</DropdownMenuLabel>
<TeamMemberUpdateDialog
currentUserTeamRole={team.currentTeamMember.role}
teamId={row.original.teamId}
teamMemberId={row.original.id}
teamMemberName={row.original.user.name ?? ''}
teamMemberRole={row.original.role}
<OrganisationMemberUpdateDialog
currentUserOrganisationRole={organisation.currentOrganisationRole}
organisationId={organisation.id}
organisationMemberId={row.original.id}
organisationMemberName={row.original.name ?? ''}
organisationMemberRole={row.original.currentOrganisationRole}
trigger={
<DropdownMenuItem
disabled={
team.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
organisation.ownerUserId === row.original.userId ||
!isOrganisationRoleWithinUserHierarchy(
organisation.currentOrganisationRole,
row.original.currentOrganisationRole,
)
}
onSelect={(e) => e.preventDefault()}
title="Update team member role"
title="Update organisation member role"
>
<Edit className="mr-2 h-4 w-4" />
<Trans>Update role</Trans>
@ -133,20 +142,21 @@ export const TeamSettingsMembersDataTable = () => {
}
/>
<TeamMemberDeleteDialog
teamId={team.id}
teamName={team.name}
teamMemberId={row.original.id}
teamMemberName={row.original.user.name ?? ''}
teamMemberEmail={row.original.user.email}
<OrganisationMemberDeleteDialog
organisationMemberId={row.original.id}
organisationMemberName={row.original.name ?? ''}
organisationMemberEmail={row.original.email}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
team.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
organisation.ownerUserId === row.original.userId ||
!isOrganisationRoleWithinUserHierarchy(
organisation.currentOrganisationRole,
row.original.currentOrganisationRole,
)
}
title={_(msg`Remove team member`)}
title={_(msg`Remove organisation member`)}
>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
@ -199,7 +209,11 @@ export const TeamSettingsMembersDataTable = () => {
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
);
};

View File

@ -7,11 +7,10 @@ import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -21,26 +20,23 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { TeamLeaveDialog } from '~/components/dialogs/team-leave-dialog';
import { TeamDeleteDialog } from '../dialogs/team-delete-dialog';
export const UserSettingsCurrentTeamsDataTable = () => {
export const OrganisationTeamsTable = () => {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.team.findTeams.useQuery(
{
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
placeholderData: (previousData) => previousData,
},
);
const { data, isLoading, isLoadingError } = trpc.team.find.useQuery({
organisationId: organisation.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
@ -76,15 +72,7 @@ export const UserSettingsCurrentTeamsDataTable = () => {
),
},
{
header: _(msg`Role`),
accessorKey: 'role',
cell: ({ row }) =>
row.original.ownerUserId === row.original.currentTeamMember.userId
? _(msg`Owner`)
: _(TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role]),
},
{
header: _(msg`Member Since`),
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
@ -92,26 +80,18 @@ export const UserSettingsCurrentTeamsDataTable = () => {
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
{canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && (
<Button variant="outline" asChild>
<Link to={`/t/${row.original.url}/settings`}>
<Trans>Manage</Trans>
</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link to={`/t/${row.original.url}/settings`}>
<Trans>Manage</Trans>
</Link>
</Button>
<TeamLeaveDialog
<TeamDeleteDialog
teamId={row.original.id}
teamName={row.original.name}
teamAvatarImageId={row.original.avatarImageId}
role={row.original.currentTeamMember.role}
trigger={
<Button
variant="destructive"
disabled={row.original.ownerUserId === row.original.currentTeamMember.userId}
onSelect={(e) => e.preventDefault()}
>
<Trans>Leave</Trans>
<Button variant="destructive" onSelect={(e) => e.preventDefault()}>
<Trans>Delete</Trans>
</Button>
}
/>
@ -163,7 +143,11 @@ export const UserSettingsCurrentTeamsDataTable = () => {
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
);
};

View File

@ -0,0 +1,183 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType } from '@prisma/client';
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
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 { useCurrentTeam } from '~/providers/team';
import { TeamGroupDeleteDialog } from '../dialogs/team-group-delete-dialog';
import { TeamGroupUpdateDialog } from '../dialogs/team-group-update-dialog';
export const TeamGroupsTable = () => {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.team.group.find.useQuery(
{
teamId: team.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
types: [OrganisationGroupType.CUSTOM],
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Group`),
accessorKey: 'name',
},
{
header: _(msg`Role`),
accessorKey: 'teamRole',
cell: ({ row }) => _(EXTENDED_TEAM_MEMBER_ROLE_MAP[row.original.teamRole]),
},
{
header: _(msg`Members`),
accessorKey: 'members',
cell: ({ row }) => row.original.members.length,
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<TeamGroupUpdateDialog
teamGroupId={row.original.id}
teamGroupName={row.original.name ?? ''}
teamGroupRole={row.original.teamRole}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
title="Update team group role"
>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Update role</Trans>
</DropdownMenuItem>
}
/>
<TeamGroupDeleteDialog
teamGroupId={row.original.id}
teamGroupName={row.original.name ?? ''}
teamGroupRole={row.original.teamRole}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
emptyState={
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
<p>
<Trans>No team groups found</Trans>
</p>
</div>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="w-1/2 py-4 pr-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-6 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
);
};

View File

@ -0,0 +1,240 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { EditIcon, MoreHorizontal, Trash2Icon } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
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 { useCurrentTeam } from '~/providers/team';
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
export const TeamMembersTable = () => {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const groupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
types: [OrganisationGroupType.INTERNAL_ORGANISATION, OrganisationGroupType.INTERNAL_TEAM],
organisationRoles: [OrganisationMemberRole.MEMBER],
perPage: 100, // Lets hope this is enough.
});
const { data, isLoading, isLoadingError } = trpc.team.member.find.useQuery(
{
teamId: team.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const groups = groupQuery.data?.data ?? [];
const memberAccessTeamGroup = groups.find(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.teamRole === OrganisationMemberRole.MEMBER,
);
const columns = useMemo(() => {
return [
{
header: _(msg`Team Member`),
cell: ({ row }) => {
const avatarFallbackText = row.original.name
? extractInitials(row.original.name)
: row.original.email.slice(0, 1).toUpperCase();
return (
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={avatarFallbackText}
primaryText={
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
}
secondaryText={row.original.email}
/>
);
},
},
{
header: _(msg`Role`),
accessorKey: 'role',
cell: ({ row }) => _(EXTENDED_TEAM_MEMBER_ROLE_MAP[row.original.teamRole]),
},
{
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`);
},
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<TeamMemberUpdateDialog
currentUserTeamRole={team.currentTeamRole}
teamId={team.id}
memberId={row.original.id}
memberName={row.original.name ?? ''}
memberTeamRole={row.original.teamRole}
trigger={
<DropdownMenuItem
disabled={
organisation.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
}
onSelect={(e) => e.preventDefault()}
title="Update team member role"
>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Update role</Trans>
</DropdownMenuItem>
}
/>
<TeamMemberDeleteDialog
teamId={team.id}
teamName={team.name}
memberId={row.original.id}
memberName={row.original.name ?? ''}
memberEmail={row.original.email}
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
organisation.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
}
title={_(msg`Remove team member`)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [groups]);
return (
<div className="space-y-8">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading || groupQuery.isPending,
rows: 3,
component: (
<>
<TableCell className="w-1/2 py-4 pr-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-6 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
<AnimateGenericFadeInOut key={groupQuery.isPending ? 'pending' : 'fetched'}>
{!groupQuery.isPending && (
<TeamInheritMemberAlert memberAccessTeamGroup={memberAccessTeamGroup || null} />
)}
</AnimateGenericFadeInOut>
</div>
);
};

View File

@ -2,16 +2,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import {
Copy,
Edit,
FolderIcon,
MoreHorizontal,
MoveRight,
Share2Icon,
Trash2,
Upload,
} from 'lucide-react';
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -27,7 +18,6 @@ import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
import { TemplateMoveDialog } from '../dialogs/template-move-dialog';
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
export type TemplatesTableActionDropdownProps = {
@ -36,15 +26,8 @@ export type TemplatesTableActionDropdownProps = {
recipients: Recipient[];
};
templateRootPath: string;
teamId?: number;
teamId: number;
onDelete?: () => Promise<void> | void;
onMove?: ({
templateId,
teamUrl,
}: {
templateId: number;
teamUrl: string;
}) => Promise<void> | void;
};
export const TemplatesTableActionDropdown = ({
@ -52,22 +35,18 @@ export const TemplatesTableActionDropdown = ({
templateRootPath,
teamId,
onDelete,
onMove,
}: TemplatesTableActionDropdownProps) => {
const { user } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
const isOwner = row.userId === user.id;
const isTeamTemplate = row.teamId === teamId;
const formatPath = row.folderId
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
: `${templateRootPath}/${row.id}/edit`;
const formatPath = `${templateRootPath}/${row.id}/edit`;
return (
<DropdownMenu>
@ -103,13 +82,6 @@ export const TemplatesTableActionDropdown = ({
<Trans>Move to Folder</Trans>
</DropdownMenuItem>
{!teamId && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
@ -142,13 +114,6 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setTemplateDirectLinkDialogOpen}
/>
<TemplateMoveDialog
templateId={row.id}
open={isMoveDialogOpen}
onOpenChange={setMoveDialogOpen}
onMove={onMove}
/>
<TemplateDeleteDialog
id={row.id}
open={isDeleteDialogOpen}

View File

@ -8,6 +8,7 @@ import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -19,7 +20,7 @@ import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { TemplateType } from '~/components/general/template/template-type';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
import { TemplateUseDialog } from '../dialogs/template-use-dialog';
import { TemplateDirectLinkBadge } from '../general/template/template-direct-link-badge';
@ -45,19 +46,15 @@ export const TemplatesTable = ({
const { _, i18n } = useLingui();
const { remaining } = useLimits();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const formatTemplateLink = (row: TemplatesTableRow) => {
const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url;
const path = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined);
if (row.folderId) {
return `${path}/f/${row.folderId}/${row.id}`;
}
const path = formatTemplatesPath(team.url);
return `${path}/${row.id}`;
};
@ -208,7 +205,10 @@ export const TemplatesTable = ({
<AlertDescription className="mt-2">
<Trans>
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" to="/settings/billing">
<Link
className="underline underline-offset-4"
to={`/o/${organisation.url}/settings/billing`}
>
Upgrade your account to continue!
</Link>
</Trans>

View File

@ -0,0 +1,169 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { OrganisationLeaveDialog } from '../dialogs/organisation-leave-dialog';
export const UserOrganisationsTable = () => {
const { _, i18n } = useLingui();
const { user, organisations } = useSession();
const { data, isLoading, isLoadingError } = trpc.organisation.getMany.useQuery(undefined, {
initialData: organisations.map((org) => ({
...org,
currentMemberId: '', // Unsed dummy data.
})),
});
const isPersonalLayoutMode = isPersonalLayout(data);
const results = {
data: data || [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Organisation`),
accessorKey: 'name',
cell: ({ row }) => (
<Link
to={isPersonalLayoutMode ? `/settings/organisations` : `/o/${row.original.url}`}
preventScrollReset={true}
>
<AvatarWithText
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="text-foreground/80 font-semibold">
{isPersonalLayoutMode ? _(msg`Personal`) : row.original.name}
</span>
}
secondaryText={
isPersonalLayoutMode
? _(msg`Your personal organisation`)
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`
}
/>
</Link>
),
},
{
header: _(msg`Role`),
accessorKey: 'role',
cell: ({ row }) =>
row.original.ownerUserId === user.id
? _(msg`Owner`)
: _(ORGANISATION_MEMBER_ROLE_MAP[row.original.currentOrganisationRole]),
},
{
header: _(msg`Created At`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
{canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
row.original.currentOrganisationRole,
) && (
<Button variant="outline" asChild>
<Link to={`/o/${row.original.url}/settings`}>
<Trans>Manage</Trans>
</Link>
</Button>
)}
<OrganisationLeaveDialog
organisationId={row.original.id}
organisationName={row.original.name}
organisationAvatarImageId={row.original.avatarImageId}
role={row.original.currentOrganisationRole}
trigger={
<Button
variant="destructive"
disabled={row.original.ownerUserId === user.id}
onSelect={(e) => e.preventDefault()}
>
<Trans>Leave</Trans>
</Button>
}
/>
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [isPersonalLayoutMode]);
return (
<div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
error={{
enable: isLoadingError,
}}
columnVisibility={{
actions: !isPersonalLayoutMode,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="w-1/3 py-4 pr-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
{!isPersonalLayoutMode && (
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-16 rounded" />
</div>
</TableCell>
)}
</>
),
}}
/>
</div>
);
};

View File

@ -1,59 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type UserSettingsPendingTeamsTableActionsProps = {
className?: string;
pendingTeamId: number;
onPayClick: (pendingTeamId: number) => void;
};
export const UserSettingsPendingTeamsTableActions = ({
className,
pendingTeamId,
onPayClick,
}: UserSettingsPendingTeamsTableActionsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTeamPending, isPending: deletingTeam } =
trpc.team.deleteTeamPending.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Pending team deleted.`),
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an unknown error while attempting to delete the pending team. Please try again later.`,
),
duration: 10000,
variant: 'destructive',
});
},
});
return (
<fieldset disabled={deletingTeam} className={cn('flex justify-end space-x-2', className)}>
<Button variant="outline" onClick={() => onPayClick(pendingTeamId)}>
<Trans>Pay</Trans>
</Button>
<Button
variant="destructive"
loading={deletingTeam}
onClick={async () => deleteTeamPending({ pendingTeamId: pendingTeamId })}
>
<Trans>Remove</Trans>
</Button>
</fieldset>
);
};

View File

@ -1,149 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
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 { TeamCheckoutCreateDialog } from '~/components/dialogs/team-checkout-create-dialog';
import { UserSettingsPendingTeamsTableActions } from './user-settings-pending-teams-table-actions';
export const UserSettingsPendingTeamsDataTable = () => {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState<number | null>(null);
const { data, isLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
{
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Team`),
accessorKey: 'name',
cell: ({ row }) => (
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
}
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`}
/>
),
},
{
header: _(msg`Created on`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
id: 'actions',
cell: ({ row }) => (
<UserSettingsPendingTeamsTableActions
className="justify-end"
pendingTeamId={row.original.id}
onPayClick={setCheckoutPendingTeamId}
/>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
useEffect(() => {
const searchParamCheckout = searchParams?.get('checkout');
if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) {
setCheckoutPendingTeamId(parseInt(searchParamCheckout));
updateSearchParams({ checkout: null });
}
}, [searchParams, updateSearchParams]);
return (
<>
<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="w-1/3 py-4 pr-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-16 rounded" />
<Skeleton className="h-10 w-20 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
<TeamCheckoutCreateDialog
pendingTeamId={checkoutPendingTeamId}
onClose={() => setCheckoutPendingTeamId(null)}
/>
</>
);
};

View File

@ -1,87 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link, useSearchParams } from 'react-router';
import { useLocation } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { UserSettingsCurrentTeamsDataTable } from './user-settings-current-teams-table';
import { UserSettingsPendingTeamsDataTable } from './user-settings-pending-teams-table';
export const UserSettingsTeamsPageDataTable = () => {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active';
const { data } = trpc.team.findTeamsPending.useQuery(
{},
{
placeholderData: (previousData) => previousData,
},
);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={_(msg`Search`)}
/>
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="active" asChild>
<Link to={pathname ?? '/'}>
<Trans>Active</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="pending" asChild>
<Link to={`${pathname}?tab=pending`}>
<Trans>Pending</Trans>
{data && data.count > 0 && (
<span className="ml-1 hidden opacity-50 md:inline-block">{data.count}</span>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{currentTab === 'pending' ? (
<UserSettingsPendingTeamsDataTable />
) : (
<UserSettingsCurrentTeamsDataTable />
)}
</div>
);
};