mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
3 Commits
f6f66272b2
...
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>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link to="/settings/analytics">
|
||||||
|
<Trans>Personal analytics</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-muted-foreground px-4 py-2"
|
className="text-muted-foreground px-4 py-2"
|
||||||
onClick={() => setLanguageSwitcherOpen(true)}
|
onClick={() => setLanguageSwitcherOpen(true)}
|
||||||
|
|||||||
@ -307,11 +307,18 @@ export const OrgMenuSwitcher = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<>
|
||||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
<Trans>Team settings</Trans>
|
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||||
</Link>
|
<Trans>Team settings</Trans>
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import {
|
import {
|
||||||
|
BarChart3Icon,
|
||||||
BracesIcon,
|
BracesIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
Globe2Icon,
|
Globe2Icon,
|
||||||
@ -128,6 +129,19 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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() && (
|
{IS_BILLING_ENABLED() && (
|
||||||
<Link to="/settings/billing">
|
<Link to="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import {
|
import {
|
||||||
|
BarChart3Icon,
|
||||||
BracesIcon,
|
BracesIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
Globe2Icon,
|
Globe2Icon,
|
||||||
@ -128,6 +129,19 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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() && (
|
{IS_BILLING_ENABLED() && (
|
||||||
<Link to="/settings/billing">
|
<Link to="/settings/billing">
|
||||||
<Button
|
<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