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 = ({
{row.getValue('name')}
); }, - 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), }; }