Compare commits

..

7 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 7c81ee8f6c fix: build errors 2025-10-20 15:19:50 +00:00
Ephraim Atta-Duncan bdef3ec018 Merge branch 'main' into feat/change-radio-direction 2025-10-20 14:03:51 +00:00
Ephraim Atta-Duncan 311815063b fix: merge conflicts 2025-10-20 14:03:43 +00:00
Ephraim Atta-Duncan e5bbbab62a fix: build errors 2025-10-07 21:25:29 +00:00
Ephraim Atta-Duncan c98dff12e8 fix: insert horizontal field into pdf 2025-10-07 21:11:58 +00:00
Ephraim Duncan 0cb0a708d5 Merge branch 'main' into feat/change-radio-direction 2025-10-07 20:32:45 +00:00
Ephraim Atta-Duncan 592befe09a feat: change direction of radio field 2025-08-23 04:40:58 +00:00
24 changed files with 144 additions and 860 deletions
@@ -1,67 +0,0 @@
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>
);
};
@@ -1,88 +0,0 @@
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,
};
}
};
@@ -1,105 +0,0 @@
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>
);
};
@@ -1,71 +0,0 @@
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>
);
};
@@ -27,6 +27,7 @@ const ZRadioFieldFormSchema = z
.optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
direction: z.enum(['vertical', 'horizontal']).optional(),
})
.refine(
(data) => {
@@ -53,6 +54,7 @@ export type EditorFieldRadioFormProps = {
export const EditorFieldRadioForm = ({
value = {
type: 'radio',
direction: 'vertical',
},
onValueChange,
}: EditorFieldRadioFormProps) => {
@@ -64,6 +66,7 @@ export const EditorFieldRadioForm = ({
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false,
readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
},
});
@@ -100,6 +103,7 @@ export const EditorFieldRadioForm = ({
onValueChange({
type: 'radio',
...validatedFormValues.data,
direction: validatedFormValues.data.direction || 'vertical',
});
}
}, [formValues]);
@@ -14,6 +14,7 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -156,7 +157,12 @@ export const DocumentSigningRadioField = ({
{!field.inserted && (
<RadioGroup
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
className={cn(
'z-10 my-0.5 gap-1',
parsedFieldMeta.direction === 'horizontal'
? 'flex flex-row flex-wrap'
: 'flex flex-col gap-y-1',
)}
>
{values?.map((item, index) => (
<div key={index} className="flex items-center">
@@ -181,7 +187,14 @@ export const DocumentSigningRadioField = ({
)}
{field.inserted && (
<RadioGroup className="my-0.5 gap-y-1">
<RadioGroup
className={cn(
'my-0.5 gap-1',
parsedFieldMeta.direction === 'horizontal'
? 'flex flex-row flex-wrap'
: 'flex flex-col gap-y-1',
)}
>
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
@@ -97,12 +97,6 @@ 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,18 +307,11 @@ 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}/analytics`}>
<Trans>Team analytics</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
@@ -2,7 +2,6 @@ import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import {
BarChart3Icon,
BracesIcon,
CreditCardIcon,
Globe2Icon,
@@ -129,19 +128,6 @@ 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,7 +2,6 @@ import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import {
BarChart3Icon,
BracesIcon,
CreditCardIcon,
Globe2Icon,
@@ -129,19 +128,6 @@ 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
@@ -1,24 +0,0 @@
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,
};
};
@@ -1,55 +0,0 @@
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=""
/>
);
}
@@ -1,88 +0,0 @@
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}
/>
);
}
@@ -1,68 +0,0 @@
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,
});
};
@@ -1,80 +0,0 @@
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),
}));
};
@@ -1,75 +0,0 @@
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,
});
};
@@ -1,4 +0,0 @@
export * from './types';
export * from './get-document-stats';
export * from './get-recipient-stats';
export * from './get-monthly-stats';
@@ -1,63 +0,0 @@
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;
};
@@ -331,36 +331,75 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
}));
const selected = field.customText.split(',');
const direction = meta.data.direction ?? 'vertical';
const topPadding = 12;
const leftRadioPadding = 8;
const leftRadioLabelPadding = 12;
const radioSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * radioSpaceY + topPadding;
if (direction === 'horizontal') {
let currentX = leftRadioPadding;
let currentY = topPadding;
const maxWidth = pageWidth - fieldX - leftRadioPadding * 2;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
for (const [index, item] of (values ?? []).entries()) {
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
// Draw label.
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
const labelText = item.value.includes('empty-value-') ? '' : item.value;
const labelWidth = font.widthOfTextAtSize(labelText, 12);
const itemWidth = leftRadioLabelPadding + labelWidth + 16;
// Draw radio button.
radio.addOptionToPage(item.value, page, {
x: fieldX + leftRadioPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (currentX + itemWidth > maxWidth && index > 0) {
currentX = leftRadioPadding;
currentY += radioSpaceY;
}
if (selected.includes(item.value)) {
radio.select(item.value);
page.drawText(labelText, {
x: fieldX + currentX + leftRadioLabelPadding,
y: pageHeight - (fieldY + currentY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX + currentX,
y: pageHeight - (fieldY + currentY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
currentX += itemWidth;
}
} else {
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * radioSpaceY + topPadding;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX + leftRadioPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
}
})
@@ -203,6 +203,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
type: 'radio',
label: field.label,
values: newValues,
direction: radioMeta.direction ?? 'vertical',
};
return meta;
+2
View File
@@ -81,6 +81,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
}),
)
.optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
});
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
@@ -278,6 +279,7 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
values: [{ id: 1, checked: false, value: '' }],
required: false,
readOnly: false,
direction: 'vertical',
};
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
@@ -108,8 +108,15 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
field.fieldMeta.values.length > 0
) {
return (
<div className="flex flex-col gap-y-2 py-0.5">
<RadioGroup className="gap-y-1">
<div className="py-0.5">
<RadioGroup
className={cn(
'gap-1',
field.fieldMeta.direction === 'horizontal'
? 'flex flex-row flex-wrap'
: 'flex flex-col gap-y-1',
)}
>
{field.fieldMeta.values.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
@@ -122,6 +122,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
values: [],
required: false,
readOnly: false,
direction: 'vertical',
};
case FieldType.CHECKBOX:
return {
@@ -11,6 +11,13 @@ import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
export type RadioFieldAdvancedSettingsProps = {
@@ -35,6 +42,9 @@ export const RadioFieldAdvancedSettings = ({
);
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
const [required, setRequired] = useState(fieldState.required ?? false);
const [direction, setDirection] = useState<'vertical' | 'horizontal'>(
fieldState.direction ?? 'vertical',
);
const addValue = () => {
const newId = values.length > 0 ? Math.max(...values.map((val) => val.id)) + 1 : 1;
@@ -69,10 +79,19 @@ export const RadioFieldAdvancedSettings = ({
const handleToggleChange = (field: keyof RadioFieldMeta, value: string | boolean) => {
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
const currentDirection =
field === 'direction' && String(value) === 'horizontal' ? 'horizontal' : 'vertical';
setReadOnly(readOnly);
setRequired(required);
setDirection(currentDirection);
const errors = validateRadioField(String(value), { readOnly, required, values, type: 'radio' });
const errors = validateRadioField(String(value), {
readOnly,
required,
values,
type: 'radio',
direction: currentDirection,
});
handleErrors(errors);
handleFieldChange(field, value);
@@ -97,7 +116,13 @@ export const RadioFieldAdvancedSettings = ({
}, [fieldState.values]);
useEffect(() => {
const errors = validateRadioField(undefined, { readOnly, required, values, type: 'radio' });
const errors = validateRadioField(undefined, {
readOnly,
required,
values,
type: 'radio',
direction,
});
handleErrors(errors);
}, [values]);
@@ -116,6 +141,27 @@ export const RadioFieldAdvancedSettings = ({
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div>
<Label>
<Trans>Direction</Trans>
</Label>
<Select
value={fieldState.direction ?? 'vertical'}
onValueChange={(val) => handleToggleChange('direction', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue placeholder={_(msg`Select direction`)} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"