feat: team analytics

This commit is contained in:
Ephraim Atta-Duncan
2025-08-20 15:19:09 +00:00
parent a51110d276
commit 202702b1c7
16 changed files with 834 additions and 5 deletions

View 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>
);
};

View File

@ -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,
};
}
};

View 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>
);
};

View 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>
);
};

View File

@ -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)}

View File

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

View File

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

View File

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