feat: add admin org stats (#2904)

This commit is contained in:
David Nguyen
2026-06-01 17:26:51 +10:00
committed by GitHub
parent 44c4826e92
commit 536142be03
9 changed files with 554 additions and 2 deletions
@@ -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) => (
<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="hover:underline">
{row.original.organisationName}
</Link>
),
},
{
header: t`Claim`,
accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground">{row.original.originalClaimId ?? '—'}</span>,
},
{
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 }) => <span className="font-medium">{row.original.totalCount}</span>,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, orderByColumn, orderByDirection]);
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: 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>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -10,6 +10,7 @@ import {
BarChart3,
Building2Icon,
FileStack,
LineChartIcon,
MailIcon,
Settings,
Trophy,
@@ -153,6 +154,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/organisation-stats') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/organisation-stats">
<LineChartIcon className="mr-2 h-5 w-5" />
<Trans>Organisation Stats</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/site-settings') && 'bg-secondary')}
@@ -0,0 +1,173 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationStatsTable } from '~/components/tables/admin-organisation-stats-table';
const ALL_CLAIMS_VALUE = 'all';
/**
* The earliest UTC calendar month for which stats exist (the month the feature launched).
* Months before this never have data, so there's no point offering them in the filter.
*/
const EARLIEST_PERIOD = { year: 2026, month: 5 };
/**
* Generate every UTC calendar month from `EARLIEST_PERIOD` up to the current month as
* `YYYY-MM` strings, newest first.
*/
const generatePeriodOptions = (): string[] => {
const periods: string[] = [];
const now = new Date();
let year = now.getUTCFullYear();
let month = now.getUTCMonth() + 1;
while (year > EARLIEST_PERIOD.year || (year === EARLIEST_PERIOD.year && month >= EARLIEST_PERIOD.month)) {
periods.push(`${year}-${String(month).padStart(2, '0')}`);
month -= 1;
if (month === 0) {
month = 12;
year -= 1;
}
}
return periods;
};
export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
const selectedClaim = searchParams?.get('claimId') ?? ALL_CLAIMS_VALUE;
const { data: claimsData, isLoading: isLoadingClaims } = trpc.admin.claims.find.useQuery({
perPage: 100,
});
const claimOptions = claimsData?.data ?? [];
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('period', value);
params.delete('page');
setSearchParams(params);
};
const onClaimChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value === ALL_CLAIMS_VALUE) {
params.delete('claimId');
} else {
params.set('claimId', value);
}
params.delete('page');
setSearchParams(params);
};
return (
<div>
<SettingsHeader
hideDivider
title={t`Organisation Stats`}
subtitle={t`View, sort and filter monthly usage stats across organisations`}
/>
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
<Select value={selectedClaim} onValueChange={onClaimChange}>
<SelectTrigger className="w-full sm:w-48" loading={isLoadingClaims}>
<SelectValue placeholder={t`All claims`} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CLAIMS_VALUE}>{t`All claims`}</SelectItem>
{claimOptions.map((claim) => (
<SelectItem key={claim.id} value={claim.id}>
{claim.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedPeriod} onValueChange={onPeriodChange}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder={t`Period`} />
</SelectTrigger>
<SelectContent>
{periodOptions.map((period) => (
<SelectItem key={period} value={period}>
{period}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-4">
<AdminOrganisationStatsTable />
</div>
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<Trans>
Documents, emails and api values may not be accurate since they record the amount of times the action was
attempted. Meaning these values may go over the actual quota, get rejected, and will still be recorded.
</Trans>
</AlertDescription>
</Alert>
</div>
);
}