mirror of
https://github.com/documenso/documenso.git
synced 2026-08-26 00:02:43 +10:00
fix(team): merge main and update analytics filters
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
const DEFAULT_PERIOD = 'month';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: 'week', label: msg`This week` },
|
||||
{ value: 'month', label: msg`This month` },
|
||||
{ value: 'quarter', label: msg`This quarter` },
|
||||
{ value: 'year', label: msg`This year` },
|
||||
{ value: 'lastMonth', label: msg`Last month` },
|
||||
{ value: 'last7Days', label: msg`Last 7 days` },
|
||||
{ value: 'last30Days', label: msg`Last 30 days` },
|
||||
] as const;
|
||||
|
||||
export const AnalyticsPeriodSelector = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const parsed = ZAnalyticsPeriodSchema.safeParse(searchParams?.get('period') ?? DEFAULT_PERIOD);
|
||||
|
||||
return parsed.success ? parsed.data : DEFAULT_PERIOD;
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === DEFAULT_PERIOD) {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{_(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -44,7 +46,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
const links = [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
label: msg`Documents`,
|
||||
@@ -54,6 +56,19 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
label: msg`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
currentTeam &&
|
||||
IS_TEAM_ANALYTICS_ENABLED() &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
|
||||
) {
|
||||
links.push({
|
||||
href: `/t/${currentTeam.url}/analytics`,
|
||||
label: msg`Analytics`,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
@@ -56,7 +58,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
const links = [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
text: t`Documents`,
|
||||
@@ -65,6 +67,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
text: t`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
currentTeam &&
|
||||
IS_TEAM_ANALYTICS_ENABLED() &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
|
||||
) {
|
||||
links.push({
|
||||
href: `/t/${currentTeam.url}/analytics`,
|
||||
text: t`Analytics`,
|
||||
});
|
||||
}
|
||||
|
||||
links.push(
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
@@ -73,7 +89,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
return links;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteTeamAction, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { parseAsStringLiteral, useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { AnalyticsPeriodSelector } from '~/components/general/analytics-period-selector';
|
||||
import { CardMetric } from '~/components/general/metric-card';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics._index';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Analytics`);
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
// Behind a rollout flag: silently send everyone back to documents when off.
|
||||
if (!IS_TEAM_ANALYTICS_ENABLED()) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
|
||||
const team = await getTeamByUrl({
|
||||
userId: session.user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
// Admins and managers only. Members are silently redirected (no existence leak).
|
||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
}
|
||||
|
||||
export default function TeamAnalyticsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [{ period, senderIds }] = useQueryStates({
|
||||
period: parseAsStringLiteral(ZAnalyticsPeriodSchema.options),
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
});
|
||||
|
||||
const timezone = useMemo(() => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = trpc.team.getAnalytics.useQuery({
|
||||
teamId: team.id,
|
||||
period: period ?? undefined,
|
||||
timezone,
|
||||
senderIds: senderIds ?? [],
|
||||
});
|
||||
|
||||
const analytics = data ?? {
|
||||
sent: 0,
|
||||
draft: 0,
|
||||
pending: 0,
|
||||
completed: 0,
|
||||
declined: 0,
|
||||
};
|
||||
|
||||
const hasActivity =
|
||||
analytics.sent > 0 ||
|
||||
analytics.draft > 0 ||
|
||||
analytics.pending > 0 ||
|
||||
analytics.completed > 0 ||
|
||||
analytics.declined > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Analytics</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap items-center gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<DocumentsTableSenderFilter teamId={team.id} />
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<AnalyticsPeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{isLoading ? (
|
||||
<SpinnerBox className="py-32" />
|
||||
) : hasActivity ? (
|
||||
<div data-testid="team-analytics-content">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div data-testid="metric-sent" className="contents">
|
||||
<CardMetric title={_(msg`Documents Sent`)} value={analytics.sent} />
|
||||
</div>
|
||||
<div data-testid="metric-completed-headline" className="contents">
|
||||
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<CardMetric title={_(msg`Draft`)} value={analytics.draft} />
|
||||
<CardMetric title={_(msg`Pending`)} value={analytics.pending} />
|
||||
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
|
||||
<CardMetric title={_(msg`Declined`)} value={analytics.declined} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-3xl text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Each tile counts documents that entered that state during the selected period, on its own date. They are
|
||||
independent activity counts and do not add up to Documents Sent.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="team-analytics-empty"
|
||||
className="flex flex-col items-center justify-center rounded-lg border border-border border-dashed py-20 text-center"
|
||||
>
|
||||
<h3 className="font-semibold text-foreground text-lg">
|
||||
<Trans>No analytics to show yet</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-md text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
There's no document activity for the selected period. Send your first document to start tracking your
|
||||
team's usage here.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-6">
|
||||
<Link to={formatDocumentsPath(team.url)}>
|
||||
<Trans>Send a document</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user