mirror of
https://github.com/documenso/documenso.git
synced 2026-07-12 22:15:01 +10:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c81ee8f6c | |||
| bdef3ec018 | |||
| 311815063b | |||
| e5bbbab62a | |||
| c98dff12e8 | |||
| 0cb0a708d5 | |||
| 592befe09a |
@@ -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]);
|
||||
|
||||
+15
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
-55
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user