feat: onepage inbox

This commit is contained in:
Mythie
2023-08-24 16:50:40 +10:00
parent 8fd9730e2b
commit 1f8d5e45e1
8 changed files with 341 additions and 93 deletions

View File

@ -4,13 +4,32 @@ import { useTransition } from 'react';
import Link from 'next/link'; 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 { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set'; import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; 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 { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; 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 { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status'; import { DocumentStatus } from '~/components/formatter/document-status';
@ -67,6 +86,57 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'created', accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />, cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
}, },
{
header: 'Actions',
cell: ({ row: _row }) => (
<div className="flex items-center gap-x-4">
<Button>Action</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-gray-500" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled>
<Pencil className="mr-2 h-4 w-4" />
Sign
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Share className="mr-2 h-4 w-4" />
Share
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
]} ]}
data={results.data} data={results.data}
perPage={results.perPage} perPage={results.perPage}

View File

@ -3,8 +3,8 @@ import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isDocumentStatus } from '@documenso/lib/types/is-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
@ -16,7 +16,7 @@ import { DocumentsDataTable } from './data-table';
export type DocumentsPageProps = { export type DocumentsPageProps = {
searchParams?: { searchParams?: {
status?: InternalDocumentStatus | 'ALL'; status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue; period?: PeriodSelectorValue;
page?: string; page?: string;
perPage?: string; perPage?: string;
@ -24,22 +24,20 @@ export type DocumentsPageProps = {
}; };
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const session = await getRequiredServerComponentSession(); const user = await getRequiredServerComponentSession();
const stats = await getStats({ 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 period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const shouldDefaultToPending = status === 'ALL' && stats.PENDING > 0;
const results = await findDocuments({ const results = await findDocuments({
userId: session.id, userId: user.id,
status: status === 'ALL' ? undefined : status, status,
orderBy: { orderBy: {
column: 'created', column: 'created',
direction: 'desc', direction: 'desc',
@ -57,10 +55,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
params.delete('page'); params.delete('page');
} }
if (value === 'ALL') {
params.delete('status');
}
return `/documents?${params.toString()}`; return `/documents?${params.toString()}`;
}; };
@ -70,47 +64,28 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<h1 className="mt-12 text-4xl font-semibold">Documents</h1> <h1 className="mt-12 text-4xl font-semibold">Documents</h1>
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6 "> <div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
<Tabs <Tabs defaultValue={status} className="overflow-x-auto">
className="overflow-x-auto"
defaultValue={shouldDefaultToPending ? InternalDocumentStatus.PENDING : status}
>
<TabsList> <TabsList>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild> {[
<Link href={getTabHref(InternalDocumentStatus.PENDING)} scroll={false}> ExtendedDocumentStatus.INBOX,
<DocumentStatus status={InternalDocumentStatus.PENDING} /> ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
<span className="ml-1 hidden opacity-50 md:inline-block"> {value !== ExtendedDocumentStatus.ALL && (
{Math.min(stats.PENDING, 99)} <span className="ml-1 hidden opacity-50 md:inline-block">
</span> {Math.min(stats[value], 99)}
</Link> </span>
</TabsTrigger> )}
</Link>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild> </TabsTrigger>
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)} scroll={false}> ))}
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.COMPLETED, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
<Link href={getTabHref(InternalDocumentStatus.DRAFT)} scroll={false}>
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.DRAFT, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
<Link href={getTabHref('ALL')} scroll={false}>
All
</Link>
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View File

@ -3,16 +3,17 @@ import { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react'; import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/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'; import { cn } from '@documenso/ui/lib/utils';
type FriendlyStatus = { type FriendlyStatus = {
label: string; label: string;
icon: LucideIcon; icon?: LucideIcon;
color: string; color: string;
}; };
const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = { const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: { PENDING: {
label: 'Pending', label: 'Pending',
icon: Clock, icon: Clock,
@ -28,10 +29,19 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
icon: File, icon: File,
color: 'text-yellow-500', color: 'text-yellow-500',
}, },
INBOX: {
label: 'Inbox',
icon: SignatureIcon,
color: 'text-muted-foreground',
},
ALL: {
label: 'All',
color: 'text-muted-foreground',
},
}; };
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & { export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: InternalDocumentStatus; status: ExtendedDocumentStatus;
inheritColor?: boolean; inheritColor?: boolean;
}; };
@ -45,11 +55,13 @@ export const DocumentStatus = ({
return ( return (
<span className={cn('flex items-center', className)} {...props}> <span className={cn('flex items-center', className)} {...props}>
<Icon {Icon && (
className={cn('mr-2 inline-block h-4 w-4', { <Icon
[color]: !inheritColor, className={cn('mr-2 inline-block h-4 w-4', {
})} [color]: !inheritColor,
/> })}
/>
)}
{label} {label}
</span> </span>
); );

View File

@ -1,14 +1,17 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; 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 { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document';
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; 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'; import { FindResultSet } from '../../types/find-result-set';
export interface FindDocumentsOptions { export interface FindDocumentsOptions {
userId: number; userId: number;
term?: string; term?: string;
status?: DocumentStatus; status?: ExtendedDocumentStatus;
page?: number; page?: number;
perPage?: number; perPage?: number;
orderBy?: { orderBy?: {
@ -20,29 +23,102 @@ export interface FindDocumentsOptions {
export const findDocuments = async ({ export const findDocuments = async ({
userId, userId,
term, term,
status, status = ExtendedDocumentStatus.ALL,
page = 1, page = 1,
perPage = 10, perPage = 10,
orderBy, orderBy,
}: FindDocumentsOptions): Promise<FindResultSet<DocumentWithReciepient>> => { }: FindDocumentsOptions): Promise<FindResultSet<DocumentWithReciepient>> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const orderByColumn = orderBy?.column ?? 'created'; const orderByColumn = orderBy?.column ?? 'created';
const orderByDirection = orderBy?.direction ?? 'desc'; const orderByDirection = orderBy?.direction ?? 'desc';
const filters: Prisma.DocumentWhereInput = { const termFilters = !term
status, ? undefined
userId, : ({
}; title: {
contains: term,
mode: 'insensitive',
},
} as const);
if (term) { const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
filters.title = { .with(ExtendedDocumentStatus.ALL, () => ({
contains: term, OR: [
mode: 'insensitive', {
}; 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([ const [data, count] = await Promise.all([
prisma.document.findMany({ prisma.document.findMany({
where: { where: {
...termFilters,
...filters, ...filters,
}, },
skip: Math.max(page - 1, 0) * perPage, skip: Math.max(page - 1, 0) * perPage,
@ -51,11 +127,19 @@ export const findDocuments = async ({
[orderByColumn]: orderByDirection, [orderByColumn]: orderByDirection,
}, },
include: { include: {
User: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: true, Recipient: true,
}, },
}), }),
prisma.document.count({ prisma.document.count({
where: { where: {
...termFilters,
...filters, ...filters,
}, },
}), }),

View File

@ -1,30 +1,88 @@
import { prisma } from '@documenso/prisma'; 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 = { export type GetStatsInput = {
userId: number; user: User;
}; };
export const getStats = async ({ userId }: GetStatsInput) => { export const getStats = async ({ user }: GetStatsInput) => {
const result = await prisma.document.groupBy({ const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
by: ['status'], prisma.document.groupBy({
_count: { by: ['status'],
_all: true, _count: {
}, _all: true,
where: { },
userId, 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, number> = { const stats: Record<ExtendedDocumentStatus, number> = {
[DocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[DocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[DocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
}; };
result.forEach((stat) => { ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all; 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; return stats;
}; };

View File

@ -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);
};

View File

@ -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];

View File

@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M1.5 11H14.5M1.5 14C1.5 14 8.72 2 4.86938 2H4.875C2.01 2 1.97437 14.0694 8 6.51188V6.5C8 6.5 9 11.3631 11.5 7.52375V7.5C11.5 7.5 11.5 9 14.5 9"
stroke={color}
strokeWidth={absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};