diff --git a/apps/web/package.json b/apps/web/package.json index c0a3035ea..22a75128f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "formidable": "^2.1.1", "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "nanoid": "^4.0.2", "next": "13.4.12", @@ -43,6 +44,7 @@ }, "devDependencies": { "@types/formidable": "^2.0.6", + "@types/luxon": "^3.3.1", "@types/node": "20.1.0", "@types/react": "18.2.18", "@types/react-dom": "18.2.7" diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index a9d650eb6..8e242b2fd 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -22,14 +22,14 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { UploadDocument } from './upload-document'; export default async function DashboardPage() { - const session = await getRequiredServerComponentSession(); + const user = await getRequiredServerComponentSession(); const [stats, results] = await Promise.all([ getStats({ - userId: session.id, + user, }), findDocuments({ - userId: session.id, + userId: user.id, perPage: 10, }), ]); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx new file mode 100644 index 000000000..7c1d42d2b --- /dev/null +++ b/apps/web/src/app/(dashboard)/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)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx new file mode 100644 index 000000000..b1d5832f8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -0,0 +1,133 @@ +'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 { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/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 = () => { + let decodedDocument = row.document; + + try { + decodedDocument = atob(decodedDocument); + } catch (err) { + // We're just going to ignore this error and try to download the document + console.error(err); + } + + const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0))); + + 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 + + + + + Sign + + + + + + + Edit + + + + + + Download + + + + + Duplicate + + + + + Void + + + + + Delete + + + Share + + + + Resend + + + + + Share + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 35fdfb4b1..1d6c08e73 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -8,7 +8,7 @@ import { Loader } 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 { Document, Recipient, User } from '@documenso/prisma/client'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -16,8 +16,16 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a 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'; + export type DocumentsDataTableProps = { - results: FindResultSet; + results: FindResultSet< + Document & { + Recipient: Recipient[]; + User: Pick; + } + >; }; export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { @@ -45,7 +53,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Title', cell: ({ row }) => ( - + {row.original.title} ), @@ -67,6 +79,15 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { accessorKey: 'created', cell: ({ row }) => , }, + { + header: 'Actions', + cell: ({ row }) => ( +
+ + +
+ ), + }, ]} 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/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 9b64baf58..fb02ef3ef 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -7,9 +7,11 @@ import { cn } from '@documenso/ui/lib/utils'; export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + // const pathname = usePathname(); + return ( 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/apps/web/src/hooks/use-debounced-value.ts b/apps/web/src/hooks/use-debounced-value.ts new file mode 100644 index 000000000..3c57f4aa0 --- /dev/null +++ b/apps/web/src/hooks/use-debounced-value.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +export function useDebouncedValue(value: T, delay: number) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index c7f6f1a19..fcc9f7032 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -7,5 +7,6 @@ module.exports = { content: [ ...baseConfig.content, `${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`, + `${path.join(require.resolve('@documenso/email'), '..')}/**/*.{ts,tsx}`, ], }; diff --git a/package-lock.json b/package-lock.json index 8f3b7d79f..e220eff53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "formidable": "^2.1.1", "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "nanoid": "^4.0.2", "next": "13.4.12", @@ -100,6 +101,7 @@ }, "devDependencies": { "@types/formidable": "^2.0.6", + "@types/luxon": "^3.3.1", "@types/node": "20.1.0", "@types/react": "18.2.18", "@types/react-dom": "18.2.7" diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx new file mode 100644 index 000000000..b64b13cff --- /dev/null +++ b/packages/email/template-components/template-document-completed.tsx @@ -0,0 +1,71 @@ +import { Button, Img, Section, Tailwind, Text } from '@react-email/components'; + +import * as config from '@documenso/tailwind-config'; + +export interface TemplateDocumentCompletedProps { + downloadLink: string; + reviewLink: string; + documentName: string; + assetBaseUrl: string; +} + +export const TemplateDocumentCompleted = ({ + downloadLink, + reviewLink, + documentName, + assetBaseUrl, +}: TemplateDocumentCompletedProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + +
+
+ Documenso +
+ + + + Completed + + + + “{documentName}” was signed by all signers + + + + Continue by downloading or reviewing the document. + + +
+ + +
+
+
+ ); +}; + +export default TemplateDocumentCompleted; diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx new file mode 100644 index 000000000..bf2fb905e --- /dev/null +++ b/packages/email/template-components/template-document-invite.tsx @@ -0,0 +1,59 @@ +import { Button, Img, Section, Tailwind, Text } from '@react-email/components'; + +import * as config from '@documenso/tailwind-config'; + +export interface TemplateDocumentInviteProps { + inviterName: string; + inviterEmail: string; + documentName: string; + signDocumentLink: string; + assetBaseUrl: string; +} + +export const TemplateDocumentInvite = ({ + inviterName, + documentName, + signDocumentLink, + assetBaseUrl, +}: TemplateDocumentInviteProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + +
+
+ Documenso +
+ + + {inviterName} has invited you to sign "{documentName}" + + + + Continue by signing the document. + + +
+ +
+
+
+ ); +}; + +export default TemplateDocumentInvite; diff --git a/packages/email/template-components/template-document-pending.tsx b/packages/email/template-components/template-document-pending.tsx new file mode 100644 index 000000000..80387b783 --- /dev/null +++ b/packages/email/template-components/template-document-pending.tsx @@ -0,0 +1,52 @@ +import { Img, Section, Tailwind, Text } from '@react-email/components'; + +import * as config from '@documenso/tailwind-config'; + +export interface TemplateDocumentPendingProps { + documentName: string; + assetBaseUrl: string; +} + +export const TemplateDocumentPending = ({ + documentName, + assetBaseUrl, +}: TemplateDocumentPendingProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + +
+
+ Documenso +
+ + + + Waiting for others + + + + “{documentName}” has been signed + + + + We're still waiting for other signers to sign this document. +
+ We'll notify you as soon as it's ready. +
+
+
+ ); +}; + +export default TemplateDocumentPending; diff --git a/packages/email/template-components/template-footer.tsx b/packages/email/template-components/template-footer.tsx new file mode 100644 index 000000000..ee395a1e9 --- /dev/null +++ b/packages/email/template-components/template-footer.tsx @@ -0,0 +1,22 @@ +import { Link, Section, Text } from '@react-email/components'; + +export const TemplateFooter = () => { + return ( +
+ + This document was sent using{' '} + + Documenso. + + + + + Documenso +
+ 2261 Market Street, #5211, San Francisco, CA 94114, USA +
+
+ ); +}; + +export default TemplateFooter; diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx index 0d4f70601..9152d5822 100644 --- a/packages/email/templates/document-completed.tsx +++ b/packages/email/templates/document-completed.tsx @@ -1,25 +1,23 @@ import { Body, - Button, Container, Head, Html, Img, - Link, Preview, Section, Tailwind, - Text, } from '@react-email/components'; import config from '@documenso/tailwind-config'; -interface DocumentCompletedEmailTemplateProps { - downloadLink?: string; - reviewLink?: string; - documentName?: string; - assetBaseUrl?: string; -} +import { + TemplateDocumentCompleted, + TemplateDocumentCompletedProps, +} from '../template-components/template-document-completed'; +import TemplateFooter from '../template-components/template-footer'; + +export type DocumentCompletedEmailTemplateProps = Partial; export const DocumentCompletedEmailTemplate = ({ downloadLink = 'https://documenso.com', @@ -50,74 +48,23 @@ export const DocumentCompletedEmailTemplate = ({
- Documenso Logo + Documenso Logo -
-
- Documenso -
- - - - Completed - - - - “{documentName}” was signed by all signers - - - - Continue by downloading or reviewing the document. - - -
- - -
-
+
-
- - This document was sent using{' '} - - Documenso. - - - - - Documenso -
- 2261 Market Street, #5211, San Francisco, CA 94114, USA -
-
+
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index 90a32b7bf..465685649 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -1,6 +1,5 @@ import { Body, - Button, Container, Head, Hr, @@ -15,13 +14,13 @@ import { import config from '@documenso/tailwind-config'; -interface DocumentInviteEmailTemplateProps { - inviterName?: string; - inviterEmail?: string; - documentName?: string; - signDocumentLink?: string; - assetBaseUrl?: string; -} +import { + TemplateDocumentInvite, + TemplateDocumentInviteProps, +} from '../template-components/template-document-invite'; +import TemplateFooter from '../template-components/template-footer'; + +export type DocumentInviteEmailTemplateProps = Partial; export const DocumentInviteEmailTemplate = ({ inviterName = 'Lucas Smith', @@ -51,36 +50,21 @@ export const DocumentInviteEmailTemplate = ({ >
- -
- Documenso Logo + +
+ Documenso Logo -
-
- Documenso -
- - - {inviterName} has invited you to sign "{documentName}" - - - - Continue by signing the document. - - -
- -
-
+
@@ -102,20 +86,7 @@ export const DocumentInviteEmailTemplate = ({
-
- - This document was sent using{' '} - - Documenso. - - - - - Documenso -
- 2261 Market Street, #5211, San Francisco, CA 94114, USA -
-
+
diff --git a/packages/email/templates/document-pending.tsx b/packages/email/templates/document-pending.tsx index 03a69554f..0ed768747 100644 --- a/packages/email/templates/document-pending.tsx +++ b/packages/email/templates/document-pending.tsx @@ -4,19 +4,20 @@ import { Head, Html, Img, - Link, Preview, Section, Tailwind, - Text, } from '@react-email/components'; import config from '@documenso/tailwind-config'; -interface DocumentPendingEmailTemplateProps { - documentName?: string; - assetBaseUrl?: string; -} +import { + TemplateDocumentPending, + TemplateDocumentPendingProps, +} from '../template-components/template-document-pending'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentPendingEmailTemplateProps = Partial; export const DocumentPendingEmailTemplate = ({ documentName = 'Open Source Pledge.pdf', @@ -43,55 +44,20 @@ export const DocumentPendingEmailTemplate = ({ >
- -
- Documenso Logo + +
+ Documenso Logo -
-
- Documenso -
- - - - Waiting for others - - - - “{documentName}” has been signed - - - - We're still waiting for other signers to sign this document. -
- We'll notify you as soon as it's ready. -
-
+
-
- - This document was sent using{' '} - - Documenso. - - - - - Documenso -
- 2261 Market Street, #5211, San Francisco, CA 94114, USA -
-
+
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 41e9c858a..c9c8eaf6c 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,13 +1,15 @@ +import { match } from 'ts-pattern'; + import { prisma } from '@documenso/prisma'; -import { Document, DocumentStatus, Prisma } from '@documenso/prisma/client'; -import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; +import { Document, Prisma, SigningStatus } from '@documenso/prisma/client'; +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?: { @@ -19,29 +21,102 @@ export interface FindDocumentsOptions { export const findDocuments = async ({ userId, term, - status, + status = ExtendedDocumentStatus.ALL, page = 1, perPage = 10, orderBy, -}: FindDocumentsOptions): Promise> => { +}: FindDocumentsOptions) => { + 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, @@ -50,21 +125,37 @@ export const findDocuments = async ({ [orderByColumn]: orderByDirection, }, include: { + User: { + select: { + id: true, + name: true, + email: true, + }, + }, Recipient: true, }, }), prisma.document.count({ where: { + ...termFilters, ...filters, }, }), ]); + const maskedData = data.map((doc) => ({ + ...doc, + Recipient: doc.Recipient.map((recipient) => ({ + ...recipient, + token: recipient.email === user.email ? recipient.token : '', + })), + })); + return { - data, + data: maskedData, count, currentPage: Math.max(page, 1), perPage, totalPages: Math.ceil(count / perPage), - }; + } satisfies FindResultSet; }; 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/lib/types/find-result-set.ts b/packages/lib/types/find-result-set.ts index 13eab7bbd..81b16f1ca 100644 --- a/packages/lib/types/find-result-set.ts +++ b/packages/lib/types/find-result-set.ts @@ -1,5 +1,5 @@ export type FindResultSet = { - data: T[]; + data: T extends Array ? T : T[]; count: number; currentPage: number; perPage: number; 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/document-with-recipient.ts b/packages/prisma/types/document-with-recipient.ts index 208fb2b68..4ba6a9776 100644 --- a/packages/prisma/types/document-with-recipient.ts +++ b/packages/prisma/types/document-with-recipient.ts @@ -1,5 +1,5 @@ import { Document, Recipient } from '@documenso/prisma/client'; -export type DocumentWithReciepient = Document & { +export type DocumentWithRecipient = Document & { Recipient: Recipient[]; }; diff --git a/packages/prisma/types/document.ts b/packages/prisma/types/document.ts new file mode 100644 index 000000000..35a6a33b5 --- /dev/null +++ b/packages/prisma/types/document.ts @@ -0,0 +1,12 @@ +import { Document, Recipient } from '@documenso/prisma/client'; + +export type DocumentWithRecipientAndSender = Omit & { + recipient: Recipient; + sender: { + id: number; + name: string | null; + email: string; + }; + subject: string; + description: string; +}; 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/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index d2892f69f..1564454d8 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -115,6 +115,11 @@ module.exports = { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', }, + screens: { + '3xl': '1920px', + '4xl': '2560px', + '5xl': '3840px', + }, }, }, plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')], 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 ( + + + + ); +};