mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
3 Commits
1650c55b19
...
feat/team-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aae25e423 | |||
| a39b4efc28 | |||
| 202702b1c7 |
67
apps/remix/app/components/analytics/analytics-charts.tsx
Normal file
67
apps/remix/app/components/analytics/analytics-charts.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import type { MonthlyStats } from '@documenso/lib/server-only/analytics';
|
||||
|
||||
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
|
||||
|
||||
export type AnalyticsChartsProps = {
|
||||
monthlyData?: MonthlyStats;
|
||||
showActiveUsers?: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsCharts = ({ monthlyData, showActiveUsers = false }: AnalyticsChartsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
if (!monthlyData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure all data has cume_count for chart compatibility
|
||||
const formatDataForChart = (data: MonthlyStats) => {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
count: item.count,
|
||||
cume_count: item.cume_count || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Charts</Trans>
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
{showActiveUsers && (
|
||||
<>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Active Users (created document)`)}
|
||||
data={formatDataForChart(monthlyData)}
|
||||
/>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative Active Users`)}
|
||||
data={formatDataForChart(monthlyData)}
|
||||
cummulative
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showActiveUsers && (
|
||||
<>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Documents Created`)}
|
||||
data={formatDataForChart(monthlyData)}
|
||||
/>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Documents Completed`)}
|
||||
data={formatDataForChart(
|
||||
monthlyData.map((d) => ({ ...d, count: d.signed_count || 0 })),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,88 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
export type DateFilterPeriod = 'all' | 'year' | 'month' | 'week' | 'day';
|
||||
|
||||
export type AnalyticsDateFilterProps = {
|
||||
value: DateFilterPeriod;
|
||||
onChange: (value: DateFilterPeriod) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsDateFilter = ({ value, onChange, className }: AnalyticsDateFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{ value: 'all' as const, label: _(msg`All Time`) },
|
||||
{ value: 'year' as const, label: _(msg`This Year`) },
|
||||
{ value: 'month' as const, label: _(msg`This Month`) },
|
||||
{ value: 'week' as const, label: _(msg`This Week`) },
|
||||
{ value: 'day' as const, label: _(msg`Today`) },
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder={_(msg`Select period`)}>{selectedOption?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getDateRangeFromPeriod = (period: DateFilterPeriod) => {
|
||||
const now = DateTime.now();
|
||||
|
||||
switch (period) {
|
||||
case 'day':
|
||||
return {
|
||||
dateFrom: now.startOf('day').toJSDate(),
|
||||
dateTo: now.endOf('day').toJSDate(),
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
dateFrom: now.startOf('week').toJSDate(),
|
||||
dateTo: now.endOf('week').toJSDate(),
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
dateFrom: now.startOf('month').toJSDate(),
|
||||
dateTo: now.endOf('month').toJSDate(),
|
||||
};
|
||||
case 'year':
|
||||
return {
|
||||
dateFrom: now.startOf('year').toJSDate(),
|
||||
dateTo: now.endOf('year').toJSDate(),
|
||||
};
|
||||
case 'all':
|
||||
default:
|
||||
return {
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
105
apps/remix/app/components/analytics/analytics-metrics.tsx
Normal file
105
apps/remix/app/components/analytics/analytics-metrics.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
File,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
FileEdit,
|
||||
Mail,
|
||||
MailOpen,
|
||||
PenTool,
|
||||
UserSquare2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { DocumentStats, RecipientStats } from '@documenso/lib/server-only/analytics';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { CardMetric } from '~/components/general/metric-card';
|
||||
|
||||
export type AnalyticsMetricsProps = {
|
||||
docStats: DocumentStats;
|
||||
recipientStats: RecipientStats;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsMetrics = ({
|
||||
docStats,
|
||||
recipientStats,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: AnalyticsMetricsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Overview Metrics */}
|
||||
<div
|
||||
className={cn('grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4', {
|
||||
'pointer-events-none opacity-50': isLoading,
|
||||
})}
|
||||
>
|
||||
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={FileCheck}
|
||||
title={_(msg`Completed Documents`)}
|
||||
value={docStats.COMPLETED}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title={_(msg`Total Recipients`)}
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={PenTool}
|
||||
title={_(msg`Signatures Collected`)}
|
||||
value={recipientStats.SIGNED}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Document Metrics Section */}
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Document metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
|
||||
<CardMetric icon={FileClock} title={_(msg`Pending Documents`)} value={docStats.PENDING} />
|
||||
<CardMetric
|
||||
icon={FileCheck}
|
||||
title={_(msg`Completed Documents`)}
|
||||
value={docStats.COMPLETED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipients Metrics Section */}
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Recipients metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title={_(msg`Total Recipients`)}
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric icon={Mail} title={_(msg`Documents Sent`)} value={recipientStats.SENT} />
|
||||
<CardMetric
|
||||
icon={MailOpen}
|
||||
title={_(msg`Documents Viewed`)}
|
||||
value={recipientStats.OPENED}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={PenTool}
|
||||
title={_(msg`Signatures Collected`)}
|
||||
value={recipientStats.SIGNED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
apps/remix/app/components/analytics/analytics-page.tsx
Normal file
71
apps/remix/app/components/analytics/analytics-page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigation } from 'react-router';
|
||||
|
||||
import type {
|
||||
DocumentStats,
|
||||
MonthlyStats,
|
||||
RecipientStats,
|
||||
} from '@documenso/lib/server-only/analytics';
|
||||
|
||||
import { AnalyticsCharts } from './analytics-charts';
|
||||
import { AnalyticsDateFilter, type DateFilterPeriod } from './analytics-date-filter';
|
||||
import { AnalyticsMetrics } from './analytics-metrics';
|
||||
|
||||
export type AnalyticsPageData = {
|
||||
docStats: DocumentStats;
|
||||
recipientStats: RecipientStats;
|
||||
monthlyData?: MonthlyStats;
|
||||
period: DateFilterPeriod;
|
||||
};
|
||||
|
||||
export type AnalyticsPageProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
data: AnalyticsPageData;
|
||||
showCharts?: boolean;
|
||||
onPeriodChange: (period: DateFilterPeriod) => void;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsPage = ({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
showCharts = true,
|
||||
onPeriodChange,
|
||||
containerClassName = 'mx-auto w-full max-w-screen-xl px-4 md:px-8',
|
||||
}: AnalyticsPageProps) => {
|
||||
const navigation = useNavigation();
|
||||
const { docStats, recipientStats, monthlyData, period } = data;
|
||||
const isLoading = navigation.state === 'loading';
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>{title}</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
<Trans>{subtitle}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<AnalyticsDateFilter value={period} onChange={onPeriodChange} />
|
||||
</div>
|
||||
|
||||
<AnalyticsMetrics
|
||||
docStats={docStats}
|
||||
recipientStats={recipientStats}
|
||||
isLoading={isLoading}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
{showCharts && (
|
||||
<AnalyticsCharts
|
||||
monthlyData={monthlyData}
|
||||
showActiveUsers={!!monthlyData?.[0]?.cume_count}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -97,6 +97,12 @@ export const MenuSwitcher = () => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/analytics">
|
||||
<Trans>Personal analytics</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground px-4 py-2"
|
||||
onClick={() => setLanguageSwitcherOpen(true)}
|
||||
|
||||
@ -307,11 +307,18 @@ export const OrgMenuSwitcher = () => {
|
||||
)}
|
||||
|
||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/analytics`}>
|
||||
<Trans>Team analytics</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
|
||||
@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
@ -128,6 +129,19 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/analytics">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<BarChart3Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Analytics</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
|
||||
@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
@ -128,6 +129,19 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/analytics">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<BarChart3Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Analytics</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
|
||||
24
apps/remix/app/hooks/use-analytics-filter.ts
Normal file
24
apps/remix/app/hooks/use-analytics-filter.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import type { DateFilterPeriod } from '~/components/analytics/analytics-date-filter';
|
||||
|
||||
export const useAnalyticsFilter = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const period = (searchParams.get('period') as DateFilterPeriod) || 'all';
|
||||
|
||||
const handlePeriodChange = (newPeriod: DateFilterPeriod) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
} else {
|
||||
params.set('period', newPeriod);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
return {
|
||||
period,
|
||||
handlePeriodChange,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getUserDocumentStats, getUserRecipientsStats } from '@documenso/lib/server-only/analytics';
|
||||
|
||||
import {
|
||||
type DateFilterPeriod,
|
||||
getDateRangeFromPeriod,
|
||||
} from '~/components/analytics/analytics-date-filter';
|
||||
import { AnalyticsPage } from '~/components/analytics/analytics-page';
|
||||
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Personal Analytics');
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
const url = new URL(request.url);
|
||||
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
|
||||
|
||||
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
|
||||
|
||||
const [docStats, recipientStats] = await Promise.all([
|
||||
getUserDocumentStats({ userId: session.user.id, dateFrom, dateTo }),
|
||||
getUserRecipientsStats({ userId: session.user.id, dateFrom, dateTo }),
|
||||
]);
|
||||
|
||||
return {
|
||||
docStats,
|
||||
recipientStats,
|
||||
period,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PersonalAnalyticsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { handlePeriodChange } = useAnalyticsFilter();
|
||||
const { docStats, recipientStats, period } = loaderData;
|
||||
|
||||
return (
|
||||
<AnalyticsPage
|
||||
title="Personal Analytics"
|
||||
subtitle="Your personal document signing analytics and insights"
|
||||
data={{
|
||||
docStats,
|
||||
recipientStats,
|
||||
period,
|
||||
}}
|
||||
showCharts={false}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
containerClassName=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import {
|
||||
getTeamDocumentStats,
|
||||
getTeamMonthlyActiveUsers,
|
||||
getTeamRecipientsStats,
|
||||
} from '@documenso/lib/server-only/analytics';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
|
||||
import {
|
||||
type DateFilterPeriod,
|
||||
getDateRangeFromPeriod,
|
||||
} from '~/components/analytics/analytics-date-filter';
|
||||
import { AnalyticsPage } from '~/components/analytics/analytics-page';
|
||||
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Team Analytics');
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await getSession(request);
|
||||
const url = new URL(request.url);
|
||||
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
|
||||
|
||||
const team = await getTeamByUrl({
|
||||
userId: session.user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw redirect(`/t/${params.teamUrl}`);
|
||||
}
|
||||
|
||||
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
|
||||
|
||||
const [docStats, recipientStats, monthlyActiveUsers] = await Promise.all([
|
||||
getTeamDocumentStats({ teamId: team.id, dateFrom, dateTo }),
|
||||
getTeamRecipientsStats({ teamId: team.id, dateFrom, dateTo }),
|
||||
getTeamMonthlyActiveUsers(team.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
docStats,
|
||||
recipientStats,
|
||||
monthlyActiveUsers,
|
||||
period,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load team analytics:', error);
|
||||
|
||||
// If it's a redirect, re-throw it
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Response('Failed to load team analytics data', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
|
||||
const team = useCurrentTeam();
|
||||
const { handlePeriodChange } = useAnalyticsFilter();
|
||||
|
||||
const { docStats, recipientStats, monthlyActiveUsers, period } = loaderData;
|
||||
|
||||
return (
|
||||
<AnalyticsPage
|
||||
title="Team Analytics"
|
||||
subtitle={`Analytics and insights for ${team.name}`}
|
||||
data={{
|
||||
docStats,
|
||||
recipientStats,
|
||||
monthlyData: monthlyActiveUsers,
|
||||
period,
|
||||
}}
|
||||
showCharts={true}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
packages/lib/server-only/analytics/get-document-stats.ts
Normal file
68
packages/lib/server-only/analytics/get-document-stats.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { DocumentStatus, Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import type {
|
||||
AnalyticsFilters,
|
||||
DocumentStats,
|
||||
TeamDocumentStatsFilters,
|
||||
UserDocumentStatsFilters,
|
||||
} from './types';
|
||||
|
||||
export const getDocumentStats = async (filters: AnalyticsFilters): Promise<DocumentStats> => {
|
||||
const { dateFrom, dateTo, ...entityFilter } = filters;
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
...entityFilter,
|
||||
deletedAt: null,
|
||||
...(dateFrom || dateTo
|
||||
? {
|
||||
createdAt: {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const counts = await prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
where,
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stats: DocumentStats = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
counts.forEach((stat: { status: DocumentStatus; _count: { _all: number } }) => {
|
||||
stats[stat.status as DocumentStatus] = stat._count._all;
|
||||
stats.ALL += stat._count._all;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
// Legacy wrapper functions for backwards compatibility
|
||||
export const getTeamDocumentStats = async (filters: TeamDocumentStatsFilters) => {
|
||||
return getDocumentStats({
|
||||
teamId: filters.teamId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserDocumentStats = async (filters: UserDocumentStatsFilters) => {
|
||||
return getDocumentStats({
|
||||
userId: filters.userId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
80
packages/lib/server-only/analytics/get-monthly-stats.ts
Normal file
80
packages/lib/server-only/analytics/get-monthly-stats.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { MonthlyStats } from './types';
|
||||
|
||||
type MonthlyDocumentGrowthQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
signed_count: bigint;
|
||||
}>;
|
||||
|
||||
type MonthlyActiveUsersQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
cume_count: bigint;
|
||||
}>;
|
||||
|
||||
export const getTeamMonthlyDocumentGrowth = async (teamId: number): Promise<MonthlyStats> => {
|
||||
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."id") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
|
||||
FROM "Document"
|
||||
WHERE "Document"."teamId" = ${teamId}
|
||||
AND "Document"."deletedAt" IS NULL
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
signed_count: Number(row.signed_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUserMonthlyDocumentGrowth = async (userId: number): Promise<MonthlyStats> => {
|
||||
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."id") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
|
||||
FROM "Document"
|
||||
WHERE "Document"."userId" = ${userId}
|
||||
AND "Document"."deletedAt" IS NULL
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
signed_count: Number(row.signed_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getTeamMonthlyActiveUsers = async (teamId: number): Promise<MonthlyStats> => {
|
||||
const result = await prisma.$queryRaw<MonthlyActiveUsersQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."userId") as "count",
|
||||
SUM(COUNT(DISTINCT "Document"."userId")) OVER (ORDER BY DATE_TRUNC('month', "Document"."createdAt")) as "cume_count"
|
||||
FROM "Document"
|
||||
WHERE "Document"."teamId" = ${teamId}
|
||||
AND "Document"."deletedAt" IS NULL
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
75
packages/lib/server-only/analytics/get-recipient-stats.ts
Normal file
75
packages/lib/server-only/analytics/get-recipient-stats.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { type Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type {
|
||||
AnalyticsFilters,
|
||||
RecipientStats,
|
||||
TeamRecipientsStatsFilters,
|
||||
UserRecipientsStatsFilters,
|
||||
} from './types';
|
||||
|
||||
export const getRecipientStats = async (filters: AnalyticsFilters): Promise<RecipientStats> => {
|
||||
const { dateFrom, dateTo, ...entityFilter } = filters;
|
||||
|
||||
const where: Prisma.RecipientWhereInput = {
|
||||
envelope: {
|
||||
...entityFilter,
|
||||
deletedAt: null,
|
||||
...(dateFrom || dateTo
|
||||
? {
|
||||
createdAt: {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const results = await prisma.recipient.groupBy({
|
||||
by: ['readStatus', 'signingStatus', 'sendStatus'],
|
||||
where,
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const stats: RecipientStats = {
|
||||
TOTAL_RECIPIENTS: 0,
|
||||
[ReadStatus.OPENED]: 0,
|
||||
[ReadStatus.NOT_OPENED]: 0,
|
||||
[SigningStatus.SIGNED]: 0,
|
||||
[SigningStatus.NOT_SIGNED]: 0,
|
||||
[SigningStatus.REJECTED]: 0,
|
||||
[SendStatus.SENT]: 0,
|
||||
[SendStatus.NOT_SENT]: 0,
|
||||
};
|
||||
|
||||
results.forEach((result) => {
|
||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||
|
||||
stats[readStatus] += _count;
|
||||
stats[signingStatus] += _count;
|
||||
stats[sendStatus] += _count;
|
||||
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
// Legacy wrapper functions for backwards compatibility
|
||||
export const getTeamRecipientsStats = async (filters: TeamRecipientsStatsFilters) => {
|
||||
return getRecipientStats({
|
||||
teamId: filters.teamId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserRecipientsStats = async (filters: UserRecipientsStatsFilters) => {
|
||||
return getRecipientStats({
|
||||
userId: filters.userId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
4
packages/lib/server-only/analytics/index.ts
Normal file
4
packages/lib/server-only/analytics/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './get-document-stats';
|
||||
export * from './get-recipient-stats';
|
||||
export * from './get-monthly-stats';
|
||||
63
packages/lib/server-only/analytics/types.ts
Normal file
63
packages/lib/server-only/analytics/types.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type EntityFilter = { teamId: number } | { userId: number };
|
||||
|
||||
export type AnalyticsDateFilter = {
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type AnalyticsFilters = EntityFilter & AnalyticsDateFilter;
|
||||
|
||||
export type DocumentStats = Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number>;
|
||||
|
||||
export type RecipientStats = {
|
||||
TOTAL_RECIPIENTS: number;
|
||||
[ReadStatus.OPENED]: number;
|
||||
[ReadStatus.NOT_OPENED]: number;
|
||||
[SigningStatus.SIGNED]: number;
|
||||
[SigningStatus.NOT_SIGNED]: number;
|
||||
[SigningStatus.REJECTED]: number;
|
||||
[SendStatus.SENT]: number;
|
||||
[SendStatus.NOT_SENT]: number;
|
||||
};
|
||||
|
||||
export type MonthlyStats = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
signed_count?: number;
|
||||
cume_count?: number;
|
||||
}>;
|
||||
|
||||
export type UserMonthlyDocumentGrowth = MonthlyStats;
|
||||
|
||||
export type TeamMonthlyDocumentGrowth = MonthlyStats;
|
||||
|
||||
export type TeamMonthlyActiveUsers = MonthlyStats;
|
||||
|
||||
// Legacy type exports for backwards compatibility
|
||||
export type TeamDocumentStatsFilters = {
|
||||
teamId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type UserDocumentStatsFilters = {
|
||||
userId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type TeamRecipientsStatsFilters = {
|
||||
teamId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type UserRecipientsStatsFilters = {
|
||||
userId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
Reference in New Issue
Block a user