From 62c4a895216f98a03ad3a751ee6a911d4d902172 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 24 Aug 2023 16:50:40 +1000 Subject: [PATCH] feat: onepage inbox --- .../app/(dashboard)/documents/data-table.tsx | 72 +++++++++++- .../src/app/(dashboard)/documents/page.tsx | 81 +++++-------- .../components/formatter/document-status.tsx | 30 +++-- .../server-only/document/find-documents.ts | 110 +++++++++++++++--- .../lib/server-only/document/get-stats.ts | 92 ++++++++++++--- .../guards/is-extended-document-status.ts | 11 ++ .../prisma/types/extended-document-status.ts | 10 ++ packages/ui/icons/signature.tsx | 28 +++++ 8 files changed, 341 insertions(+), 93 deletions(-) create mode 100644 packages/prisma/guards/is-extended-document-status.ts create mode 100644 packages/prisma/types/extended-document-status.ts create mode 100644 packages/ui/icons/signature.tsx diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 35fdfb4b1..012038f4e 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -4,13 +4,32 @@ import { useTransition } from 'react'; import Link from 'next/link'; -import { Loader } from 'lucide-react'; +import { + Copy, + Download, + Edit, + History, + Loader, + MoreHorizontal, + Pencil, + Share, + Trash2, + XCircle, +} from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; +import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; @@ -67,6 +86,57 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { accessorKey: 'created', cell: ({ row }) => , }, + { + header: 'Actions', + cell: ({ row: _row }) => ( +
+ + + + + + + + Action + + + Sign + + + + Edit + + + + Download + + + + Duplicate + + + + Void + + + + Delete + + + Share + + + Resend + + + + Share + + + +
+ ), + }, ]} data={results.data} perPage={results.perPage} diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 76675f573..4ea55936b 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -3,8 +3,8 @@ import Link from 'next/link'; 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 { isDocumentStatus } from '@documenso/lib/types/is-document-status'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; @@ -16,7 +16,7 @@ import { DocumentsDataTable } from './data-table'; export type DocumentsPageProps = { searchParams?: { - status?: InternalDocumentStatus | 'ALL'; + status?: ExtendedDocumentStatus; period?: PeriodSelectorValue; page?: string; perPage?: string; @@ -24,22 +24,20 @@ export type DocumentsPageProps = { }; export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { - const session = await getRequiredServerComponentSession(); + const user = await getRequiredServerComponentSession(); const stats = await getStats({ - userId: session.id, + user, }); - const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; + const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; // const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; - const shouldDefaultToPending = status === 'ALL' && stats.PENDING > 0; - const results = await findDocuments({ - userId: session.id, - status: status === 'ALL' ? undefined : status, + userId: user.id, + status, orderBy: { column: 'created', direction: 'desc', @@ -57,10 +55,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage params.delete('page'); } - if (value === 'ALL') { - params.delete('status'); - } - return `/documents?${params.toString()}`; }; @@ -70,47 +64,28 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage

Documents

-
- +
+ - - - + {[ + ExtendedDocumentStatus.INBOX, + ExtendedDocumentStatus.PENDING, + ExtendedDocumentStatus.COMPLETED, + ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.ALL, + ].map((value) => ( + + + - - {Math.min(stats.PENDING, 99)} - - - - - - - - - - {Math.min(stats.COMPLETED, 99)} - - - - - - - - - - {Math.min(stats.DRAFT, 99)} - - - - - - - All - - + {value !== ExtendedDocumentStatus.ALL && ( + + {Math.min(stats[value], 99)} + + )} + + + ))} diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx index 4e1ccf742..126a52f4f 100644 --- a/apps/web/src/components/formatter/document-status.tsx +++ b/apps/web/src/components/formatter/document-status.tsx @@ -3,16 +3,17 @@ import { HTMLAttributes } from 'react'; import { CheckCircle2, Clock, File } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; import { cn } from '@documenso/ui/lib/utils'; type FriendlyStatus = { label: string; - icon: LucideIcon; + icon?: LucideIcon; color: string; }; -const FRIENDLY_STATUS_MAP: Record = { +const FRIENDLY_STATUS_MAP: Record = { PENDING: { label: 'Pending', icon: Clock, @@ -28,10 +29,19 @@ const FRIENDLY_STATUS_MAP: Record = { icon: File, color: 'text-yellow-500', }, + INBOX: { + label: 'Inbox', + icon: SignatureIcon, + color: 'text-muted-foreground', + }, + ALL: { + label: 'All', + color: 'text-muted-foreground', + }, }; export type DocumentStatusProps = HTMLAttributes & { - status: InternalDocumentStatus; + status: ExtendedDocumentStatus; inheritColor?: boolean; }; @@ -45,11 +55,13 @@ export const DocumentStatus = ({ return ( - + {Icon && ( + + )} {label} ); diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 4b209f611..93b1e2089 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,14 +1,17 @@ +import { match } from 'ts-pattern'; + import { prisma } from '@documenso/prisma'; -import { Document, DocumentStatus, Prisma, SigningStatus } from '@documenso/prisma/client'; +import { Document, Prisma, SigningStatus } from '@documenso/prisma/client'; import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document'; import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { FindResultSet } from '../../types/find-result-set'; export interface FindDocumentsOptions { userId: number; term?: string; - status?: DocumentStatus; + status?: ExtendedDocumentStatus; page?: number; perPage?: number; orderBy?: { @@ -20,29 +23,102 @@ export interface FindDocumentsOptions { export const findDocuments = async ({ userId, term, - status, + status = ExtendedDocumentStatus.ALL, page = 1, perPage = 10, orderBy, }: FindDocumentsOptions): Promise> => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + const orderByColumn = orderBy?.column ?? 'created'; const orderByDirection = orderBy?.direction ?? 'desc'; - const filters: Prisma.DocumentWhereInput = { - status, - userId, - }; + const termFilters = !term + ? undefined + : ({ + title: { + contains: term, + mode: 'insensitive', + }, + } as const); - if (term) { - filters.title = { - contains: term, - mode: 'insensitive', - }; - } + const filters = match(status) + .with(ExtendedDocumentStatus.ALL, () => ({ + OR: [ + { + userId, + }, + { + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: user.email, + }, + }, + }, + ], + })) + .with(ExtendedDocumentStatus.INBOX, () => ({ + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.NOT_SIGNED, + }, + }, + })) + .with(ExtendedDocumentStatus.DRAFT, () => ({ + userId, + status: ExtendedDocumentStatus.DRAFT, + })) + .with(ExtendedDocumentStatus.PENDING, () => ({ + OR: [ + { + userId, + status: ExtendedDocumentStatus.PENDING, + }, + { + status: ExtendedDocumentStatus.PENDING, + + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.SIGNED, + }, + }, + }, + ], + })) + .with(ExtendedDocumentStatus.COMPLETED, () => ({ + OR: [ + { + userId, + status: ExtendedDocumentStatus.COMPLETED, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + }, + }, + }, + ], + })) + .exhaustive(); const [data, count] = await Promise.all([ prisma.document.findMany({ where: { + ...termFilters, ...filters, }, skip: Math.max(page - 1, 0) * perPage, @@ -51,11 +127,19 @@ export const findDocuments = async ({ [orderByColumn]: orderByDirection, }, include: { + User: { + select: { + id: true, + name: true, + email: true, + }, + }, Recipient: true, }, }), prisma.document.count({ where: { + ...termFilters, ...filters, }, }), diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 25754d7bc..6e875f9be 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,30 +1,88 @@ import { prisma } from '@documenso/prisma'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { SigningStatus, User } from '@documenso/prisma/client'; +import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; export type GetStatsInput = { - userId: number; + user: User; }; -export const getStats = async ({ userId }: GetStatsInput) => { - const result = await prisma.document.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - userId, - }, - }); +export const getStats = async ({ user }: GetStatsInput) => { + const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ + prisma.document.groupBy({ + by: ['status'], + _count: { + _all: true, + }, + where: { + userId: user.id, + }, + }), + prisma.document.groupBy({ + by: ['status'], + _count: { + _all: true, + }, + where: { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.NOT_SIGNED, + }, + }, + }, + }), + prisma.document.groupBy({ + by: ['status'], + _count: { + _all: true, + }, + where: { + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.SIGNED, + }, + }, + }, + }), + ]); - const stats: Record = { - [DocumentStatus.DRAFT]: 0, - [DocumentStatus.PENDING]: 0, - [DocumentStatus.COMPLETED]: 0, + const stats: Record = { + [ExtendedDocumentStatus.DRAFT]: 0, + [ExtendedDocumentStatus.PENDING]: 0, + [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.INBOX]: 0, + [ExtendedDocumentStatus.ALL]: 0, }; - result.forEach((stat) => { + ownerCounts.forEach((stat) => { stats[stat.status] = stat._count._all; }); + notSignedCounts.forEach((stat) => { + stats[ExtendedDocumentStatus.INBOX] += stat._count._all; + }); + + hasSignedCounts.forEach((stat) => { + if (stat.status === ExtendedDocumentStatus.COMPLETED) { + stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; + } + + if (stat.status === ExtendedDocumentStatus.PENDING) { + stats[ExtendedDocumentStatus.PENDING] += stat._count._all; + } + }); + + Object.keys(stats).forEach((key) => { + if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { + stats[ExtendedDocumentStatus.ALL] += stats[key]; + } + }); + return stats; }; diff --git a/packages/prisma/guards/is-extended-document-status.ts b/packages/prisma/guards/is-extended-document-status.ts new file mode 100644 index 000000000..43be73f64 --- /dev/null +++ b/packages/prisma/guards/is-extended-document-status.ts @@ -0,0 +1,11 @@ +import { ExtendedDocumentStatus } from '../types/extended-document-status'; + +export const isExtendedDocumentStatus = (value: unknown): value is ExtendedDocumentStatus => { + if (typeof value !== 'string') { + return false; + } + + // We're using the assertion for a type-guard so it's safe to ignore the eslint warning + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return Object.values(ExtendedDocumentStatus).includes(value as ExtendedDocumentStatus); +}; diff --git a/packages/prisma/types/extended-document-status.ts b/packages/prisma/types/extended-document-status.ts new file mode 100644 index 000000000..a3576750d --- /dev/null +++ b/packages/prisma/types/extended-document-status.ts @@ -0,0 +1,10 @@ +import { DocumentStatus } from '@prisma/client'; + +export const ExtendedDocumentStatus = { + ...DocumentStatus, + INBOX: 'INBOX', + ALL: 'ALL', +} as const; + +export type ExtendedDocumentStatus = + (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus]; diff --git a/packages/ui/icons/signature.tsx b/packages/ui/icons/signature.tsx new file mode 100644 index 000000000..b91998bb5 --- /dev/null +++ b/packages/ui/icons/signature.tsx @@ -0,0 +1,28 @@ +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +export const SignatureIcon: LucideIcon = ({ + size = 24, + color = 'currentColor', + strokeWidth = 1.33, + absoluteStrokeWidth, + ...props +}) => { + return ( + + + + ); +};