feat: org insights

This commit is contained in:
Ephraim Atta-Duncan
2025-08-04 11:55:16 +00:00
parent 49c70fc8a8
commit 38f3a52233
9 changed files with 887 additions and 46 deletions

View File

@ -0,0 +1,49 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateRangeFilterProps = {
currentRange: string;
};
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const handleRangeChange = (value: string) => {
startTransition(() => {
updateSearchParams({
dateRange: value,
page: 1,
});
});
};
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{_(msg`Time Range:`)}</span>
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -11,16 +11,20 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
export type OrganisationOverview = {
id: string;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
subscriptionStatus?: string;
isActive?: boolean;
teamCount?: number;
memberCount?: number;
};
type LeaderboardTableProps = {
signingVolume: SigningVolume[];
type OrganisationOverviewTableProps = {
organisations: OrganisationOverview[];
totalPages: number;
perPage: number;
page: number;
@ -28,14 +32,14 @@ type LeaderboardTableProps = {
sortOrder: 'asc' | 'desc';
};
export const AdminLeaderboardTable = ({
signingVolume,
export const AdminOrganisationOverviewTable = ({
organisations,
totalPages,
perPage,
page,
sortBy,
sortOrder,
}: LeaderboardTableProps) => {
}: OrganisationOverviewTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
@ -69,15 +73,14 @@ export const AdminLeaderboardTable = ({
<div>
<a
className="text-primary underline"
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
target="_blank"
href={`/admin/organisation-insights/${row.original.id}`}
>
{row.getValue('name')}
</a>
</div>
);
},
size: 250,
size: 200,
},
{
header: () => (
@ -85,7 +88,7 @@ export const AdminLeaderboardTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')}
>
{_(msg`Signing Volume`)}
{_(msg`Document Volume`)}
{sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
@ -99,6 +102,42 @@ export const AdminLeaderboardTable = ({
),
accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
size: 120,
},
{
header: () => {
return <div>{_(msg`Status`)}</div>;
},
accessorKey: 'subscriptionStatus',
cell: ({ row }) => {
const status = row.original.subscriptionStatus;
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
status ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{status || 'Free'}
</div>
);
},
size: 100,
},
{
header: () => {
return <div>{_(msg`Teams`)}</div>;
},
accessorKey: 'teamCount',
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
size: 80,
},
{
header: () => {
return <div>{_(msg`Members`)}</div>;
},
accessorKey: 'memberCount',
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
size: 80,
},
{
header: () => {
@ -122,8 +161,9 @@ export const AdminLeaderboardTable = ({
},
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
size: 120,
},
] satisfies DataTableColumnDef<SigningVolume>[];
] satisfies DataTableColumnDef<OrganisationOverview>[];
}, [sortOrder, sortBy]);
useEffect(() => {
@ -169,13 +209,13 @@ export const AdminLeaderboardTable = ({
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder={_(msg`Search by name or email`)}
placeholder={_(msg`Search by organisation name`)}
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={columns}
data={signingVolume}
data={organisations}
perPage={perPage}
currentPage={page}
totalPages={totalPages}

View File

@ -100,6 +100,22 @@ export const AdminOrganisationsTable = ({
</Badge>
),
},
{
header: t`Status`,
cell: ({ row }) => {
const subscription = row.original.subscription;
const isPaid = subscription && subscription.status === 'ACTIVE';
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{isPaid ? 'Paid' : 'Free'}
</div>
);
},
},
{
header: t`Subscription`,
cell: ({ row }) =>

View File

@ -0,0 +1,251 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Archive, Building2, Loader, TrendingUp, Users } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
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 { DateRangeFilter } from '~/components/filters/date-range-filter';
type OrganisationInsightsTableProps = {
insights: OrganisationDetailedInsights;
page: number;
perPage: number;
dateRange: string;
view: 'teams' | 'users' | 'documents';
};
export const OrganisationInsightsTable = ({
insights,
page,
perPage,
dateRange,
view,
}: OrganisationInsightsTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const onPaginationChange = (newPage: number, newPerPage: number) => {
startTransition(() => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
});
};
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
startTransition(() => {
updateSearchParams({
view: newView,
page: 1,
});
});
};
const teamsColumns = [
{
header: _(msg`Team Name`),
accessorKey: 'name',
cell: ({ row }) => row.getValue('name'),
},
{
header: _(msg`Members`),
accessorKey: 'memberCount',
cell: ({ row }) => row.getValue('memberCount'),
},
{
header: _(msg`Documents`),
accessorKey: 'documentCount',
cell: ({ row }) => row.getValue('documentCount'),
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')),
},
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
const usersColumns = [
{
header: _(msg`Name`),
accessorKey: 'name',
cell: ({ row }) => row.getValue('name') || row.getValue('email'),
},
{
header: _(msg`Email`),
accessorKey: 'email',
cell: ({ row }) => row.getValue('email'),
},
{
header: _(msg`Documents Created`),
accessorKey: 'documentCount',
cell: ({ row }) => row.getValue('documentCount'),
},
{
header: _(msg`Documents Signed`),
accessorKey: 'signedDocumentCount',
cell: ({ row }) => row.getValue('signedDocumentCount'),
},
{
header: _(msg`Joined`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')),
},
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
const documentsColumns = [
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => row.getValue('title'),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => row.getValue('status'),
},
{
header: _(msg`Team`),
accessorKey: 'teamName',
cell: ({ row }) => row.getValue('teamName'),
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')),
},
{
header: _(msg`Completed`),
accessorKey: 'completedAt',
cell: ({ row }) => {
const completedAt = row.getValue('completedAt') as Date | null;
return completedAt ? i18n.date(completedAt) : '-';
},
},
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
const getCurrentData = (): unknown[] => {
switch (view) {
case 'teams':
return insights.teams;
case 'users':
return insights.users;
case 'documents':
return insights.documents;
default:
return [];
}
};
const getCurrentColumns = (): DataTableColumnDef<unknown>[] => {
switch (view) {
case 'teams':
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
case 'users':
return usersColumns as unknown as DataTableColumnDef<unknown>[];
case 'documents':
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
default:
return [];
}
};
const SummaryCard = ({
icon: Icon,
title,
value,
subtitle,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
subtitle?: string;
}) => (
<div className="bg-card rounded-lg border p-4">
<div className="flex items-center space-x-2">
<Icon className="text-muted-foreground h-5 w-5" />
<div className="flex-1">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
</div>
</div>
</div>
);
return (
<div className="relative">
{insights.summary && (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
<SummaryCard
icon={TrendingUp}
title={_(msg`Volume (Period)`)}
value={insights.summary.volumeThisPeriod}
subtitle={_(msg`This time range`)}
/>
<SummaryCard
icon={Archive}
title={_(msg`Volume (All Time)`)}
value={insights.summary.volumeAllTime}
subtitle={_(msg`Total completed`)}
/>
</div>
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex gap-2">
<Button
variant={view === 'teams' ? 'default' : 'outline'}
onClick={() => handleViewChange('teams')}
disabled={isPending}
>
{_(msg`Teams`)}
</Button>
<Button
variant={view === 'users' ? 'default' : 'outline'}
onClick={() => handleViewChange('users')}
disabled={isPending}
>
{_(msg`Users`)}
</Button>
<Button
variant={view === 'documents' ? 'default' : 'outline'}
onClick={() => handleViewChange('documents')}
disabled={isPending}
>
{_(msg`Documents`)}
</Button>
</div>
<DateRangeFilter currentRange={dateRange} />
</div>
<DataTable<unknown, unknown>
columns={getCurrentColumns()}
data={getCurrentData()}
perPage={perPage}
currentPage={page}
totalPages={insights.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};