diff --git a/apps/remix/app/components/filters/date-range-filter.tsx b/apps/remix/app/components/filters/date-range-filter.tsx
new file mode 100644
index 000000000..a4409cc2a
--- /dev/null
+++ b/apps/remix/app/components/filters/date-range-filter.tsx
@@ -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 (
+
+ {_(msg`Time Range:`)}
+
+
+ );
+};
diff --git a/apps/remix/app/components/tables/admin-leaderboard-table.tsx b/apps/remix/app/components/tables/admin-organisation-overview-table.tsx
similarity index 75%
rename from apps/remix/app/components/tables/admin-leaderboard-table.tsx
rename to apps/remix/app/components/tables/admin-organisation-overview-table.tsx
index eca409eb9..00380f63b 100644
--- a/apps/remix/app/components/tables/admin-leaderboard-table.tsx
+++ b/apps/remix/app/components/tables/admin-organisation-overview-table.tsx
@@ -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 = ({
);
},
- 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' ? (
@@ -99,6 +102,42 @@ export const AdminLeaderboardTable = ({
),
accessorKey: 'signingVolume',
cell: ({ row }) => {Number(row.getValue('signingVolume'))}
,
+ size: 120,
+ },
+ {
+ header: () => {
+ return {_(msg`Status`)}
;
+ },
+ accessorKey: 'subscriptionStatus',
+ cell: ({ row }) => {
+ const status = row.original.subscriptionStatus;
+ return (
+
+ {status || 'Free'}
+
+ );
+ },
+ size: 100,
+ },
+ {
+ header: () => {
+ return {_(msg`Teams`)}
;
+ },
+ accessorKey: 'teamCount',
+ cell: ({ row }) => {Number(row.original.teamCount) || 0}
,
+ size: 80,
+ },
+ {
+ header: () => {
+ return {_(msg`Members`)}
;
+ },
+ accessorKey: 'memberCount',
+ cell: ({ row }) => {Number(row.original.memberCount) || 0}
,
+ size: 80,
},
{
header: () => {
@@ -122,8 +161,9 @@ export const AdminLeaderboardTable = ({
},
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
+ size: 120,
},
- ] satisfies DataTableColumnDef[];
+ ] satisfies DataTableColumnDef[];
}, [sortOrder, sortBy]);
useEffect(() => {
@@ -169,13 +209,13 @@ export const AdminLeaderboardTable = ({
),
},
+ {
+ header: t`Status`,
+ cell: ({ row }) => {
+ const subscription = row.original.subscription;
+ const isPaid = subscription && subscription.status === 'ACTIVE';
+ return (
+
+ {isPaid ? 'Paid' : 'Free'}
+
+ );
+ },
+ },
{
header: t`Subscription`,
cell: ({ row }) =>
diff --git a/apps/remix/app/components/tables/organisation-insights-table.tsx b/apps/remix/app/components/tables/organisation-insights-table.tsx
new file mode 100644
index 000000000..4fe627b24
--- /dev/null
+++ b/apps/remix/app/components/tables/organisation-insights-table.tsx
@@ -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[] => {
+ switch (view) {
+ case 'teams':
+ return teamsColumns as unknown as DataTableColumnDef[];
+ case 'users':
+ return usersColumns as unknown as DataTableColumnDef[];
+ case 'documents':
+ return documentsColumns as unknown as DataTableColumnDef[];
+ default:
+ return [];
+ }
+ };
+
+ const SummaryCard = ({
+ icon: Icon,
+ title,
+ value,
+ subtitle,
+ }: {
+ icon: React.ComponentType<{ className?: string }>;
+ title: string;
+ value: number;
+ subtitle?: string;
+ }) => (
+
+
+
+
+
{title}
+
{value}
+ {subtitle &&
{subtitle}
}
+
+
+
+ );
+
+ return (
+
+ {insights.summary && (
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ columns={getCurrentColumns()}
+ data={getCurrentData()}
+ perPage={perPage}
+ currentPage={page}
+ totalPages={insights.totalPages}
+ onPaginationChange={onPaginationChange}
+ >
+ {(table) => }
+
+
+ {isPending && (
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
index 18de42b01..4678a36f8 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
@@ -114,13 +114,13 @@ export default function AdminLayout() {
variant="ghost"
className={cn(
'justify-start md:w-full',
- pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
+ pathname?.startsWith('/admin/org-insights') && 'bg-secondary',
)}
asChild
>
-
+
- Leaderboard
+ Organisation Insights
diff --git a/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx b/apps/remix/app/routes/_authenticated+/admin+/org-insights.tsx
similarity index 51%
rename from apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
rename to apps/remix/app/routes/_authenticated+/admin+/org-insights.tsx
index 2a6857e5e..df9387689 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/org-insights.tsx
@@ -1,13 +1,14 @@
import { Trans } from '@lingui/react/macro';
-import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
+import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
+import { DateRangeFilter } from '~/components/filters/date-range-filter';
import {
- AdminLeaderboardTable,
- type SigningVolume,
-} from '~/components/tables/admin-leaderboard-table';
+ AdminOrganisationOverviewTable,
+ type OrganisationOverview,
+} from '~/components/tables/admin-organisation-overview-table';
-import type { Route } from './+types/leaderboard';
+import type { Route } from './+types/org-insights';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
@@ -27,44 +28,58 @@ export async function loader({ request }: Route.LoaderArgs) {
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
+ const dateRange = (url.searchParams.get('dateRange') || 'last30days') as
+ | 'last30days'
+ | 'last90days'
+ | 'lastYear'
+ | 'allTime';
- const { leaderboard, totalPages } = await getSigningVolume({
+ const { organisations, totalPages } = await getOrganisationInsights({
search,
page,
perPage,
sortBy,
sortOrder,
+ dateRange,
});
- const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
- ...item,
+ const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
+ id: String(item.id),
name: item.name || '',
+ signingVolume: item.signingVolume,
createdAt: item.createdAt || new Date(),
+ planId: item.customerId || '',
+ subscriptionStatus: item.subscriptionStatus,
+ teamCount: item.teamCount || 0,
+ memberCount: item.memberCount || 0,
}));
return {
- signingVolume: typedSigningVolume,
+ organisations: typedOrganisations,
totalPages,
page,
perPage,
sortBy,
sortOrder,
+ dateRange,
};
}
-export default function Leaderboard({ loaderData }: Route.ComponentProps) {
- const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
+export default function Organisations({ loaderData }: Route.ComponentProps) {
+ const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
return (
-
+
- Signing Volume
+ Organisation Insights
+
+
-
+
+
+ Organisation Insights
+
+
+
+
+
+
+ );
+}
diff --git a/packages/lib/server-only/admin/get-organisation-detailed-insights.ts b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts
new file mode 100644
index 000000000..9f401fc20
--- /dev/null
+++ b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts
@@ -0,0 +1,301 @@
+import type { DocumentStatus } from '@prisma/client';
+
+import { kyselyPrisma, sql } from '@documenso/prisma';
+
+export type OrganisationSummary = {
+ totalTeams: number;
+ totalMembers: number;
+ totalDocuments: number;
+ activeDocuments: number;
+ completedDocuments: number;
+ volumeThisPeriod: number;
+ volumeAllTime: number;
+};
+
+export type OrganisationDetailedInsights = {
+ teams: TeamInsights[];
+ users: UserInsights[];
+ documents: DocumentInsights[];
+ totalPages: number;
+ summary?: OrganisationSummary;
+};
+
+export type TeamInsights = {
+ id: number;
+ name: string;
+ memberCount: number;
+ documentCount: number;
+ createdAt: Date;
+};
+
+export type UserInsights = {
+ id: number;
+ name: string;
+ email: string;
+ documentCount: number;
+ signedDocumentCount: number;
+ createdAt: Date;
+};
+
+export type DocumentInsights = {
+ id: string;
+ title: string;
+ status: DocumentStatus;
+ teamName: string;
+ createdAt: Date;
+ completedAt: Date | null;
+};
+
+export type GetOrganisationDetailedInsightsOptions = {
+ organisationId: string;
+ page?: number;
+ perPage?: number;
+ dateRange?: 'last30days' | 'last90days' | 'lastYear' | 'allTime';
+ view: 'teams' | 'users' | 'documents';
+};
+
+export async function getOrganisationDetailedInsights({
+ organisationId,
+ page = 1,
+ perPage = 10,
+ dateRange = 'last30days',
+ view,
+}: GetOrganisationDetailedInsightsOptions): Promise
{
+ const offset = Math.max(page - 1, 0) * perPage;
+
+ let dateFilter = sql``;
+ const now = new Date();
+
+ switch (dateRange) {
+ case 'last30days': {
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ dateFilter = sql`AND d."createdAt" >= ${thirtyDaysAgo}`;
+ break;
+ }
+ case 'last90days': {
+ const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ dateFilter = sql`AND d."createdAt" >= ${ninetyDaysAgo}`;
+ break;
+ }
+ case 'lastYear': {
+ const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
+ dateFilter = sql`AND d."createdAt" >= ${oneYearAgo}`;
+ break;
+ }
+ case 'allTime':
+ default:
+ dateFilter = sql``;
+ break;
+ }
+
+ // Get organisation summary metrics
+ const summaryData = await getOrganisationSummary(organisationId, dateFilter);
+
+ const viewData = await (async () => {
+ switch (view) {
+ case 'teams':
+ return await getTeamInsights(organisationId, offset, perPage, dateFilter);
+ case 'users':
+ return await getUserInsights(organisationId, offset, perPage, dateFilter);
+ case 'documents':
+ return await getDocumentInsights(organisationId, offset, perPage, dateFilter);
+ default:
+ throw new Error(`Invalid view: ${view}`);
+ }
+ })();
+
+ return {
+ ...viewData,
+ summary: summaryData,
+ };
+}
+
+async function getTeamInsights(
+ organisationId: string,
+ offset: number,
+ perPage: number,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ dateFilter: any,
+): Promise {
+ const teamsQuery = kyselyPrisma.$kysely
+ .selectFrom('Team as t')
+ .leftJoin('Document as d', (join) =>
+ join.onRef('t.id', '=', 'd.teamId').on('d.deletedAt', 'is', null),
+ )
+ .where('t.organisationId', '=', organisationId)
+ .select([
+ 't.id as id',
+ 't.name as name',
+ 't.createdAt as createdAt',
+ sql`0`.as('memberCount'),
+ sql`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL ${dateFilter} THEN d.id END)`.as(
+ 'documentCount',
+ ),
+ ])
+ .groupBy(['t.id', 't.name', 't.createdAt'])
+ .orderBy('documentCount', 'desc')
+ .limit(perPage)
+ .offset(offset);
+
+ const countQuery = kyselyPrisma.$kysely
+ .selectFrom('Team as t')
+ .where('t.organisationId', '=', organisationId)
+ .select(({ fn }) => [fn.countAll().as('count')]);
+
+ const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]);
+ const count = Number(countResult[0]?.count || 0);
+
+ return {
+ teams: teams as TeamInsights[],
+ users: [],
+ documents: [],
+ totalPages: Math.ceil(Number(count) / perPage),
+ };
+}
+
+async function getUserInsights(
+ organisationId: string,
+ offset: number,
+ perPage: number,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, unused-imports/no-unused-vars
+ _dateFilter: any,
+): Promise {
+ const usersQuery = kyselyPrisma.$kysely
+ .selectFrom('OrganisationMember as om')
+ .innerJoin('User as u', 'u.id', 'om.userId')
+ .where('om.organisationId', '=', organisationId)
+ .select([
+ 'u.id as id',
+ 'u.name as name',
+ 'u.email as email',
+ 'u.createdAt as createdAt',
+ sql`0`.as('documentCount'),
+ sql`0`.as('signedDocumentCount'),
+ ])
+ .groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
+ .orderBy('u.createdAt', 'desc')
+ .limit(perPage)
+ .offset(offset);
+
+ const countQuery = kyselyPrisma.$kysely
+ .selectFrom('OrganisationMember as om')
+ .innerJoin('User as u', 'u.id', 'om.userId')
+ .where('om.organisationId', '=', organisationId)
+ .select(({ fn }) => [fn.countAll().as('count')]);
+
+ const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]);
+ const count = Number(countResult[0]?.count || 0);
+
+ return {
+ teams: [],
+ users: users as UserInsights[],
+ documents: [],
+ totalPages: Math.ceil(Number(count) / perPage),
+ };
+}
+
+async function getDocumentInsights(
+ organisationId: string,
+ offset: number,
+ perPage: number,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ dateFilter: any,
+): Promise {
+ let documentsQuery = kyselyPrisma.$kysely
+ .selectFrom('Document as d')
+ .innerJoin('Team as t', 'd.teamId', 't.id')
+ .where('t.organisationId', '=', organisationId)
+ .where('d.deletedAt', 'is', null);
+
+ // Apply date filter if it's not empty (which means all time)
+ if (dateFilter && dateFilter.sql && dateFilter.sql !== '') {
+ documentsQuery = documentsQuery.where(sql`${dateFilter}`);
+ }
+
+ documentsQuery = documentsQuery
+ .select([
+ 'd.id as id',
+ 'd.title as title',
+ 'd.status as status',
+ 'd.createdAt as createdAt',
+ 'd.completedAt as completedAt',
+ 't.name as teamName',
+ ])
+ .orderBy('d.createdAt', 'desc')
+ .limit(perPage)
+ .offset(offset);
+
+ let countQuery = kyselyPrisma.$kysely
+ .selectFrom('Document as d')
+ .innerJoin('Team as t', 'd.teamId', 't.id')
+ .where('t.organisationId', '=', organisationId)
+ .where('d.deletedAt', 'is', null);
+
+ // Apply same date filter to count query
+ if (dateFilter && dateFilter.sql && dateFilter.sql !== '') {
+ countQuery = countQuery.where(sql`${dateFilter}`);
+ }
+
+ countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
+
+ const [documents, countResult] = await Promise.all([
+ documentsQuery.execute(),
+ countQuery.execute(),
+ ]);
+
+ const count = Number((countResult[0] as { count: number })?.count || 0);
+
+ return {
+ teams: [],
+ users: [],
+ documents: documents.map((doc) => ({
+ ...doc,
+ id: String((doc as { id: number }).id),
+ })) as DocumentInsights[],
+ totalPages: Math.ceil(Number(count) / perPage),
+ };
+}
+
+async function getOrganisationSummary(
+ organisationId: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ dateFilter: any,
+): Promise {
+ const summaryQuery = kyselyPrisma.$kysely
+ .selectFrom('Organisation as o')
+ .leftJoin('Team as t', 'o.id', 't.organisationId')
+ .leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
+ .leftJoin('Document as d', (join) =>
+ join.onRef('t.id', '=', 'd.teamId').on('d.deletedAt', 'is', null),
+ )
+ .where('o.id', '=', organisationId)
+ .select([
+ sql`COUNT(DISTINCT t.id)`.as('totalTeams'),
+ sql`COUNT(DISTINCT om."userId")`.as('totalMembers'),
+ sql`COUNT(DISTINCT d.id)`.as('totalDocuments'),
+ sql`COUNT(DISTINCT CASE WHEN d.status IN ('DRAFT', 'PENDING') THEN d.id END)`.as(
+ 'activeDocuments',
+ ),
+ sql`COUNT(DISTINCT CASE WHEN d.status = 'COMPLETED' THEN d.id END)`.as(
+ 'completedDocuments',
+ ),
+ sql`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND d.status = 'COMPLETED' ${dateFilter} THEN d.id END)`.as(
+ 'volumeThisPeriod',
+ ),
+ sql`COUNT(DISTINCT CASE WHEN d.status = 'COMPLETED' THEN d.id END)`.as(
+ 'volumeAllTime',
+ ),
+ ]);
+
+ const result = await summaryQuery.executeTakeFirst();
+
+ return {
+ totalTeams: Math.max(Number(result?.totalTeams || 0), 1),
+ totalMembers: Number(result?.totalMembers || 0),
+ totalDocuments: Number(result?.totalDocuments || 0),
+ activeDocuments: Number(result?.activeDocuments || 0),
+ completedDocuments: Number(result?.completedDocuments || 0),
+ volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
+ volumeAllTime: Number(result?.volumeAllTime || 0),
+ };
+}
diff --git a/packages/lib/server-only/admin/get-signing-volume.ts b/packages/lib/server-only/admin/get-signing-volume.ts
index 8ed734ecc..2a2f877d7 100644
--- a/packages/lib/server-only/admin/get-signing-volume.ts
+++ b/packages/lib/server-only/admin/get-signing-volume.ts
@@ -1,13 +1,16 @@
-import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
+import { DocumentStatus } from '@prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma';
-export type SigningVolume = {
+export type OrganisationInsights = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
- planId: string;
+ customerId: string | null;
+ subscriptionStatus?: string;
+ teamCount?: number;
+ memberCount?: number;
};
export type GetSigningVolumeOptions = {
@@ -28,8 +31,7 @@ export async function getSigningVolume({
const offset = Math.max(page - 1, 0) * perPage;
let findQuery = kyselyPrisma.$kysely
- .selectFrom('Subscription as s')
- .innerJoin('Organisation as o', 's.organisationId', 'o.id')
+ .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Document as d', (join) =>
join
@@ -37,18 +39,17 @@ export async function getSigningVolume({
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null),
)
- .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
- 's.id as id',
- 's.createdAt as createdAt',
- 's.planId as planId',
+ 'o.id as id',
+ 'o.createdAt as createdAt',
+ 'o.customerId as customerId',
sql`COALESCE(o.name, 'Unknown')`.as('name'),
sql`COUNT(DISTINCT d.id)`.as('signingVolume'),
])
- .groupBy(['s.id', 'o.name']);
+ .groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) {
case 'name':
@@ -67,10 +68,8 @@ export async function getSigningVolume({
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
- .selectFrom('Subscription as s')
- .innerJoin('Organisation as o', 's.organisationId', 'o.id')
+ .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
- .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
@@ -79,7 +78,116 @@ export async function getSigningVolume({
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
- leaderboard: results,
+ organisations: results,
+ totalPages: Math.ceil(Number(count) / perPage),
+ };
+}
+
+export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
+ dateRange?: 'last30days' | 'last90days' | 'lastYear' | 'allTime';
+ startDate?: Date;
+ endDate?: Date;
+};
+
+export async function getOrganisationInsights({
+ search = '',
+ page = 1,
+ perPage = 10,
+ sortBy = 'signingVolume',
+ sortOrder = 'desc',
+ dateRange = 'last30days',
+ startDate,
+ endDate,
+}: GetOrganisationInsightsOptions) {
+ const offset = Math.max(page - 1, 0) * perPage;
+
+ const now = new Date();
+ let dateCondition = sql`1=1`;
+
+ if (startDate && endDate) {
+ dateCondition = sql`d."createdAt" >= ${startDate} AND d."createdAt" <= ${endDate}`;
+ } else {
+ switch (dateRange) {
+ case 'last30days': {
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ dateCondition = sql`d."createdAt" >= ${thirtyDaysAgo}`;
+ break;
+ }
+ case 'last90days': {
+ const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ dateCondition = sql`d."createdAt" >= ${ninetyDaysAgo}`;
+ break;
+ }
+ case 'lastYear': {
+ const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
+ dateCondition = sql`d."createdAt" >= ${oneYearAgo}`;
+ break;
+ }
+ case 'allTime':
+ default:
+ dateCondition = sql`1=1`;
+ break;
+ }
+ }
+
+ let findQuery = kyselyPrisma.$kysely
+ .selectFrom('Organisation as o')
+ .leftJoin('Team as t', 'o.id', 't.organisationId')
+ .leftJoin('Document as d', (join) =>
+ join
+ .onRef('t.id', '=', 'd.teamId')
+ .on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
+ .on('d.deletedAt', 'is', null),
+ )
+ .leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
+ .leftJoin('Subscription as s', 'o.id', 's.organisationId')
+ .where((eb) =>
+ eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
+ )
+ .select([
+ 'o.id as id',
+ 'o.createdAt as createdAt',
+ 'o.customerId as customerId',
+ sql`COALESCE(o.name, 'Unknown')`.as('name'),
+ sql`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND ${dateCondition} THEN d.id END)`.as(
+ 'signingVolume',
+ ),
+ sql`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
+ sql`COUNT(DISTINCT om."userId")`.as('memberCount'),
+ sql`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
+ 'subscriptionStatus',
+ ),
+ ])
+ .groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
+
+ switch (sortBy) {
+ case 'name':
+ findQuery = findQuery.orderBy('name', sortOrder);
+ break;
+ case 'createdAt':
+ findQuery = findQuery.orderBy('createdAt', sortOrder);
+ break;
+ case 'signingVolume':
+ findQuery = findQuery.orderBy('signingVolume', sortOrder);
+ break;
+ default:
+ findQuery = findQuery.orderBy('signingVolume', 'desc');
+ }
+
+ findQuery = findQuery.limit(perPage).offset(offset);
+
+ const countQuery = kyselyPrisma.$kysely
+ .selectFrom('Organisation as o')
+ .leftJoin('Team as t', 'o.id', 't.organisationId')
+ .where((eb) =>
+ eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
+ )
+ .select(({ fn }) => [fn.countAll().as('count')]);
+
+ const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
+
+ return {
+ organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}