From 536142be030b3c20c4352531abcee0f4f52e57c3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 1 Jun 2026 17:26:51 +1000 Subject: [PATCH] feat: add admin org stats (#2904) --- .../tables/admin-organisation-stats-table.tsx | 188 ++++++++++++++++++ .../routes/_authenticated+/admin+/_layout.tsx | 15 ++ .../admin+/organisation-stats._index.tsx | 173 ++++++++++++++++ .../rate-limit/check-monthly-quota.ts | 2 +- .../monthly-period.ts} | 0 .../admin-router/find-organisation-stats.ts | 139 +++++++++++++ .../find-organisation-stats.types.ts | 35 ++++ .../reset-organisation-monthly-stat.ts | 2 +- packages/trpc/server/admin-router/router.ts | 2 + 9 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 apps/remix/app/components/tables/admin-organisation-stats-table.tsx create mode 100644 apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx rename packages/lib/{server-only/rate-limit/current-monthly-period.ts => universal/monthly-period.ts} (100%) create mode 100644 packages/trpc/server/admin-router/find-organisation-stats.ts create mode 100644 packages/trpc/server/admin-router/find-organisation-stats.types.ts diff --git a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx new file mode 100644 index 000000000..97275e1c1 --- /dev/null +++ b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx @@ -0,0 +1,188 @@ +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' | 'totalCount'; +type OrderByDirection = 'asc' | 'desc'; + +const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => { + if (value === 'documentCount' || value === 'emailCount' || value === 'apiCount' || value === 'totalCount') { + return value; + } + + return undefined; +}; + +const parseOrderByDirection = (value: string | undefined): OrderByDirection => { + return value === 'asc' ? 'asc' : 'desc'; +}; + +export const AdminOrganisationStatsTable = () => { + const { t } = useLingui(); + + const [searchParams] = 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.findStats.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'; + + updateSearchParams({ + orderByColumn: column, + orderByDirection: nextDirection, + page: 1, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + const sortableHeader = (label: string, column: OrderByColumn) => ( + + ); + + return [ + { + header: t`Organisation`, + accessorKey: 'organisationName', + cell: ({ row }) => ( + + {row.original.organisationName} + + ), + }, + { + header: t`Claim`, + accessorKey: 'originalClaimId', + cell: ({ row }) => {row.original.originalClaimId ?? '—'}, + }, + { + header: t`Period`, + accessorKey: 'period', + cell: ({ row }) => row.original.period, + }, + { + header: () => sortableHeader(t`Documents`, 'documentCount'), + accessorKey: 'documentCount', + cell: ({ row }) => row.original.documentCount, + }, + { + header: () => sortableHeader(t`Emails`, 'emailCount'), + accessorKey: 'emailCount', + cell: ({ row }) => row.original.emailCount, + }, + { + header: () => sortableHeader(t`API`, 'apiCount'), + accessorKey: 'apiCount', + cell: ({ row }) => row.original.apiCount, + }, + { + header: () => sortableHeader(t`Total`, 'totalCount'), + accessorKey: 'totalCount', + cell: ({ row }) => {row.original.totalCount}, + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, orderByColumn, orderByDirection]); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + ), + }} + > + {(table) => } + +
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index af24ab0bf..af00d92cb 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -10,6 +10,7 @@ import { BarChart3, Building2Icon, FileStack, + LineChartIcon, MailIcon, Settings, Trophy, @@ -153,6 +154,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) { + +