mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into feat/public-profiles
This commit is contained in:
@ -14,18 +14,34 @@ import {
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getUserWithAtLeastOneDocumentPerMonth,
|
||||
getUserWithAtLeastOneDocumentSignedPerMonth,
|
||||
getUserWithSignedDocumentMonthlyGrowth,
|
||||
getUsersCount,
|
||||
getUsersWithSubscriptionsCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
|
||||
import { UserWithDocumentChart } from './user-with-document';
|
||||
|
||||
export default async function AdminStatsPage() {
|
||||
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
|
||||
const [
|
||||
usersCount,
|
||||
usersWithSubscriptionsCount,
|
||||
docStats,
|
||||
recipientStats,
|
||||
userWithAtLeastOneDocumentPerMonth,
|
||||
userWithAtLeastOneDocumentSignedPerMonth,
|
||||
MONTHLY_USERS_SIGNED,
|
||||
] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getUsersWithSubscriptionsCount(),
|
||||
getDocumentStats(),
|
||||
getRecipientsStats(),
|
||||
getUserWithAtLeastOneDocumentPerMonth(),
|
||||
getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||
getUserWithSignedDocumentMonthlyGrowth(),
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -43,12 +59,11 @@ export default async function AdminStatsPage() {
|
||||
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-1 lg:grid-cols-2">
|
||||
<div className="mt-16 gap-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
||||
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
||||
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
||||
@ -58,7 +73,7 @@ export default async function AdminStatsPage() {
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title="Total Recipients"
|
||||
@ -70,6 +85,23 @@ export default async function AdminStatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">Charts</h3>
|
||||
<div className="mt-5 grid grid-cols-2 gap-10">
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
title="MAU (created document)"
|
||||
tooltip="Monthly Active Users: Users that created at least one Document"
|
||||
/>
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
completed
|
||||
title="MAU (had document completed)"
|
||||
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
export type UserWithDocumentChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
data: GetUserWithDocumentMonthlyGrowth;
|
||||
completed?: boolean;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
tooltip,
|
||||
}: TooltipProps<ValueType, NameType> & { tooltip?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<p className="">{label}</p>
|
||||
<p className="text-documenso">
|
||||
{`${tooltip} : `}
|
||||
<span className="text-black">{payload[0].value}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const UserWithDocumentChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
completed = false,
|
||||
tooltip,
|
||||
}: UserWithDocumentChartProps) => {
|
||||
const formattedData = (data: GetUserWithDocumentMonthlyGrowth, completed: boolean) => {
|
||||
return [...data].reverse().map(({ month, count, signed_count }) => {
|
||||
const formattedMonth = DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL');
|
||||
if (completed) {
|
||||
return {
|
||||
month: formattedMonth,
|
||||
count: Number(signed_count),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
month: formattedMonth,
|
||||
count: Number(count),
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex h-12 px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart className="bg-white" data={formattedData(data, completed)}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip
|
||||
content={<CustomTooltip tooltip={tooltip} />}
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
}}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label={tooltip}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -8,7 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@ -86,14 +86,15 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipients, completedFields] = await Promise.all([
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
getCompletedFieldsForDocument({
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -163,10 +164,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
</Card>
|
||||
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentReadOnlyFields
|
||||
fields={completedFields}
|
||||
documentMeta={document.documentMeta || undefined}
|
||||
/>
|
||||
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
|
||||
@ -383,7 +383,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
|
||||
@ -10,6 +10,7 @@ import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
@ -70,8 +71,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
let recipientHasAccount: boolean | null = null;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <SigningAuthPageView email={recipient.email} />;
|
||||
recipientHasAccount = await getUserByEmail({ email: recipient?.email })
|
||||
.then((user) => !!user)
|
||||
.catch(() => false);
|
||||
|
||||
return <SigningAuthPageView email={recipient.email} emailHasAccount={!!recipientHasAccount} />;
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
|
||||
@ -11,9 +11,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type SigningAuthPageViewProps = {
|
||||
email: string;
|
||||
emailHasAccount?: boolean;
|
||||
};
|
||||
|
||||
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
|
||||
export const SigningAuthPageView = ({ email, emailHasAccount }: SigningAuthPageViewProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
@ -30,7 +31,9 @@ export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
|
||||
});
|
||||
|
||||
await signOut({
|
||||
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||
callbackUrl: emailHasAccount
|
||||
? `/signin?email=${encodeURIComponent(encryptedEmail)}`
|
||||
: `/signup?email=${encodeURIComponent(encryptedEmail)}`,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
@ -59,7 +62,7 @@ export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
|
||||
onClick={async () => handleChangeAccount(email)}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
{emailHasAccount ? 'Login' : 'Sign up'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EyeOffIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -10,19 +11,19 @@ import {
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: CompletedField[];
|
||||
fields: DocumentField[];
|
||||
documentMeta?: DocumentMeta;
|
||||
};
|
||||
|
||||
@ -53,56 +54,71 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'flex w-fit flex-col py-2.5 text-sm',
|
||||
className: 'relative flex w-fit flex-col p-2.5 text-sm',
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
{field.Recipient.name
|
||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||
: field.Recipient.email}{' '}
|
||||
</span>
|
||||
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
|
||||
<p className="font-semibold">
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED ? 'Signed' : 'Pending'}{' '}
|
||||
{FRIENDLY_FIELD_TYPE[field.type].toLowerCase()} field
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{field.Recipient.name
|
||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||
: field.Recipient.email}{' '}
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
|
||||
onClick={() => handleHideField(field.secondaryId)}
|
||||
title="Hide field"
|
||||
>
|
||||
Hide field
|
||||
</Button>
|
||||
<EyeOffIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverHover>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground break-all text-sm">
|
||||
{match(field)
|
||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||
field.Signature?.signatureImageAsBase64 ? (
|
||||
<img
|
||||
src={field.Signature.signatureImageAsBase64}
|
||||
alt="Signature"
|
||||
className="h-full w-full object-contain dark:invert"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{field.Signature?.typedSignature}
|
||||
</p>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
|
||||
() => field.customText,
|
||||
)
|
||||
.with({ type: FieldType.DATE }, () =>
|
||||
convertToLocalSystemFormat(
|
||||
field.customText,
|
||||
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
),
|
||||
)
|
||||
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
||||
.exhaustive()}
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED &&
|
||||
match(field)
|
||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||
field.Signature?.signatureImageAsBase64 ? (
|
||||
<img
|
||||
src={field.Signature.signatureImageAsBase64}
|
||||
alt="Signature"
|
||||
className="h-full w-full object-contain dark:invert"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{field.Signature?.typedSignature}
|
||||
</p>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
|
||||
() => field.customText,
|
||||
)
|
||||
.with({ type: FieldType.DATE }, () =>
|
||||
convertToLocalSystemFormat(
|
||||
field.customText,
|
||||
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
),
|
||||
)
|
||||
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
||||
.exhaustive()}
|
||||
|
||||
{field.Recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<p
|
||||
className={cn('text-muted-foreground text-lg duration-200', {
|
||||
'font-signature sm:text-xl md:text-2xl lg:text-3xl':
|
||||
field.type === FieldType.SIGNATURE ||
|
||||
field.type === FieldType.FREE_SIGNATURE,
|
||||
})}
|
||||
>
|
||||
{FRIENDLY_FIELD_TYPE[field.type]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FieldRootContainer>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user