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..aa79774bd
--- /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 type { DateRange } from '@documenso/lib/types/search-params';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+
+type DateRangeFilterProps = {
+ currentRange: DateRange;
+};
+
+export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
+ const { _ } = useLingui();
+ const [isPending, startTransition] = useTransition();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const handleRangeChange = (value: string) => {
+ startTransition(() => {
+ updateSearchParams({
+ dateRange: value as DateRange,
+ page: 1,
+ });
+ });
+ };
+
+ return (
+
+
+
+ );
+};
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 74%
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..eb38e04f4 100644
--- a/apps/remix/app/components/tables/admin-leaderboard-table.tsx
+++ b/apps/remix/app/components/tables/admin-organisation-overview-table.tsx
@@ -2,40 +2,49 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
+import { Trans } from '@lingui/react/macro';
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
+import { Link } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import type { DateRange } from '@documenso/lib/types/search-params';
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 { Input } from '@documenso/ui/primitives/input';
-export type SigningVolume = {
- id: number;
+export type OrganisationOverview = {
+ id: string;
name: string;
signingVolume: number;
createdAt: Date;
- planId: string;
+ customerId: string;
+ subscriptionStatus?: string;
+ isActive?: boolean;
+ teamCount?: number;
+ memberCount?: number;
};
-type LeaderboardTableProps = {
- signingVolume: SigningVolume[];
+type OrganisationOverviewTableProps = {
+ organisations: OrganisationOverview[];
totalPages: number;
perPage: number;
page: number;
sortBy: 'name' | 'createdAt' | 'signingVolume';
sortOrder: 'asc' | 'desc';
+ dateRange: DateRange;
};
-export const AdminLeaderboardTable = ({
- signingVolume,
+export const AdminOrganisationOverviewTable = ({
+ organisations,
totalPages,
perPage,
page,
sortBy,
sortOrder,
-}: LeaderboardTableProps) => {
+ dateRange,
+}: OrganisationOverviewTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
@@ -67,17 +76,16 @@ export const AdminLeaderboardTable = ({
cell: ({ row }) => {
return (
);
},
- size: 250,
+ size: 240,
},
{
header: () => (
@@ -85,7 +93,9 @@ export const AdminLeaderboardTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')}
>
- {_(msg`Signing Volume`)}
+
+ Document Volume
+
{sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
@@ -99,6 +109,23 @@ export const AdminLeaderboardTable = ({
),
accessorKey: 'signingVolume',
cell: ({ row }) => {Number(row.getValue('signingVolume'))}
,
+ size: 160,
+ },
+ {
+ header: () => {
+ return Teams;
+ },
+ accessorKey: 'teamCount',
+ cell: ({ row }) => {Number(row.original.teamCount) || 0}
,
+ size: 120,
+ },
+ {
+ header: () => {
+ return Members;
+ },
+ accessorKey: 'memberCount',
+ cell: ({ row }) => {Number(row.original.memberCount) || 0}
,
+ size: 160,
},
{
header: () => {
@@ -107,7 +134,9 @@ export const AdminLeaderboardTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('createdAt')}
>
- {_(msg`Created`)}
+
+ Created
+
{sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
@@ -121,10 +150,11 @@ export const AdminLeaderboardTable = ({
);
},
accessorKey: 'createdAt',
- cell: ({ row }) => i18n.date(row.original.createdAt),
+ cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
+ size: 120,
},
- ] satisfies DataTableColumnDef[];
- }, [sortOrder, sortBy]);
+ ] satisfies DataTableColumnDef[];
+ }, [sortOrder, sortBy, dateRange]);
useEffect(() => {
startTransition(() => {
@@ -169,13 +199,13 @@ export const AdminLeaderboardTable = ({
(
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
),
},
+ {
+ id: 'billingStatus',
+ header: t`Status`,
+ cell: ({ row }) => {
+ const subscription = row.original.subscription;
+ const isPaid = subscription && subscription.status === 'ACTIVE';
+ return (
+
+ {isPaid ? t`Paid` : t`Free`}
+
+ );
+ },
+ },
{
header: t`Subscription`,
cell: ({ row }) =>
@@ -168,7 +186,7 @@ export const AdminOrganisationsTable = ({
onPaginationChange={onPaginationChange}
columnVisibility={{
owner: showOwnerColumn,
- status: memberUserId !== undefined,
+ role: memberUserId !== undefined,
}}
error={{
enable: isLoadingError,
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..6304e0b50
--- /dev/null
+++ b/apps/remix/app/components/tables/organisation-insights-table.tsx
@@ -0,0 +1,287 @@
+import { useTransition } from 'react';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
+import { Link } from 'react-router';
+import { useNavigation } from 'react-router';
+
+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 type { DateRange } from '@documenso/lib/types/search-params';
+import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
+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';
+import { DocumentStatus } from '~/components/general/document/document-status';
+
+type OrganisationInsightsTableProps = {
+ insights: OrganisationDetailedInsights;
+ page: number;
+ perPage: number;
+ dateRange: DateRange;
+ view: 'teams' | 'users' | 'documents';
+};
+
+export const OrganisationInsightsTable = ({
+ insights,
+ page,
+ perPage,
+ dateRange,
+ view,
+}: OrganisationInsightsTableProps) => {
+ const { _, i18n } = useLingui();
+ const [isPending, startTransition] = useTransition();
+ const navigation = useNavigation();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const isLoading = isPending || navigation.state === 'loading';
+
+ 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')},
+ size: 240,
+ },
+ {
+ header: _(msg`Members`),
+ accessorKey: 'memberCount',
+ cell: ({ row }) => Number(row.getValue('memberCount')),
+ size: 120,
+ },
+ {
+ header: _(msg`Documents`),
+ accessorKey: 'documentCount',
+ cell: ({ row }) => Number(row.getValue('documentCount')),
+ size: 140,
+ },
+ {
+ header: _(msg`Created`),
+ accessorKey: 'createdAt',
+ cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
+ size: 160,
+ },
+ ] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
+
+ const usersColumns = [
+ {
+ header: () => {_(msg`Name`)},
+ accessorKey: 'name',
+ cell: ({ row }) => (
+
+ {(row.getValue('name') as string) || (row.getValue('email') as string)}
+
+ ),
+ size: 220,
+ },
+ {
+ header: () => {_(msg`Email`)},
+ accessorKey: 'email',
+ cell: ({ row }) => {row.getValue('email')},
+ size: 260,
+ },
+ {
+ header: () => {_(msg`Documents Created`)},
+ accessorKey: 'documentCount',
+ cell: ({ row }) => Number(row.getValue('documentCount')),
+ size: 180,
+ },
+ {
+ header: () => {_(msg`Documents Completed`)},
+ accessorKey: 'signedDocumentCount',
+ cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
+ size: 180,
+ },
+ {
+ header: () => {_(msg`Joined`)},
+ accessorKey: 'createdAt',
+ cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
+ size: 160,
+ },
+ ] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
+
+ const documentsColumns = [
+ {
+ header: () => {_(msg`Title`)},
+ accessorKey: 'title',
+ cell: ({ row }) => (
+
+ {row.getValue('title')}
+
+ ),
+ size: 200,
+ },
+ {
+ header: () => {_(msg`Status`)},
+ accessorKey: 'status',
+ cell: ({ row }) => (
+
+ ),
+ size: 120,
+ },
+ {
+ header: () => {_(msg`Team`)},
+ accessorKey: 'teamName',
+ cell: ({ row }) => (
+
+ {row.getValue('teamName')}
+
+ ),
+ size: 150,
+ },
+ {
+ header: () => {_(msg`Created`)},
+ accessorKey: 'createdAt',
+ cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
+ size: 140,
+ },
+ {
+ header: () => {_(msg`Completed`)},
+ accessorKey: 'completedAt',
+ cell: ({ row }) => {
+ const completedAt = row.getValue('completedAt') as Date | null;
+
+ return completedAt ? i18n.date(new Date(completedAt)) : '-';
+ },
+ size: 140,
+ },
+ ] 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 [];
+ }
+ };
+
+ return (
+
+ {insights.summary && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ columns={getCurrentColumns()}
+ data={getCurrentData()}
+ perPage={perPage}
+ currentPage={page}
+ totalPages={insights.totalPages}
+ onPaginationChange={onPaginationChange}
+ >
+ {(table) => }
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ );
+};
+
+const SummaryCard = ({
+ icon: Icon,
+ title,
+ value,
+ subtitle,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ title: string;
+ value: number;
+ subtitle?: string;
+}) => (
+
+
+
+
{title}
+
{value}
+ {subtitle &&
{subtitle}
}
+
+
+);
diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
index 18de42b01..33ef772f5 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/organisation-insights') && 'bg-secondary',
)}
asChild
>
-
+
- Leaderboard
+ Organisation Insights
@@ -128,7 +128,7 @@ export default function AdminLayout() {
variant="ghost"
className={cn(
'justify-start md:w-full',
- pathname?.startsWith('/admin/banner') && 'bg-secondary',
+ pathname?.startsWith('/admin/site-settings') && 'bg-secondary',
)}
asChild
>
diff --git a/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx b/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
deleted file mode 100644
index 2a6857e5e..000000000
--- a/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Trans } from '@lingui/react/macro';
-
-import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
-
-import {
- AdminLeaderboardTable,
- type SigningVolume,
-} from '~/components/tables/admin-leaderboard-table';
-
-import type { Route } from './+types/leaderboard';
-
-export async function loader({ request }: Route.LoaderArgs) {
- const url = new URL(request.url);
-
- const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
- const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
-
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
- | 'asc'
- | 'desc';
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- const sortBy = (
- ['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
- ) as 'name' | 'createdAt' | 'signingVolume';
-
- const page = Number(url.searchParams.get('page')) || 1;
- const perPage = Number(url.searchParams.get('perPage')) || 10;
- const search = url.searchParams.get('search') || '';
-
- const { leaderboard, totalPages } = await getSigningVolume({
- search,
- page,
- perPage,
- sortBy,
- sortOrder,
- });
-
- const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
- ...item,
- name: item.name || '',
- createdAt: item.createdAt || new Date(),
- }));
-
- return {
- signingVolume: typedSigningVolume,
- totalPages,
- page,
- perPage,
- sortBy,
- sortOrder,
- };
-}
-
-export default function Leaderboard({ loaderData }: Route.ComponentProps) {
- const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
-
- return (
-
-
-
- Signing Volume
-
-
-
-
- );
-}
diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx
new file mode 100644
index 000000000..560c25c3b
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx
@@ -0,0 +1,59 @@
+import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
+import type { DateRange } from '@documenso/lib/types/search-params';
+import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
+
+import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
+
+import type { Route } from './+types/organisation-insights.$id';
+
+export async function loader({ params, request }: Route.LoaderArgs) {
+ const { id } = params;
+ const url = new URL(request.url);
+
+ const page = Number(url.searchParams.get('page')) || 1;
+ const perPage = Number(url.searchParams.get('perPage')) || 10;
+ const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
+ const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
+
+ const [insights, organisation] = await Promise.all([
+ getOrganisationDetailedInsights({
+ organisationId: id,
+ page,
+ perPage,
+ dateRange,
+ view,
+ }),
+ getAdminOrganisation({ organisationId: id }),
+ ]);
+
+ return {
+ organisationId: id,
+ organisationName: organisation.name,
+ insights,
+ page,
+ perPage,
+ dateRange,
+ view,
+ };
+}
+
+export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
+ const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
+
+ return (
+
+
+
{organisationName}
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisation-insights._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights._index.tsx
new file mode 100644
index 000000000..ed2908be6
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights._index.tsx
@@ -0,0 +1,91 @@
+import { Trans } from '@lingui/react/macro';
+
+import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
+import type { DateRange } from '@documenso/lib/types/search-params';
+
+import { DateRangeFilter } from '~/components/filters/date-range-filter';
+import {
+ AdminOrganisationOverviewTable,
+ type OrganisationOverview,
+} from '~/components/tables/admin-organisation-overview-table';
+
+import type { Route } from './+types/organisation-insights._index';
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const url = new URL(request.url);
+
+ const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
+ const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
+
+ const isSortOrder = (value: string): value is 'asc' | 'desc' =>
+ value === 'asc' || value === 'desc';
+ const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
+ value === 'name' || value === 'createdAt' || value === 'signingVolume';
+
+ const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
+ const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
+ ? rawSortBy
+ : 'signingVolume';
+
+ 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 DateRange;
+
+ const { organisations, totalPages } = await getOrganisationInsights({
+ search,
+ page,
+ perPage,
+ sortBy,
+ sortOrder,
+ dateRange,
+ });
+
+ const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
+ id: String(item.id),
+ name: item.name || '',
+ signingVolume: item.signingVolume,
+ createdAt: item.createdAt || new Date(),
+ customerId: item.customerId || '',
+ subscriptionStatus: item.subscriptionStatus,
+ teamCount: item.teamCount || 0,
+ memberCount: item.memberCount || 0,
+ }));
+
+ return {
+ organisations: typedOrganisations,
+ totalPages,
+ page,
+ perPage,
+ sortBy,
+ sortOrder,
+ dateRange,
+ };
+}
+
+export default function Organisations({ loaderData }: Route.ComponentProps) {
+ const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
+
+ return (
+
+
+
+ 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..342fa6b8e
--- /dev/null
+++ b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts
@@ -0,0 +1,363 @@
+import type { DocumentStatus } from '@prisma/client';
+import { EnvelopeType } from '@prisma/client';
+
+import type { DateRange } from '@documenso/lib/types/search-params';
+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?: DateRange;
+ 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;
+
+ const now = new Date();
+ let createdAtFrom: Date | null = null;
+
+ switch (dateRange) {
+ case 'last30days': {
+ createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case 'last90days': {
+ createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case 'lastYear': {
+ createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
+ break;
+ }
+ case 'allTime':
+ default:
+ createdAtFrom = null;
+ break;
+ }
+
+ const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
+
+ const viewData = await (async () => {
+ switch (view) {
+ case 'teams':
+ return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
+ case 'users':
+ return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
+ case 'documents':
+ return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
+ default:
+ throw new Error(`Invalid view: ${view}`);
+ }
+ })();
+
+ return {
+ ...viewData,
+ summary: summaryData,
+ };
+}
+
+async function getTeamInsights(
+ organisationId: string,
+ offset: number,
+ perPage: number,
+ createdAtFrom: Date | null,
+): Promise {
+ const teamsQuery = kyselyPrisma.$kysely
+ .selectFrom('Team as t')
+ .leftJoin('Envelope as e', (join) =>
+ join
+ .onRef('t.id', '=', 'e.teamId')
+ .on('e.deletedAt', 'is', null)
+ .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
+ )
+ .leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
+ .leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
+ .leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
+ .leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
+ .where('t.organisationId', '=', organisationId)
+ .select([
+ 't.id as id',
+ 't.name as name',
+ 't.createdAt as createdAt',
+ sql`COUNT(DISTINCT om."userId")`.as('memberCount'),
+ (createdAtFrom
+ ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
+ : sql`COUNT(DISTINCT e.id)`
+ ).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,
+ createdAtFrom: Date | null,
+): Promise {
+ const usersBase = kyselyPrisma.$kysely
+ .selectFrom('OrganisationMember as om')
+ .innerJoin('User as u', 'u.id', 'om.userId')
+ .where('om.organisationId', '=', organisationId)
+ .leftJoin('Envelope as e', (join) =>
+ join
+ .onRef('e.userId', '=', 'u.id')
+ .on('e.deletedAt', 'is', null)
+ .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
+ )
+ .leftJoin('Team as td', (join) =>
+ join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
+ )
+ .leftJoin('Recipient as r', (join) =>
+ join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
+ )
+ .leftJoin('Envelope as se', (join) =>
+ join
+ .onRef('se.id', '=', 'r.envelopeId')
+ .on('se.deletedAt', 'is', null)
+ .on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
+ )
+ .leftJoin('Team as ts', (join) =>
+ join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
+ );
+
+ const usersQuery = usersBase
+ .select([
+ 'u.id as id',
+ 'u.name as name',
+ 'u.email as email',
+ 'u.createdAt as createdAt',
+ (createdAtFrom
+ ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
+ : sql`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
+ ).as('documentCount'),
+ (createdAtFrom
+ ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
+ : sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
+ ).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,
+ createdAtFrom: Date | null,
+): Promise {
+ let documentsQuery = kyselyPrisma.$kysely
+ .selectFrom('Envelope as e')
+ .innerJoin('Team as t', 'e.teamId', 't.id')
+ .where('t.organisationId', '=', organisationId)
+ .where('e.deletedAt', 'is', null)
+ .where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
+
+ if (createdAtFrom) {
+ documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom);
+ }
+
+ documentsQuery = documentsQuery
+ .select([
+ 'e.id as id',
+ 'e.title as title',
+ 'e.status as status',
+ 'e.createdAt as createdAt',
+ 'e.completedAt as completedAt',
+ 't.name as teamName',
+ ])
+ .orderBy('e.createdAt', 'desc')
+ .limit(perPage)
+ .offset(offset);
+
+ let countQuery = kyselyPrisma.$kysely
+ .selectFrom('Envelope as e')
+ .innerJoin('Team as t', 'e.teamId', 't.id')
+ .where('t.organisationId', '=', organisationId)
+ .where('e.deletedAt', 'is', null)
+ .where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
+
+ if (createdAtFrom) {
+ countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom);
+ }
+
+ 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,
+ createdAtFrom: Date | null,
+): Promise {
+ const summaryQuery = kyselyPrisma.$kysely
+ .selectFrom('Organisation as o')
+ .where('o.id', '=', organisationId)
+ .select([
+ sql`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
+ 'totalTeams',
+ ),
+ sql`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
+ 'totalMembers',
+ ),
+ sql`(
+ SELECT COUNT(DISTINCT e2.id)
+ FROM "Envelope" AS e2
+ INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
+ WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
+ )`.as('totalDocuments'),
+ sql`(
+ SELECT COUNT(DISTINCT e2.id)
+ FROM "Envelope" AS e2
+ INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
+ WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
+ )`.as('activeDocuments'),
+ sql`(
+ SELECT COUNT(DISTINCT e2.id)
+ FROM "Envelope" AS e2
+ INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
+ WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
+ )`.as('completedDocuments'),
+ (createdAtFrom
+ ? sql`(
+ SELECT COUNT(DISTINCT e2.id)
+ FROM "Envelope" AS e2
+ INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
+ WHERE t2."organisationId" = o.id
+ AND e2."deletedAt" IS NULL
+ AND e2.type = 'DOCUMENT'
+ AND e2.status = 'COMPLETED'
+ AND e2."createdAt" >= ${createdAtFrom}
+ )`
+ : sql`(
+ SELECT COUNT(DISTINCT e2.id)
+ FROM "Envelope" AS e2
+ INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
+ WHERE t2."organisationId" = o.id
+ AND e2."deletedAt" IS NULL
+ AND e2.type = 'DOCUMENT'
+ AND e2.status = 'COMPLETED'
+ )`
+ ).as('volumeThisPeriod'),
+ sql`(
+ SELECT COUNT(DISTINCT e2.id)
+ FROM "Envelope" AS e2
+ INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
+ WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
+ )`.as('volumeAllTime'),
+ ]);
+
+ const result = await summaryQuery.executeTakeFirst();
+
+ return {
+ totalTeams: Number(result?.totalTeams || 0),
+ 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 ce9352a92..71edce8b5 100644
--- a/packages/lib/server-only/admin/get-signing-volume.ts
+++ b/packages/lib/server-only/admin/get-signing-volume.ts
@@ -1,13 +1,17 @@
-import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
+import { DocumentStatus, EnvelopeType } from '@prisma/client';
+import type { DateRange } from '@documenso/lib/types/search-params';
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,28 +32,26 @@ 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('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
- .on('e.deletedAt', 'is', null),
+ .on('e.deletedAt', 'is', null)
+ .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
- .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
- .where('e.type', '=', EnvelopeType.DOCUMENT)
.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 e.id)`.as('signingVolume'),
])
- .groupBy(['s.id', 'o.name']);
+ .groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) {
case 'name':
@@ -68,19 +70,127 @@ 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}%`)]),
)
- .select(({ fn }) => [fn.countAll().as('count')]);
+ .select(() => [sql`COUNT(DISTINCT o.id)`.as('count')]);
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?: DateRange;
+ 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`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
+ } else {
+ switch (dateRange) {
+ case 'last30days': {
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
+ break;
+ }
+ case 'last90days': {
+ const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
+ break;
+ }
+ case 'lastYear': {
+ const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
+ dateCondition = sql`e."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('Envelope as e', (join) =>
+ join
+ .onRef('t.id', '=', 'e.teamId')
+ .on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
+ .on('e.deletedAt', 'is', null)
+ .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
+ )
+ .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 e.id IS NOT NULL AND ${dateCondition} THEN e.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(() => [sql`COUNT(DISTINCT o.id)`.as('count')]);
+
+ const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
+
+ return {
+ organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}
diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts
index 35a166b95..1293c8a23 100644
--- a/packages/lib/types/search-params.ts
+++ b/packages/lib/types/search-params.ts
@@ -1,5 +1,7 @@
import { z } from 'zod';
+export type DateRange = 'last30days' | 'last90days' | 'lastYear' | 'allTime';
+
/**
* Backend only schema is used for find search params.
*