mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: org insights (#1937)
This commit is contained in:
49
apps/remix/app/components/filters/date-range-filter.tsx
Normal file
49
apps/remix/app/components/filters/date-range-filter.tsx
Normal 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 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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,40 +2,49 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
|
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 { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
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 type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
export type SigningVolume = {
|
export type OrganisationOverview = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
customerId: string;
|
||||||
|
subscriptionStatus?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
teamCount?: number;
|
||||||
|
memberCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardTableProps = {
|
type OrganisationOverviewTableProps = {
|
||||||
signingVolume: SigningVolume[];
|
organisations: OrganisationOverview[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
|
dateRange: DateRange;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminLeaderboardTable = ({
|
export const AdminOrganisationOverviewTable = ({
|
||||||
signingVolume,
|
organisations,
|
||||||
totalPages,
|
totalPages,
|
||||||
perPage,
|
perPage,
|
||||||
page,
|
page,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
}: LeaderboardTableProps) => {
|
dateRange,
|
||||||
|
}: OrganisationOverviewTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@ -67,17 +76,16 @@ export const AdminLeaderboardTable = ({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<a
|
<Link
|
||||||
className="text-primary underline"
|
className="hover:underline"
|
||||||
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
|
to={`/admin/organisation-insights/${row.original.id}?dateRange=${dateRange}`}
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
{row.getValue('name')}
|
{row.getValue('name')}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 250,
|
size: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => (
|
header: () => (
|
||||||
@ -85,7 +93,9 @@ export const AdminLeaderboardTable = ({
|
|||||||
className="flex cursor-pointer items-center"
|
className="flex cursor-pointer items-center"
|
||||||
onClick={() => handleColumnSort('signingVolume')}
|
onClick={() => handleColumnSort('signingVolume')}
|
||||||
>
|
>
|
||||||
{_(msg`Signing Volume`)}
|
<span className="whitespace-nowrap">
|
||||||
|
<Trans>Document Volume</Trans>
|
||||||
|
</span>
|
||||||
{sortBy === 'signingVolume' ? (
|
{sortBy === 'signingVolume' ? (
|
||||||
sortOrder === 'asc' ? (
|
sortOrder === 'asc' ? (
|
||||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||||
@ -99,6 +109,23 @@ export const AdminLeaderboardTable = ({
|
|||||||
),
|
),
|
||||||
accessorKey: 'signingVolume',
|
accessorKey: 'signingVolume',
|
||||||
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
||||||
|
size: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => {
|
||||||
|
return <Trans>Teams</Trans>;
|
||||||
|
},
|
||||||
|
accessorKey: 'teamCount',
|
||||||
|
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => {
|
||||||
|
return <Trans>Members</Trans>;
|
||||||
|
},
|
||||||
|
accessorKey: 'memberCount',
|
||||||
|
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
|
||||||
|
size: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => {
|
header: () => {
|
||||||
@ -107,7 +134,9 @@ export const AdminLeaderboardTable = ({
|
|||||||
className="flex cursor-pointer items-center"
|
className="flex cursor-pointer items-center"
|
||||||
onClick={() => handleColumnSort('createdAt')}
|
onClick={() => handleColumnSort('createdAt')}
|
||||||
>
|
>
|
||||||
{_(msg`Created`)}
|
<span className="whitespace-nowrap">
|
||||||
|
<Trans>Created</Trans>
|
||||||
|
</span>
|
||||||
{sortBy === 'createdAt' ? (
|
{sortBy === 'createdAt' ? (
|
||||||
sortOrder === 'asc' ? (
|
sortOrder === 'asc' ? (
|
||||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||||
@ -121,10 +150,11 @@ export const AdminLeaderboardTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
|
||||||
|
size: 120,
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<SigningVolume>[];
|
] satisfies DataTableColumnDef<OrganisationOverview>[];
|
||||||
}, [sortOrder, sortBy]);
|
}, [sortOrder, sortBy, dateRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@ -169,13 +199,13 @@ export const AdminLeaderboardTable = ({
|
|||||||
<Input
|
<Input
|
||||||
className="my-6 flex flex-row gap-4"
|
className="my-6 flex flex-row gap-4"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={_(msg`Search by name or email`)}
|
placeholder={_(msg`Search by organisation name`)}
|
||||||
value={searchString}
|
value={searchString}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={signingVolume}
|
data={organisations}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
@ -93,13 +93,31 @@ export const AdminOrganisationsTable = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t`Status`,
|
id: 'role',
|
||||||
|
header: t`Role`,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="neutral">
|
<Badge variant="neutral">
|
||||||
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
|
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'billingStatus',
|
||||||
|
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 ? t`Paid` : t`Free`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: t`Subscription`,
|
header: t`Subscription`,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@ -168,7 +186,7 @@ export const AdminOrganisationsTable = ({
|
|||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
owner: showOwnerColumn,
|
owner: showOwnerColumn,
|
||||||
status: memberUserId !== undefined,
|
role: memberUserId !== undefined,
|
||||||
}}
|
}}
|
||||||
error={{
|
error={{
|
||||||
enable: isLoadingError,
|
enable: isLoadingError,
|
||||||
|
|||||||
287
apps/remix/app/components/tables/organisation-insights-table.tsx
Normal file
287
apps/remix/app/components/tables/organisation-insights-table.tsx
Normal file
@ -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 }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
|
||||||
|
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: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
className="block max-w-full truncate hover:underline"
|
||||||
|
to={`/admin/users/${row.original.id}`}
|
||||||
|
>
|
||||||
|
{(row.getValue('name') as string) || (row.getValue('email') as string)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
size: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
|
||||||
|
size: 260,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
|
||||||
|
accessorKey: 'documentCount',
|
||||||
|
cell: ({ row }) => Number(row.getValue('documentCount')),
|
||||||
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
|
||||||
|
accessorKey: 'signedDocumentCount',
|
||||||
|
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
|
||||||
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
||||||
|
size: 160,
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
|
||||||
|
|
||||||
|
const documentsColumns = [
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
|
||||||
|
accessorKey: 'title',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
className="block max-w-[200px] truncate hover:underline"
|
||||||
|
to={`/admin/documents/${row.original.id}`}
|
||||||
|
title={row.getValue('title') as string}
|
||||||
|
>
|
||||||
|
{row.getValue('title')}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
size: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
|
||||||
|
),
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
|
||||||
|
accessorKey: 'teamName',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
|
||||||
|
{row.getValue('teamName')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
||||||
|
size: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
|
||||||
|
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<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 [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{insights.summary && (
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
<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`Documents Completed`)}
|
||||||
|
value={insights.summary.volumeThisPeriod}
|
||||||
|
/>
|
||||||
|
</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={isLoading}
|
||||||
|
>
|
||||||
|
{_(msg`Teams`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'users' ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleViewChange('users')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{_(msg`Users`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'documents' ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleViewChange('documents')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{_(msg`Documents`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DateRangeFilter currentRange={dateRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
|
||||||
|
<DataTable<unknown, unknown>
|
||||||
|
columns={getCurrentColumns()}
|
||||||
|
data={getCurrentData()}
|
||||||
|
perPage={perPage}
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={insights.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SummaryCard = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
subtitle?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
|
||||||
|
<Icon className="text-muted-foreground h-4 w-4 items-start" />
|
||||||
|
<div className="-mt-0.5 space-y-2">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
@ -114,13 +114,13 @@ export default function AdminLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to="/admin/leaderboard">
|
<Link to="/admin/organisation-insights">
|
||||||
<Trophy className="mr-2 h-5 w-5" />
|
<Trophy className="mr-2 h-5 w-5" />
|
||||||
<Trans>Leaderboard</Trans>
|
<Trans>Organisation Insights</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ export default function AdminLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
pathname?.startsWith('/admin/site-settings') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h2 className="text-4xl font-semibold">
|
|
||||||
<Trans>Signing Volume</Trans>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<AdminLeaderboardTable
|
|
||||||
signingVolume={signingVolume}
|
|
||||||
totalPages={totalPages}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
sortBy={sortBy}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-4xl font-semibold">{organisationName}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<OrganisationInsightsTable
|
||||||
|
insights={insights}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
dateRange={dateRange}
|
||||||
|
view={view}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Organisation Insights</Trans>
|
||||||
|
</h2>
|
||||||
|
<DateRangeFilter currentRange={dateRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<AdminOrganisationOverviewTable
|
||||||
|
organisations={organisations}
|
||||||
|
totalPages={totalPages}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<OrganisationDetailedInsights> {
|
||||||
|
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<OrganisationDetailedInsights> {
|
||||||
|
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<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||||
|
(createdAtFrom
|
||||||
|
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||||
|
: sql<number>`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<OrganisationDetailedInsights> {
|
||||||
|
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<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||||
|
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
|
||||||
|
).as('documentCount'),
|
||||||
|
(createdAtFrom
|
||||||
|
? sql<number>`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<number>`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<OrganisationDetailedInsights> {
|
||||||
|
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<OrganisationSummary> {
|
||||||
|
const summaryQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Organisation as o')
|
||||||
|
.where('o.id', '=', organisationId)
|
||||||
|
.select([
|
||||||
|
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
||||||
|
'totalTeams',
|
||||||
|
),
|
||||||
|
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
||||||
|
'totalMembers',
|
||||||
|
),
|
||||||
|
sql<number>`(
|
||||||
|
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<number>`(
|
||||||
|
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<number>`(
|
||||||
|
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<number>`(
|
||||||
|
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<number>`(
|
||||||
|
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<number>`(
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
export type SigningVolume = {
|
export type OrganisationInsights = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
customerId: string | null;
|
||||||
|
subscriptionStatus?: string;
|
||||||
|
teamCount?: number;
|
||||||
|
memberCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSigningVolumeOptions = {
|
export type GetSigningVolumeOptions = {
|
||||||
@ -28,28 +32,26 @@ export async function getSigningVolume({
|
|||||||
const offset = Math.max(page - 1, 0) * perPage;
|
const offset = Math.max(page - 1, 0) * perPage;
|
||||||
|
|
||||||
let findQuery = kyselyPrisma.$kysely
|
let findQuery = kyselyPrisma.$kysely
|
||||||
.selectFrom('Subscription as s')
|
.selectFrom('Organisation as o')
|
||||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
|
||||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||||
.leftJoin('Envelope as e', (join) =>
|
.leftJoin('Envelope as e', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('t.id', '=', 'e.teamId')
|
.onRef('t.id', '=', 'e.teamId')
|
||||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
.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) =>
|
.where((eb) =>
|
||||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||||
)
|
)
|
||||||
.where('e.type', '=', EnvelopeType.DOCUMENT)
|
|
||||||
.select([
|
.select([
|
||||||
's.id as id',
|
'o.id as id',
|
||||||
's.createdAt as createdAt',
|
'o.createdAt as createdAt',
|
||||||
's.planId as planId',
|
'o.customerId as customerId',
|
||||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||||
])
|
])
|
||||||
.groupBy(['s.id', 'o.name']);
|
.groupBy(['o.id', 'o.name', 'o.customerId']);
|
||||||
|
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'name':
|
case 'name':
|
||||||
@ -68,19 +70,127 @@ export async function getSigningVolume({
|
|||||||
findQuery = findQuery.limit(perPage).offset(offset);
|
findQuery = findQuery.limit(perPage).offset(offset);
|
||||||
|
|
||||||
const countQuery = kyselyPrisma.$kysely
|
const countQuery = kyselyPrisma.$kysely
|
||||||
.selectFrom('Subscription as s')
|
.selectFrom('Organisation as o')
|
||||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
|
||||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||||
)
|
)
|
||||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||||
|
|
||||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||||
|
|
||||||
return {
|
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<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||||
|
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
|
||||||
|
'signingVolume',
|
||||||
|
),
|
||||||
|
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
|
||||||
|
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||||
|
sql<string>`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<number>`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),
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export type DateRange = 'last30days' | 'last90days' | 'lastYear' | 'allTime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend only schema is used for find search params.
|
* Backend only schema is used for find search params.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user