diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx new file mode 100644 index 000000000..7c1d42d2b --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx @@ -0,0 +1,65 @@ +'use client'; + +import Link from 'next/link'; + +import { Edit, Pencil, Share } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DataTableActionButtonProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { + 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; + const isDraft = row.status === DocumentStatus.DRAFT; + const isPending = row.status === DocumentStatus.PENDING; + const isComplete = row.status === DocumentStatus.COMPLETED; + const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + + return match({ + isOwner, + isRecipient, + isDraft, + isPending, + isComplete, + isSigned, + }) + .with({ isOwner: true, isDraft: true }, () => ( + + )) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .otherwise(() => ( + + )); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx new file mode 100644 index 000000000..72fdb4845 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx @@ -0,0 +1,124 @@ +'use client'; + +import Link from 'next/link'; + +import { + Copy, + Download, + Edit, + History, + MoreHorizontal, + Pencil, + Share, + Trash2, + XCircle, +} from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; +import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { trpc } from '@documenso/trpc/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +export type DataTableActionDropdownProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + 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; + // const isDraft = row.status === DocumentStatus.DRAFT; + // const isPending = row.status === DocumentStatus.PENDING; + const isComplete = row.status === DocumentStatus.COMPLETED; + // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + + const onDownloadClick = async () => { + let document: DocumentWithData | null = null; + + if (!recipient) { + document = await trpc.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpc.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + const documentBytes = await getFile(documentData); + + const blob = new Blob([documentBytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + + link.href = window.URL.createObjectURL(blob); + link.download = row.title || 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); + }; + + return ( + + + + + + + Action + + + + Download + + + + + Duplicate + + + + + Void + + + + + Delete + + + + + Resend + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx new file mode 100644 index 000000000..c04f9f13d --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/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)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx new file mode 100644 index 000000000..b1ab92e42 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -0,0 +1,115 @@ +'use client'; + +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'; +import { Document, Recipient, User } from '@documenso/prisma/client'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; + +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; +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< + Document & { + Recipient: Recipient[]; + User: Pick; + } + >; +}; + +export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { + const { data: session } = useSession(); + const [isPending, startTransition] = useTransition(); + + const updateSearchParams = useUpdateSearchParams(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + if (!session) { + return null; + } + + return ( +
+ , + }, + { + header: 'Title', + cell: ({ row }) => , + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: ({ row }) => { + return ( + + + + ); + }, + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( +
+ + +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx new file mode 100644 index 000000000..d62d82ada --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -0,0 +1,36 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; + +import { DocumentsDataTable } from './data-table'; + +export type DocumentsPageProps = { + searchParams?: { + page?: string; + perPage?: string; + }; +}; + +export default async function Documents({ searchParams = {} }: DocumentsPageProps) { + const user = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 20; + + const results = await findDocuments({ + userId: user.id, + orderBy: { + column: 'createdAt', + direction: 'desc', + }, + page, + perPage, + }); + + return ( +
+

Manage documents

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx index 0b59335bf..8050f867a 100644 --- a/apps/web/src/app/(dashboard)/admin/nav.tsx +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { BarChart3, User2 } from 'lucide-react'; +import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -44,6 +44,34 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => { Users + + + + ); }; diff --git a/packages/lib/server-only/admin/get-all-documents.ts b/packages/lib/server-only/admin/get-all-documents.ts new file mode 100644 index 000000000..a1abdb186 --- /dev/null +++ b/packages/lib/server-only/admin/get-all-documents.ts @@ -0,0 +1,67 @@ +import { prisma } from '@documenso/prisma'; +import { Document } from '@documenso/prisma/client'; + +export interface FindDocumentsOptions { + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Omit; + direction: 'asc' | 'desc'; + }; +} + +export const findDocuments = async ({ + term, + page = 1, + perPage = 10, + orderBy, +}: FindDocumentsOptions) => { + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const termFilters = !term + ? undefined + : ({ + title: { + contains: term, + mode: 'insensitive', + }, + } as const); + + const [data, count] = await Promise.all([ + prisma.document.findMany({ + where: { + ...termFilters, + }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + User: { + select: { + id: true, + name: true, + email: true, + }, + }, + Recipient: true, + }, + }), + prisma.document.count({ + where: { + ...termFilters, + }, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +};