chore: minor changes

This commit is contained in:
Ephraim Atta-Duncan
2025-08-14 10:17:45 +00:00
parent 28f1948a5e
commit 020c0497ba
11 changed files with 187 additions and 141 deletions

View File

@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
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 { import {
Select, Select,
SelectContent, SelectContent,
@ -13,7 +14,7 @@ import {
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
type DateRangeFilterProps = { type DateRangeFilterProps = {
currentRange: string; currentRange: DateRange;
}; };
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => { export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
@ -24,7 +25,7 @@ export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
const handleRangeChange = (value: string) => { const handleRangeChange = (value: string) => {
startTransition(() => { startTransition(() => {
updateSearchParams({ updateSearchParams({
dateRange: value, dateRange: value as DateRange,
page: 1, page: 1,
}); });
}); });
@ -32,7 +33,6 @@ export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{_(msg`Time Range:`)}</span>
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}> <Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
<SelectTrigger className="w-48"> <SelectTrigger className="w-48">
<SelectValue /> <SelectValue />

View File

@ -3,6 +3,7 @@ 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 { 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';
@ -16,7 +17,7 @@ export type OrganisationOverview = {
name: string; name: string;
signingVolume: number; signingVolume: number;
createdAt: Date; createdAt: Date;
planId: string; customerId: string;
subscriptionStatus?: string; subscriptionStatus?: string;
isActive?: boolean; isActive?: boolean;
teamCount?: number; teamCount?: number;
@ -71,16 +72,16 @@ export const AdminOrganisationOverviewTable = ({
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div> <div>
<a <Link
className="text-primary underline" className="text-primary underline"
href={`/admin/organisation-insights/${row.original.id}`} to={`/admin/organisation-insights/${row.original.id}`}
> >
{row.getValue('name')} {row.getValue('name')}
</a> </Link>
</div> </div>
); );
}, },
size: 200, size: 240,
}, },
{ {
header: () => ( header: () => (
@ -88,7 +89,7 @@ export const AdminOrganisationOverviewTable = ({
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')} onClick={() => handleColumnSort('signingVolume')}
> >
{_(msg`Document Volume`)} <span className="whitespace-nowrap">{_(msg`Document Volume`)}</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" />
@ -102,26 +103,7 @@ export const AdminOrganisationOverviewTable = ({
), ),
accessorKey: 'signingVolume', accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>, cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
size: 120, size: 160,
},
{
header: () => {
return <div>{_(msg`Status`)}</div>;
},
accessorKey: 'subscriptionStatus',
cell: ({ row }) => {
const status = row.original.subscriptionStatus;
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
status ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{status || 'Free'}
</div>
);
},
size: 100,
}, },
{ {
header: () => { header: () => {
@ -129,7 +111,7 @@ export const AdminOrganisationOverviewTable = ({
}, },
accessorKey: 'teamCount', accessorKey: 'teamCount',
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>, cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
size: 80, size: 120,
}, },
{ {
header: () => { header: () => {
@ -137,7 +119,7 @@ export const AdminOrganisationOverviewTable = ({
}, },
accessorKey: 'memberCount', accessorKey: 'memberCount',
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>, cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
size: 80, size: 160,
}, },
{ {
header: () => { header: () => {
@ -146,7 +128,7 @@ export const AdminOrganisationOverviewTable = ({
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('createdAt')} onClick={() => handleColumnSort('createdAt')}
> >
{_(msg`Created`)} <span className="whitespace-nowrap">{_(msg`Created`)}</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" />
@ -160,7 +142,7 @@ export const AdminOrganisationOverviewTable = ({
); );
}, },
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt), cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
size: 120, size: 120,
}, },
] satisfies DataTableColumnDef<OrganisationOverview>[]; ] satisfies DataTableColumnDef<OrganisationOverview>[];

View File

@ -93,7 +93,8 @@ 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`}
@ -101,6 +102,7 @@ export const AdminOrganisationsTable = ({
), ),
}, },
{ {
id: 'billingStatus',
header: t`Status`, header: t`Status`,
cell: ({ row }) => { cell: ({ row }) => {
const subscription = row.original.subscription; const subscription = row.original.subscription;
@ -111,7 +113,7 @@ export const AdminOrganisationsTable = ({
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`} }`}
> >
{isPaid ? 'Paid' : 'Free'} {isPaid ? t`Paid` : t`Free`}
</div> </div>
); );
}, },
@ -184,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,

View File

@ -6,18 +6,21 @@ import { Archive, Building2, Loader, TrendingUp, Users } from 'lucide-react';
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 { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights'; 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 { Button } from '@documenso/ui/primitives/button';
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 { DateRangeFilter } from '~/components/filters/date-range-filter'; import { DateRangeFilter } from '~/components/filters/date-range-filter';
import { DocumentStatus } from '~/components/general/document/document-status';
type OrganisationInsightsTableProps = { type OrganisationInsightsTableProps = {
insights: OrganisationDetailedInsights; insights: OrganisationDetailedInsights;
page: number; page: number;
perPage: number; perPage: number;
dateRange: string; dateRange: DateRange;
view: 'teams' | 'users' | 'documents'; view: 'teams' | 'users' | 'documents';
}; };
@ -54,82 +57,104 @@ export const OrganisationInsightsTable = ({
{ {
header: _(msg`Team Name`), header: _(msg`Team Name`),
accessorKey: 'name', accessorKey: 'name',
cell: ({ row }) => row.getValue('name'), cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
size: 240,
}, },
{ {
header: _(msg`Members`), header: _(msg`Members`),
accessorKey: 'memberCount', accessorKey: 'memberCount',
cell: ({ row }) => Number(row.getValue('memberCount')), cell: ({ row }) => Number(row.getValue('memberCount')),
size: 120,
}, },
{ {
header: _(msg`Documents`), header: _(msg`Documents`),
accessorKey: 'documentCount', accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')), cell: ({ row }) => Number(row.getValue('documentCount')),
size: 140,
}, },
{ {
header: _(msg`Created`), header: _(msg`Created`),
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')), cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
}, },
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[]; ] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
const usersColumns = [ const usersColumns = [
{ {
header: _(msg`Name`), header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
accessorKey: 'name', accessorKey: 'name',
cell: ({ row }) => row.getValue('name') || row.getValue('email'), cell: ({ row }) => (
<span className="block max-w-full truncate">
{(row.getValue('name') as string) || (row.getValue('email') as string)}
</span>
),
size: 220,
}, },
{ {
header: _(msg`Email`), header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
accessorKey: 'email', accessorKey: 'email',
cell: ({ row }) => row.getValue('email'), cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
size: 260,
}, },
{ {
header: _(msg`Documents Created`), header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
accessorKey: 'documentCount', accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')), cell: ({ row }) => Number(row.getValue('documentCount')),
size: 180,
}, },
{ {
header: _(msg`Documents Signed`), header: () => <span className="whitespace-nowrap">{_(msg`Documents Signed`)}</span>,
accessorKey: 'signedDocumentCount', accessorKey: 'signedDocumentCount',
cell: ({ row }) => Number(row.getValue('signedDocumentCount')), cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
size: 180,
}, },
{ {
header: _(msg`Joined`), header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')), cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
}, },
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[]; ] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
const documentsColumns = [ const documentsColumns = [
{ {
header: _(msg`Title`), header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
accessorKey: 'title', accessorKey: 'title',
cell: ({ row }) => row.getValue('title'), cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('title')}</span>,
size: 240,
}, },
{ {
header: _(msg`Status`), header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => row.getValue('status'), cell: ({ row }) => (
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
),
size: 140,
}, },
{ {
header: _(msg`Team`), header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
accessorKey: 'teamName', accessorKey: 'teamName',
cell: ({ row }) => row.getValue('teamName'), cell: ({ row }) => (
<span className="block max-w-full truncate">{row.getValue('teamName')}</span>
),
size: 180,
}, },
{ {
header: _(msg`Created`), header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')), cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
}, },
{ {
header: _(msg`Completed`), header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
accessorKey: 'completedAt', accessorKey: 'completedAt',
cell: ({ row }) => { cell: ({ row }) => {
const completedAt = row.getValue('completedAt') as Date | null; const completedAt = row.getValue('completedAt') as Date | null;
return completedAt ? i18n.date(completedAt) : '-'; return completedAt ? i18n.date(new Date(completedAt)) : '-';
}, },
size: 160,
}, },
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[]; ] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];

View File

@ -114,7 +114,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/org-insights') && 'bg-secondary', pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary',
)} )}
asChild asChild
> >
@ -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
> >

View File

@ -57,6 +57,7 @@ export default function AdminDocumentsPage() {
header: _(msg`Created`), header: _(msg`Created`),
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt), cell: ({ row }) => i18n.date(row.original.createdAt),
size: 160,
}, },
{ {
header: _(msg`Title`), header: _(msg`Title`),
@ -65,17 +66,19 @@ export default function AdminDocumentsPage() {
return ( return (
<Link <Link
to={`/admin/documents/${row.original.id}`} to={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]" className="block truncate font-medium hover:underline"
> >
{row.original.title} {row.original.title}
</Link> </Link>
); );
}, },
size: 240,
}, },
{ {
header: _(msg`Status`), header: _(msg`Status`),
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />, cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
}, },
{ {
header: _(msg`Owner`), header: _(msg`Owner`),
@ -112,11 +115,13 @@ export default function AdminDocumentsPage() {
</Tooltip> </Tooltip>
); );
}, },
size: 100,
}, },
{ {
header: 'Last updated', header: 'Last updated',
accessorKey: 'updatedAt', accessorKey: 'updatedAt',
cell: ({ row }) => i18n.date(row.original.updatedAt), cell: ({ row }) => i18n.date(row.original.updatedAt),
size: 160,
}, },
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []); }, []);

View File

@ -1,6 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights'; 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 { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table'; import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
@ -13,11 +12,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const page = Number(url.searchParams.get('page')) || 1; const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10; const perPage = Number(url.searchParams.get('perPage')) || 10;
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
| 'last30days'
| 'last90days'
| 'lastYear'
| 'allTime';
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents'; const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
const [insights, organisation] = await Promise.all([ const [insights, organisation] = await Promise.all([
@ -48,9 +43,7 @@ export default function OrganisationInsights({ loaderData }: Route.ComponentProp
return ( return (
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold"> <h2 className="text-4xl font-semibold">{organisationName}</h2>
<Trans>{organisationName}</Trans>
</h2>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<OrganisationInsightsTable <OrganisationInsightsTable

View File

@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume'; 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 { DateRangeFilter } from '~/components/filters/date-range-filter';
import { import {
@ -16,23 +17,20 @@ export async function loader({ request }: Route.LoaderArgs) {
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume'; const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc'; const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions const isSortOrder = (value: string): value is 'asc' | 'desc' =>
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as value === 'asc' || value === 'desc';
| 'asc' const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
| 'desc'; value === 'name' || value === 'createdAt' || value === 'signingVolume';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = ( const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume' const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
) as 'name' | 'createdAt' | 'signingVolume'; ? rawSortBy
: 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1; const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10; const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || ''; const search = url.searchParams.get('search') || '';
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
| 'last30days'
| 'last90days'
| 'lastYear'
| 'allTime';
const { organisations, totalPages } = await getOrganisationInsights({ const { organisations, totalPages } = await getOrganisationInsights({
search, search,
@ -48,7 +46,7 @@ export async function loader({ request }: Route.LoaderArgs) {
name: item.name || '', name: item.name || '',
signingVolume: item.signingVolume, signingVolume: item.signingVolume,
createdAt: item.createdAt || new Date(), createdAt: item.createdAt || new Date(),
planId: item.customerId || '', customerId: item.customerId || '',
subscriptionStatus: item.subscriptionStatus, subscriptionStatus: item.subscriptionStatus,
teamCount: item.teamCount || 0, teamCount: item.teamCount || 0,
memberCount: item.memberCount || 0, memberCount: item.memberCount || 0,

View File

@ -1,5 +1,6 @@
import type { DocumentStatus } from '@prisma/client'; import type { DocumentStatus } 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 OrganisationSummary = { export type OrganisationSummary = {
@ -50,7 +51,7 @@ export type GetOrganisationDetailedInsightsOptions = {
organisationId: string; organisationId: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
dateRange?: 'last30days' | 'last90days' | 'lastYear' | 'allTime'; dateRange?: DateRange;
view: 'teams' | 'users' | 'documents'; view: 'teams' | 'users' | 'documents';
}; };
@ -63,42 +64,38 @@ export async function getOrganisationDetailedInsights({
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> { }: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
const offset = Math.max(page - 1, 0) * perPage; const offset = Math.max(page - 1, 0) * perPage;
let dateFilter = sql``;
const now = new Date(); const now = new Date();
let createdAtFrom: Date | null = null;
switch (dateRange) { switch (dateRange) {
case 'last30days': { case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateFilter = sql`AND d."createdAt" >= ${thirtyDaysAgo}`;
break; break;
} }
case 'last90days': { case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateFilter = sql`AND d."createdAt" >= ${ninetyDaysAgo}`;
break; break;
} }
case 'lastYear': { case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateFilter = sql`AND d."createdAt" >= ${oneYearAgo}`;
break; break;
} }
case 'allTime': case 'allTime':
default: default:
dateFilter = sql``; createdAtFrom = null;
break; break;
} }
// Get organisation summary metrics const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
const summaryData = await getOrganisationSummary(organisationId, dateFilter);
const viewData = await (async () => { const viewData = await (async () => {
switch (view) { switch (view) {
case 'teams': case 'teams':
return await getTeamInsights(organisationId, offset, perPage, dateFilter); return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
case 'users': case 'users':
return await getUserInsights(organisationId, offset, perPage, dateFilter); return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
case 'documents': case 'documents':
return await getDocumentInsights(organisationId, offset, perPage, dateFilter); return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
default: default:
throw new Error(`Invalid view: ${view}`); throw new Error(`Invalid view: ${view}`);
} }
@ -114,8 +111,7 @@ async function getTeamInsights(
organisationId: string, organisationId: string,
offset: number, offset: number,
perPage: number, perPage: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any createdAtFrom: Date | null,
dateFilter: any,
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t') .selectFrom('Team as t')
@ -132,9 +128,10 @@ async function getTeamInsights(
't.name as name', 't.name as name',
't.createdAt as createdAt', 't.createdAt as createdAt',
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'), sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL ${dateFilter} THEN d.id END)`.as( (createdAtFrom
'documentCount', ? sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND d."createdAt" >= ${createdAtFrom} THEN d.id END)`
), : sql<number>`COUNT(DISTINCT d.id)`
).as('documentCount'),
]) ])
.groupBy(['t.id', 't.name', 't.createdAt']) .groupBy(['t.id', 't.name', 't.createdAt'])
.orderBy('documentCount', 'desc') .orderBy('documentCount', 'desc')
@ -161,26 +158,42 @@ async function getUserInsights(
organisationId: string, organisationId: string,
offset: number, offset: number,
perPage: number, perPage: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, unused-imports/no-unused-vars createdAtFrom: Date | null,
_dateFilter: any,
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
const usersQuery = kyselyPrisma.$kysely const usersBase = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om') .selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId') .innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.leftJoin('Document as d', (join) => .leftJoin('Document as d', (join) =>
join.onRef('d.userId', '=', 'u.id').on('d.deletedAt', 'is', null), join.onRef('d.userId', '=', 'u.id').on('d.deletedAt', 'is', null),
) )
.leftJoin('Team as td', (join) =>
join.onRef('td.id', '=', 'd.teamId').on('td.organisationId', '=', organisationId),
)
.leftJoin('Recipient as r', (join) => .leftJoin('Recipient as r', (join) =>
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null), join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
) )
.where('om.organisationId', '=', organisationId) .leftJoin('Document as sd', (join) =>
join.onRef('sd.id', '=', 'r.documentId').on('sd.deletedAt', 'is', null),
)
.leftJoin('Team as ts', (join) =>
join.onRef('ts.id', '=', 'sd.teamId').on('ts.organisationId', '=', organisationId),
);
const usersQuery = usersBase
.select([ .select([
'u.id as id', 'u.id as id',
'u.name as name', 'u.name as name',
'u.email as email', 'u.email as email',
'u.createdAt as createdAt', 'u.createdAt as createdAt',
sql<number>`COUNT(DISTINCT d.id)`.as('documentCount'), (createdAtFrom
sql<number>`COUNT(DISTINCT r.id)`.as('signedDocumentCount'), ? sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND td.id IS NOT NULL AND d."createdAt" >= ${createdAtFrom} THEN d.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN d.id END)`
).as('documentCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN r.id IS NOT NULL AND ts.id IS NOT NULL AND r."signedAt" >= ${createdAtFrom} AND r.role = 'SIGNER'::"RecipientRole" THEN r.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN ts.id IS NOT NULL AND r.role = 'SIGNER'::"RecipientRole" THEN r.id END)`
).as('signedDocumentCount'),
]) ])
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt']) .groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
.orderBy('u.createdAt', 'desc') .orderBy('u.createdAt', 'desc')
@ -208,8 +221,7 @@ async function getDocumentInsights(
organisationId: string, organisationId: string,
offset: number, offset: number,
perPage: number, perPage: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any createdAtFrom: Date | null,
dateFilter: any,
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
let documentsQuery = kyselyPrisma.$kysely let documentsQuery = kyselyPrisma.$kysely
.selectFrom('Document as d') .selectFrom('Document as d')
@ -217,9 +229,8 @@ async function getDocumentInsights(
.where('t.organisationId', '=', organisationId) .where('t.organisationId', '=', organisationId)
.where('d.deletedAt', 'is', null); .where('d.deletedAt', 'is', null);
// Apply date filter if it's not empty (which means all time) if (createdAtFrom) {
if (dateFilter && dateFilter.sql && dateFilter.sql !== '') { documentsQuery = documentsQuery.where('d.createdAt', '>=', createdAtFrom);
documentsQuery = documentsQuery.where(sql`${dateFilter}`);
} }
documentsQuery = documentsQuery documentsQuery = documentsQuery
@ -241,9 +252,8 @@ async function getDocumentInsights(
.where('t.organisationId', '=', organisationId) .where('t.organisationId', '=', organisationId)
.where('d.deletedAt', 'is', null); .where('d.deletedAt', 'is', null);
// Apply same date filter to count query if (createdAtFrom) {
if (dateFilter && dateFilter.sql && dateFilter.sql !== '') { countQuery = countQuery.where('d.createdAt', '>=', createdAtFrom);
countQuery = countQuery.where(sql`${dateFilter}`);
} }
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]); countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
@ -268,39 +278,67 @@ async function getDocumentInsights(
async function getOrganisationSummary( async function getOrganisationSummary(
organisationId: string, organisationId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any createdAtFrom: Date | null,
dateFilter: any,
): Promise<OrganisationSummary> { ): Promise<OrganisationSummary> {
const summaryQuery = kyselyPrisma.$kysely const summaryQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .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) .where('o.id', '=', organisationId)
.select([ .select([
sql<number>`COUNT(DISTINCT t.id)`.as('totalTeams'), sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
sql<number>`COUNT(DISTINCT om."userId")`.as('totalMembers'), 'totalTeams',
sql<number>`COUNT(DISTINCT d.id)`.as('totalDocuments'),
sql<number>`COUNT(DISTINCT CASE WHEN d.status IN ('DRAFT', 'PENDING') THEN d.id END)`.as(
'activeDocuments',
), ),
sql<number>`COUNT(DISTINCT CASE WHEN d.status = 'COMPLETED' THEN d.id END)`.as( sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
'completedDocuments', 'totalMembers',
),
sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND d.status = 'COMPLETED' ${dateFilter} THEN d.id END)`.as(
'volumeThisPeriod',
),
sql<number>`COUNT(DISTINCT CASE WHEN d.status = 'COMPLETED' THEN d.id END)`.as(
'volumeAllTime',
), ),
sql<number>`(
SELECT COUNT(DISTINCT d2.id)
FROM "Document" AS d2
INNER JOIN "Team" AS t2 ON t2.id = d2."teamId"
WHERE t2."organisationId" = o.id AND d2."deletedAt" IS NULL
)`.as('totalDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT d2.id)
FROM "Document" AS d2
INNER JOIN "Team" AS t2 ON t2.id = d2."teamId"
WHERE t2."organisationId" = o.id AND d2."deletedAt" IS NULL AND d2.status IN ('DRAFT', 'PENDING')
)`.as('activeDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT d2.id)
FROM "Document" AS d2
INNER JOIN "Team" AS t2 ON t2.id = d2."teamId"
WHERE t2."organisationId" = o.id AND d2."deletedAt" IS NULL AND d2.status = 'COMPLETED'
)`.as('completedDocuments'),
(createdAtFrom
? sql<number>`(
SELECT COUNT(DISTINCT d2.id)
FROM "Document" AS d2
INNER JOIN "Team" AS t2 ON t2.id = d2."teamId"
WHERE t2."organisationId" = o.id
AND d2."deletedAt" IS NULL
AND d2.status = 'COMPLETED'
AND d2."createdAt" >= ${createdAtFrom}
)`
: sql<number>`(
SELECT COUNT(DISTINCT d2.id)
FROM "Document" AS d2
INNER JOIN "Team" AS t2 ON t2.id = d2."teamId"
WHERE t2."organisationId" = o.id
AND d2."deletedAt" IS NULL
AND d2.status = 'COMPLETED'
)`
).as('volumeThisPeriod'),
sql<number>`(
SELECT COUNT(DISTINCT d2.id)
FROM "Document" AS d2
INNER JOIN "Team" AS t2 ON t2.id = d2."teamId"
WHERE t2."organisationId" = o.id AND d2."deletedAt" IS NULL AND d2.status = 'COMPLETED'
)`.as('volumeAllTime'),
]); ]);
const result = await summaryQuery.executeTakeFirst(); const result = await summaryQuery.executeTakeFirst();
return { return {
totalTeams: Math.max(Number(result?.totalTeams || 0), 1), totalTeams: Number(result?.totalTeams || 0),
totalMembers: Number(result?.totalMembers || 0), totalMembers: Number(result?.totalMembers || 0),
totalDocuments: Number(result?.totalDocuments || 0), totalDocuments: Number(result?.totalDocuments || 0),
activeDocuments: Number(result?.activeDocuments || 0), activeDocuments: Number(result?.activeDocuments || 0),

View File

@ -1,5 +1,6 @@
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } 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 OrganisationInsights = { export type OrganisationInsights = {
@ -73,7 +74,7 @@ export async function getSigningVolume({
.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()]);
@ -84,7 +85,7 @@ export async function getSigningVolume({
} }
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & { export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
dateRange?: 'last30days' | 'last90days' | 'lastYear' | 'allTime'; dateRange?: DateRange;
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
}; };
@ -182,7 +183,7 @@ export async function getOrganisationInsights({
.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()]);

View File

@ -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.
* *