diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 09760f806..fa6c0d1ac 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); +const { version } = require('./package.json'); const { parsed: env } = require('dotenv').config({ path: path.join(__dirname, '../../.env.local'), @@ -18,7 +19,10 @@ const config = { '@documenso/ui', '@documenso/email', ], - env, + env: { + ...env, + APP_VERSION: version, + }, modularizeImports: { 'lucide-react': { transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx new file mode 100644 index 000000000..3aa47d1a9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -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 ( +
+
+ + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx new file mode 100644 index 000000000..3b87a9b13 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -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; + +export const AdminNav = ({ className, ...props }: AdminNavProps) => { + const pathname = usePathname(); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 000000000..5fe030685 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Admin() { + redirect('/admin/stats'); +} diff --git a/apps/web/src/app/(dashboard)/admin/stats/page.tsx b/apps/web/src/app/(dashboard)/admin/stats/page.tsx new file mode 100644 index 000000000..b93af5a03 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/stats/page.tsx @@ -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 ( +
+

Instance Stats

+ +
+ + + + +
+ +
+
+

Document metrics

+ +
+ + + + +
+
+ +
+

Recipients metrics

+ +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx deleted file mode 100644 index 77b18b98c..000000000 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import Link from 'next/link'; - -import { Clock, File, FileCheck } from 'lucide-react'; - -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; -import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; -import { getStats } from '@documenso/lib/server-only/document/get-stats'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@documenso/ui/primitives/table'; - -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card'; -import { DocumentStatus } from '~/components/formatter/document-status'; -import { LocaleDate } from '~/components/formatter/locale-date'; - -import { UploadDocument } from './upload-document'; - -const CARD_DATA = [ - { - icon: FileCheck, - title: 'Completed', - status: InternalDocumentStatus.COMPLETED, - }, - { - icon: File, - title: 'Drafts', - status: InternalDocumentStatus.DRAFT, - }, - { - icon: Clock, - title: 'Pending', - status: InternalDocumentStatus.PENDING, - }, -]; - -export default async function DashboardPage() { - const user = await getRequiredServerComponentSession(); - - const [stats, results] = await Promise.all([ - getStats({ - user, - }), - findDocuments({ - userId: user.id, - perPage: 10, - }), - ]); - - return ( -
-

Dashboard

- -
- {CARD_DATA.map((card) => ( - - - - ))} -
- -
- - -

Recent Documents

- -
- - - - ID - Title - Reciepient - Status - Created - - - - {results.data.map((document) => { - return ( - - {document.id} - - - {document.title} - - - - - - - - - - - - - - - ); - })} - {results.data.length === 0 && ( - - - No results. - - - )} - -
-
-
-
- ); -} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 7ed28feca..ba134ac58 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -136,7 +136,7 @@ export const EditDocumentForm = ({ duration: 5000, }); - router.push('/dashboard'); + router.push('/documents'); } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-title.tsx b/apps/web/src/app/(dashboard)/documents/data-table-title.tsx new file mode 100644 index 000000000..c04f9f13d --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/data-table-title.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { Document, Recipient, User } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = row.User.id === session.user.id; + const isRecipient = !!recipient; + + return match({ + isOwner, + isRecipient, + }) + .with({ isOwner: true }, () => ( + + {row.title} + + )) + .with({ isRecipient: true }, () => ( + + {row.title} + + )) + .otherwise(() => ( + + {row.title} + + )); +}; diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 1d6c08e73..b8c735b59 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -2,9 +2,8 @@ import { useTransition } from 'react'; -import Link from 'next/link'; - import { Loader } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; @@ -18,6 +17,7 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { DataTableActionButton } from './data-table-action-button'; import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; export type DocumentsDataTableProps = { results: FindResultSet< @@ -29,6 +29,7 @@ export type DocumentsDataTableProps = { }; export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { + const { data: session } = useSession(); const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); @@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { }); }; + if (!session) { + return null; + } + return (
, }, { header: 'Title', - cell: ({ row }) => ( - - {row.original.title} - - ), + cell: ({ row }) => , }, { header: 'Recipient', @@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { accessorKey: 'status', cell: ({ row }) => , }, - { - header: 'Created', - accessorKey: 'created', - cell: ({ row }) => , - }, { header: 'Actions', cell: ({ row }) => ( @@ -95,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { totalPages={results.totalPages} onPaginationChange={onPaginationChange} > - {(table) => } + {(table) => } {isPending && ( diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 4ea55936b..d1f558806 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period- import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { DocumentStatus } from '~/components/formatter/document-status'; -import { UploadDocument } from '../dashboard/upload-document'; import { DocumentsDataTable } from './data-table'; +import { UploadDocument } from './upload-document'; export type DocumentsPageProps = { searchParams?: { @@ -81,6 +81,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage {value !== ExtendedDocumentStatus.ALL && ( {Math.min(stats[value], 99)} + {stats[value] > 99 && '+'} )} diff --git a/apps/web/src/app/(dashboard)/dashboard/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx similarity index 100% rename from apps/web/src/app/(dashboard)/dashboard/upload-document.tsx rename to apps/web/src/app/(dashboard)/documents/upload-document.tsx diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1d1e056ae..2ce8744d4 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,8 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { LocaleProvider } from '@documenso/lib/client-only/providers/locale'; +import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { TrpcProvider } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Toaster } from '@documenso/ui/primitives/toaster'; @@ -45,6 +47,8 @@ export const metadata = { export default async function RootLayout({ children }: { children: React.ReactNode }) { const flags = await getServerComponentAllFlags(); + const locale = getLocale(); + return ( - - - - - {children} - - - - - + + + + + + {children} + + + + + + ); diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx index 78814cd45..a2a81bb2a 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx @@ -15,7 +15,7 @@ export type StackAvatarProps = { type: 'unsigned' | 'waiting' | 'opened' | 'completed'; }; -export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => { +export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => { let classes = ''; let zIndexClass = ''; const firstClass = first ? '' : '-ml-3'; @@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr ${firstClass} dark:border-border h-10 w-10 border-2 border-solid border-white`} > - {fallbackText ?? 'UK'} + {fallbackText} ); }; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 2a053a35a..e36415813 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,5 +1,5 @@ -import { initials } from '@documenso/lib/client-only/recipient-initials'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { Recipient } from '@documenso/prisma/client'; import { Tooltip, @@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email}
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email} @@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email} @@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email} diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx index 97af9dc9e..91f470f74 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { initials } from '@documenso/lib/client-only/recipient-initials'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { Recipient } from '@documenso/prisma/client'; import { StackAvatar } from './stack-avatar'; @@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) { first={first} zIndex={String(zIndex - index * 10)} type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)} - fallbackText={lastItemText ? lastItemText : initials(recipient.name)} + fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)} /> ); }); diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 02af86d70..9ae9b4297 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -11,10 +11,13 @@ import { Monitor, Moon, Sun, + UserCog, } from 'lucide-react'; import { signOut } from 'next-auth/react'; import { useTheme } from 'next-themes'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; +import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import { User } from '@documenso/prisma/client'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; @@ -35,24 +38,21 @@ export type ProfileDropdownProps = { export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { const { theme, setTheme } = useTheme(); - const { getFlag } = useFeatureFlags(); + const isUserAdmin = isAdmin(user); const isBillingEnabled = getFlag('app_billing'); - const initials = - user.name - ?.split(' ') - .map((name: string) => name.slice(0, 1).toUpperCase()) - .slice(0, 2) - .join('') ?? 'UK'; + const avatarFallback = user.name + ? recipientInitials(user.name) + : user.email.slice(0, 1).toUpperCase(); return ( @@ -60,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { Account + {isUserAdmin && ( + <> + + + + Admin + + + + + + )} + diff --git a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx index f59d42096..a2248ccdc 100644 --- a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx +++ b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx @@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr )} >
-
- {Icon && } +
+ {Icon && } -

{title}

+

{title}

diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx index 837c6aa38..ecefb1e3b 100644 --- a/apps/web/src/components/formatter/locale-date.tsx +++ b/apps/web/src/components/formatter/locale-date.tsx @@ -2,16 +2,31 @@ import { HTMLAttributes, useEffect, useState } from 'react'; +import { DateTime, DateTimeFormatOptions } from 'luxon'; + +import { useLocale } from '@documenso/lib/client-only/providers/locale'; + export type LocaleDateProps = HTMLAttributes & { date: string | number | Date; + format?: DateTimeFormatOptions; }; -export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => { - const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString()); +/** + * Formats the date based on the user locale. + * + * Will use the estimated locale from the user headers on SSR, then will use + * the client browser locale once mounted. + */ +export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => { + const { locale } = useLocale(); + + const [localeDate, setLocaleDate] = useState(() => + DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format), + ); useEffect(() => { - setLocaleDate(new Date(date).toLocaleString()); - }, [date]); + setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format)); + }, [date, format]); return ( diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 5e44146ea..d9d727afc 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -18,13 +18,15 @@ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ErrorMessages = { +const ERROR_MESSAGES = { [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', [ErrorCode.USER_MISSING_PASSWORD]: 'This account appears to be using a social login method, please sign in using that method', }; +const LOGIN_REDIRECT_PATH = '/documents'; + export const ZSignInFormSchema = z.object({ email: z.string().email().min(1), password: z.string().min(6).max(72), @@ -37,9 +39,10 @@ export type SignInFormProps = { }; export const SignInForm = ({ className }: SignInFormProps) => { - const { toast } = useToast(); const searchParams = useSearchParams(); + const { toast } = useToast(); + const { register, handleSubmit, @@ -61,7 +64,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { timeout = setTimeout(() => { toast({ variant: 'destructive', - description: ErrorMessages[errorCode] ?? 'An unknown error occurred', + description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred', }); }, 0); } @@ -78,12 +81,10 @@ export const SignInForm = ({ className }: SignInFormProps) => { await signIn('credentials', { email, password, - callbackUrl: '/documents', + callbackUrl: LOGIN_REDIRECT_PATH, }).catch((err) => { console.error(err); }); - - // throw new Error('Not implemented'); } catch (err) { toast({ title: 'An unknown error occurred', @@ -95,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { const onSignInWithGoogleClick = async () => { try { - await signIn('google', { callbackUrl: '/dashboard' }); - // throw new Error('Not implemented'); + await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH }); } catch (err) { toast({ title: 'An unknown error occurred', diff --git a/packages/lib/client-only/providers/locale.tsx b/packages/lib/client-only/providers/locale.tsx new file mode 100644 index 000000000..ff8b03e5a --- /dev/null +++ b/packages/lib/client-only/providers/locale.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export type LocaleContextValue = { + locale: string; +}; + +export const LocaleContext = createContext(null); + +export const useLocale = () => { + const context = useContext(LocaleContext); + + if (!context) { + throw new Error('useLocale must be used within a LocaleProvider'); + } + + return context; +}; + +export function LocaleProvider({ + children, + locale, +}: { + children: React.ReactNode; + locale: string; +}) { + return ( + + {children} + + ); +} diff --git a/packages/lib/client-only/recipient-initials.ts b/packages/lib/client-only/recipient-initials.ts deleted file mode 100644 index 0712ccd7d..000000000 --- a/packages/lib/client-only/recipient-initials.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const initials = (text: string) => - text - ?.split(' ') - .map((name: string) => name.slice(0, 1).toUpperCase()) - .slice(0, 2) - .join('') ?? 'UK'; diff --git a/packages/lib/next-auth/guards/is-admin.ts b/packages/lib/next-auth/guards/is-admin.ts new file mode 100644 index 000000000..2801305dd --- /dev/null +++ b/packages/lib/next-auth/guards/is-admin.ts @@ -0,0 +1,5 @@ +import { Role, User } from '@documenso/prisma/client'; + +const isAdmin = (user: User) => user.roles.includes(Role.ADMIN); + +export { isAdmin }; diff --git a/packages/lib/server-only/admin/get-documents-stats.ts b/packages/lib/server-only/admin/get-documents-stats.ts new file mode 100644 index 000000000..e0d53373f --- /dev/null +++ b/packages/lib/server-only/admin/get-documents-stats.ts @@ -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, 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; +}; diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts new file mode 100644 index 000000000..f24d0b5a2 --- /dev/null +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -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; +}; diff --git a/packages/lib/server-only/admin/get-users-stats.ts b/packages/lib/server-only/admin/get-users-stats.ts new file mode 100644 index 000000000..09892171a --- /dev/null +++ b/packages/lib/server-only/admin/get-users-stats.ts @@ -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, + }, + }, + }, + }); +}; diff --git a/packages/lib/utils/recipient-formatter.ts b/packages/lib/utils/recipient-formatter.ts new file mode 100644 index 000000000..da404830b --- /dev/null +++ b/packages/lib/utils/recipient-formatter.ts @@ -0,0 +1,12 @@ +import { Recipient } from '@documenso/prisma/client'; + +export const recipientInitials = (text: string) => + text + .split(' ') + .map((name: string) => name.slice(0, 1).toUpperCase()) + .slice(0, 2) + .join(''); + +export const recipientAbbreviation = (recipient: Recipient) => { + return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase(); +}; diff --git a/packages/prisma/migrations/20230907075057_user_roles/migration.sql b/packages/prisma/migrations/20230907075057_user_roles/migration.sql new file mode 100644 index 000000000..f47e48361 --- /dev/null +++ b/packages/prisma/migrations/20230907075057_user_roles/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[]; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2e016f5ec..22955310b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -13,6 +13,11 @@ enum IdentityProvider { GOOGLE } +enum Role { + ADMIN + USER +} + model User { id Int @id @default(autoincrement()) name String? @@ -21,6 +26,7 @@ model User { password String? source String? signature String? + roles Role[] @default([USER]) identityProvider IdentityProvider @default(DOCUMENSO) accounts Account[] sessions Session[] diff --git a/packages/ui/primitives/data-table-pagination.tsx b/packages/ui/primitives/data-table-pagination.tsx index 0ff27ae11..8147c92fb 100644 --- a/packages/ui/primitives/data-table-pagination.tsx +++ b/packages/ui/primitives/data-table-pagination.tsx @@ -1,19 +1,46 @@ import { Table } from '@tanstack/react-table'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { match } from 'ts-pattern'; import { Button } from './button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; interface DataTablePaginationProps { table: Table; + + /** + * The type of information to show on the left hand side of the pagination. + * + * Defaults to 'VisibleCount'. + */ + additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None'; } -export function DataTablePagination({ table }: DataTablePaginationProps) { +export function DataTablePagination({ + table, + additionalInformation = 'VisibleCount', +}: DataTablePaginationProps) { return (

- {table.getFilteredSelectedRowModel().rows.length} of{' '} - {table.getFilteredRowModel().rows.length} row(s) selected. + {match(additionalInformation) + .with('SelectedCount', () => ( + + {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected. + + )) + .with('VisibleCount', () => { + const visibleRows = table.getFilteredRowModel().rows.length; + + return ( + + Showing {visibleRows} result{visibleRows > 1 && 's'}. + + ); + }) + .with('None', () => null) + .exhaustive()}
diff --git a/turbo.json b/turbo.json index f7d3d342c..6dc2735e1 100644 --- a/turbo.json +++ b/turbo.json @@ -2,13 +2,8 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - ".next/**", - "!.next/cache/**" - ] + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**"] }, "lint": {}, "dev": { @@ -16,10 +11,9 @@ "persistent": true } }, - "globalDependencies": [ - "**/.env.*local" - ], + "globalDependencies": ["**/.env.*local"], "globalEnv": [ + "APP_VERSION", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_APP_URL",