diff --git a/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx b/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx new file mode 100644 index 000000000..8526d4712 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/leaderboard/data-table-leaderboard.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useEffect, useMemo, useState, useTransition } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Input } from '@documenso/ui/primitives/input'; + +export type SigningVolume = { + id: number; + name: string; + signingVolume: number; + createdAt: Date; + planId: string; +}; + +type LeaderboardTableProps = { + signingVolume: SigningVolume[]; + totalPages: number; + perPage: number; + page: number; + sortBy: 'name' | 'createdAt' | 'signingVolume'; + sortOrder: 'asc' | 'desc'; +}; + +export const LeaderboardTable = ({ + signingVolume, + totalPages, + perPage, + page, + sortBy, + sortOrder, +}: LeaderboardTableProps) => { + const { _, i18n } = useLingui(); + + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + const [searchString, setSearchString] = useState(''); + const debouncedSearchString = useDebouncedValue(searchString, 1000); + + const columns = useMemo(() => { + return [ + { + header: () => ( +
handleColumnSort('name')} + > + {_(msg`Name`)} + +
+ ), + accessorKey: 'name', + cell: ({ row }) => { + return ( +
+ + {row.getValue('name')} + +
+ ); + }, + size: 250, + }, + { + header: () => ( +
handleColumnSort('signingVolume')} + > + {_(msg`Signing Volume`)} + +
+ ), + accessorKey: 'signingVolume', + cell: ({ row }) =>
{Number(row.getValue('signingVolume'))}
, + }, + { + header: () => { + return ( +
handleColumnSort('createdAt')} + > + {_(msg`Created`)} + +
+ ); + }, + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + ] satisfies DataTableColumnDef[]; + }, [sortOrder]); + + useEffect(() => { + startTransition(() => { + updateSearchParams({ + search: debouncedSearchString, + page: 1, + perPage, + sortBy, + sortOrder, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchString]); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const handleChange = (e: React.ChangeEvent) => { + setSearchString(e.target.value); + }; + + const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => { + startTransition(() => { + updateSearchParams({ + sortBy: column, + sortOrder: sortOrder === 'asc' ? 'desc' : 'asc', + }); + }); + }; + + return ( +
+ + + {(table) => } + + + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/leaderboard/fetch-leaderboard.actions.ts b/apps/web/src/app/(dashboard)/admin/leaderboard/fetch-leaderboard.actions.ts new file mode 100644 index 000000000..42fc20c97 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/leaderboard/fetch-leaderboard.actions.ts @@ -0,0 +1,25 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; +import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume'; + +type SearchOptions = { + search: string; + page: number; + perPage: number; + sortBy: 'name' | 'createdAt' | 'signingVolume'; + sortOrder: 'asc' | 'desc'; +}; + +export async function search({ search, page, perPage, sortBy, sortOrder }: SearchOptions) { + const { user } = await getRequiredServerComponentSession(); + + if (!isAdmin(user)) { + throw new Error('Unauthorized'); + } + + const results = await getSigningVolume({ search, page, perPage, sortBy, sortOrder }); + + return results; +} diff --git a/apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx b/apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx new file mode 100644 index 000000000..0d1c5172a --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx @@ -0,0 +1,60 @@ +import { Trans } from '@lingui/macro'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; + +import { LeaderboardTable } from './data-table-leaderboard'; +import { search } from './fetch-leaderboard.actions'; + +type AdminLeaderboardProps = { + searchParams?: { + search?: string; + page?: number; + perPage?: number; + sortBy?: 'name' | 'createdAt' | 'signingVolume'; + sortOrder?: 'asc' | 'desc'; + }; +}; + +export default async function Leaderboard({ searchParams = {} }: AdminLeaderboardProps) { + await setupI18nSSR(); + + const { user } = await getRequiredServerComponentSession(); + + if (!isAdmin(user)) { + throw new Error('Unauthorized'); + } + + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + const searchString = searchParams.search || ''; + const sortBy = searchParams.sortBy || 'signingVolume'; + const sortOrder = searchParams.sortOrder || 'desc'; + + const { leaderboard: signingVolume, totalPages } = await search({ + search: searchString, + page, + perPage, + sortBy, + sortOrder, + }); + + return ( +
+

+ Signing Volume +

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx index cf0bb81f2..bcae0fc75 100644 --- a/apps/web/src/app/(dashboard)/admin/nav.tsx +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Trans } from '@lingui/macro'; -import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react'; +import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -80,6 +80,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => { + +