mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Merge pull request #361 from documenso/feat/admin-ui-metrics
feat: admin ui for metrics
This commit is contained in:
@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
const { parsed: env } = require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
@ -18,7 +19,10 @@ const config = {
|
|||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
'@documenso/email',
|
||||||
],
|
],
|
||||||
env,
|
env: {
|
||||||
|
...env,
|
||||||
|
APP_VERSION: version,
|
||||||
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
|||||||
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
|
import { AdminNav } from './nav';
|
||||||
|
|
||||||
|
export type AdminSectionLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
|
const user = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
if (!isAdmin(user)) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
|
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
|
||||||
|
|
||||||
|
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { BarChart3, User2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/stats') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/stats">
|
||||||
|
<BarChart3 className="mr-2 h-5 w-5" />
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'justify-start md:w-full',
|
||||||
|
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<User2 className="mr-2 h-5 w-5" />
|
||||||
|
Users (Coming Soon)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
redirect('/admin/stats');
|
||||||
|
}
|
||||||
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
File,
|
||||||
|
FileCheck,
|
||||||
|
FileClock,
|
||||||
|
FileEdit,
|
||||||
|
Mail,
|
||||||
|
MailOpen,
|
||||||
|
PenTool,
|
||||||
|
User as UserIcon,
|
||||||
|
UserPlus2,
|
||||||
|
UserSquare2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
|
import {
|
||||||
|
getUsersCount,
|
||||||
|
getUsersWithSubscriptionsCount,
|
||||||
|
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
|
|
||||||
|
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||||
|
|
||||||
|
export default async function AdminStatsPage() {
|
||||||
|
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
|
||||||
|
getUsersCount(),
|
||||||
|
getUsersWithSubscriptionsCount(),
|
||||||
|
getDocumentStats(),
|
||||||
|
getRecipientsStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
|
||||||
|
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||||
|
<CardMetric
|
||||||
|
icon={UserPlus2}
|
||||||
|
title="Active Subscriptions"
|
||||||
|
value={usersWithSubscriptionsCount}
|
||||||
|
/>
|
||||||
|
<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-2">
|
||||||
|
<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} />
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||||
|
|
||||||
|
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||||
|
<CardMetric
|
||||||
|
icon={UserSquare2}
|
||||||
|
title="Total Recipients"
|
||||||
|
value={recipientStats.TOTAL_RECIPIENTS}
|
||||||
|
/>
|
||||||
|
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
||||||
|
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
||||||
|
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,10 +11,12 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { User } from '@documenso/prisma/client';
|
import { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
@ -36,8 +38,8 @@ export type ProfileDropdownProps = {
|
|||||||
|
|
||||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
@ -58,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{isUserAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/admin" className="cursor-pointer">
|
||||||
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/profile" className="cursor-pointer">
|
<Link href="/settings/profile" className="cursor-pointer">
|
||||||
<LucideUser className="mr-2 h-4 w-4" />
|
<LucideUser className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-center">
|
||||||
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
|
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
|
||||||
|
|
||||||
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3>
|
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
||||||
|
|||||||
5
packages/lib/next-auth/guards/is-admin.ts
Normal file
5
packages/lib/next-auth/guards/is-admin.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Role, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||||
|
|
||||||
|
export { isAdmin };
|
||||||
26
packages/lib/server-only/admin/get-documents-stats.ts
Normal file
26
packages/lib/server-only/admin/get-documents-stats.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
|
export const getDocumentStats = async () => {
|
||||||
|
const counts = await prisma.document.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: {
|
||||||
|
_all: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
|
||||||
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
counts.forEach((stat) => {
|
||||||
|
stats[stat.status] = stat._count._all;
|
||||||
|
|
||||||
|
stats.ALL += stat._count._all;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
};
|
||||||
29
packages/lib/server-only/admin/get-recipients-stats.ts
Normal file
29
packages/lib/server-only/admin/get-recipients-stats.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const getRecipientsStats = async () => {
|
||||||
|
const results = await prisma.recipient.groupBy({
|
||||||
|
by: ['readStatus', 'signingStatus', 'sendStatus'],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
TOTAL_RECIPIENTS: 0,
|
||||||
|
[ReadStatus.OPENED]: 0,
|
||||||
|
[ReadStatus.NOT_OPENED]: 0,
|
||||||
|
[SigningStatus.SIGNED]: 0,
|
||||||
|
[SigningStatus.NOT_SIGNED]: 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;
|
||||||
|
};
|
||||||
18
packages/lib/server-only/admin/get-users-stats.ts
Normal file
18
packages/lib/server-only/admin/get-users-stats.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const getUsersCount = async () => {
|
||||||
|
return await prisma.user.count();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUsersWithSubscriptionsCount = async () => {
|
||||||
|
return await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
Subscription: {
|
||||||
|
some: {
|
||||||
|
status: SubscriptionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];
|
||||||
@ -13,6 +13,11 @@ enum IdentityProvider {
|
|||||||
GOOGLE
|
GOOGLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
@ -21,6 +26,7 @@ model User {
|
|||||||
password String?
|
password String?
|
||||||
source String?
|
source String?
|
||||||
signature String?
|
signature String?
|
||||||
|
roles Role[] @default([USER])
|
||||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|||||||
14
turbo.json
14
turbo.json
@ -2,13 +2,8 @@
|
|||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^build"],
|
||||||
"^build"
|
"outputs": [".next/**", "!.next/cache/**"]
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".next/**",
|
|
||||||
"!.next/cache/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"dev": {
|
"dev": {
|
||||||
@ -16,10 +11,9 @@
|
|||||||
"persistent": true
|
"persistent": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalDependencies": [
|
"globalDependencies": ["**/.env.*local"],
|
||||||
"**/.env.*local"
|
|
||||||
],
|
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
|
"APP_VERSION",
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
"NEXT_PUBLIC_APP_URL",
|
"NEXT_PUBLIC_APP_URL",
|
||||||
|
|||||||
Reference in New Issue
Block a user