mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 12:41:36 +10:00
feat: team analytics
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user