From f5365554abcaaf56588faa1877f52ca7cfaa1878 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 30 May 2025 18:17:50 +0000 Subject: [PATCH] feat: rework document table filters --- .../app/components/tables/documents-table.tsx | 2 +- .../tables/documents-table/data-table.tsx | 275 ++++++++++++++++++ .../_authenticated+/documents._index.tsx | 104 ++----- .../server-only/document/find-documents.ts | 97 +++++- .../lib/server-only/document/get-stats.ts | 59 +++- .../trpc/server/document-router/schema.ts | 18 +- .../data-table/data-table-faceted-filter.tsx | 152 ++++++++++ .../data-table/data-table-pagination.tsx | 83 ++++++ .../data-table/data-table-single-filter.tsx | 137 +++++++++ .../data-table/data-table-toolbar.tsx | 103 +++++++ .../ui/primitives/data-table/data-table.tsx | 209 +++++++++++++ .../ui/primitives/data-table/data/data.tsx | 101 +++++++ .../ui/primitives/data-table/user-nav.tsx | 56 ++++ .../data-table/utils/time-filters.ts | 113 +++++++ 14 files changed, 1407 insertions(+), 102 deletions(-) create mode 100644 apps/remix/app/components/tables/documents-table/data-table.tsx create mode 100644 packages/ui/primitives/data-table/data-table-faceted-filter.tsx create mode 100644 packages/ui/primitives/data-table/data-table-pagination.tsx create mode 100644 packages/ui/primitives/data-table/data-table-single-filter.tsx create mode 100644 packages/ui/primitives/data-table/data-table-toolbar.tsx create mode 100644 packages/ui/primitives/data-table/data-table.tsx create mode 100644 packages/ui/primitives/data-table/data/data.tsx create mode 100644 packages/ui/primitives/data-table/user-nav.tsx create mode 100644 packages/ui/primitives/data-table/utils/time-filters.ts diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index 4b7cdca3d..bc36577b7 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -170,7 +170,7 @@ type DataTableTitleProps = { teamUrl?: string; }; -const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => { +export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => { const { user } = useSession(); const recipient = row.recipients.find((recipient) => recipient.email === user.email); diff --git a/apps/remix/app/components/tables/documents-table/data-table.tsx b/apps/remix/app/components/tables/documents-table/data-table.tsx new file mode 100644 index 000000000..ccd1df0f6 --- /dev/null +++ b/apps/remix/app/components/tables/documents-table/data-table.tsx @@ -0,0 +1,275 @@ +import { useMemo, useTransition } from 'react'; + +import { i18n } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { Link, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { DataTable } from '@documenso/ui/primitives/data-table/data-table'; +import { + type TimePeriod, + isDateInPeriod, +} from '@documenso/ui/primitives/data-table/utils/time-filters'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { DocumentStatus } from '~/components/general/document/document-status'; +import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { DocumentsTableActionButton } from '../documents-table-action-button'; +import { DocumentsTableActionDropdown } from '../documents-table-action-dropdown'; + +export type DataTableProps = { + data?: TFindDocumentsInternalResponse; + isLoading?: boolean; + isLoadingError?: boolean; + onMoveDocument?: (documentId: number) => void; +}; + +type DocumentsTableRow = TFindDocumentsInternalResponse['data'][number]; + +export function DocumentsDataTable({ + data, + isLoading, + isLoadingError, + onMoveDocument, +}: DataTableProps) { + const { _ } = useLingui(); + const team = useOptionalCurrentTeam(); + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + const [searchParams] = useSearchParams(); + + const handleStatusFilterChange = (values: string[]) => { + startTransition(() => { + if (values.length === 0) { + updateSearchParams({ status: undefined, page: undefined }); + } else { + updateSearchParams({ status: values.join(','), page: undefined }); + } + }); + }; + + const currentStatus = searchParams.get('status'); + const selectedStatusValues = currentStatus ? currentStatus.split(',').filter(Boolean) : []; + + const handleResetFilters = () => { + startTransition(() => { + updateSearchParams({ status: undefined, period: undefined, page: undefined }); + }); + }; + + const isStatusFiltered = selectedStatusValues.length > 0; + + const handleTimePeriodFilterChange = (values: string[]) => { + startTransition(() => { + if (values.length === 0) { + updateSearchParams({ period: undefined, page: undefined }); + } else { + updateSearchParams({ period: values[0], page: undefined }); + } + }); + }; + + const currentPeriod = searchParams.get('period'); + const selectedTimePeriodValues = currentPeriod ? [currentPeriod] : []; + const isTimePeriodFiltered = selectedTimePeriodValues.length > 0; + + const columns = useMemo(() => { + return [ + { + header: 'Created', + accessorKey: 'createdAt', + cell: ({ row }) => + i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }), + filterFn: (row, id, value) => { + const createdAt = row.getValue(id) as Date; + if (!value || !Array.isArray(value) || value.length === 0) { + return true; + } + + const period = value[0] as TimePeriod; + return isDateInPeriod(createdAt, period); + }, + }, + { + header: _(msg`Title`), + accessorKey: 'title', + cell: ({ row }) => , + }, + { + id: 'sender', + header: _(msg`Sender`), + cell: ({ row }) => row.original.user.name ?? row.original.user.email, + }, + { + header: _(msg`Recipient`), + accessorKey: 'recipient', + cell: ({ row }) => ( + + ), + }, + { + header: _(msg`Status`), + accessorKey: 'status', + cell: ({ row }) => , + size: 140, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + header: _(msg`Actions`), + cell: ({ row }) => + (!row.original.deletedAt || isDocumentCompleted(row.original.status)) && ( +
+ + onMoveDocument(row.original.id) : undefined} + /> +
+ ), + }, + ] satisfies DataTableColumnDef[]; + }, [team, onMoveDocument]); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( +
+ + + + + + + + +
+ +
+
+ + + + + + + + ), + }} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +} + +type DataTableTitleProps = { + row: DocumentsTableRow; + teamUrl?: string; +}; + +export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => { + const { user } = useSession(); + + const recipient = row.recipients.find((recipient) => recipient.email === user.email); + + const isOwner = row.user.id === user.id; + const isRecipient = !!recipient; + const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl; + + const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined); + const formatPath = row.folderId + ? `${documentsPath}/f/${row.folderId}/${row.id}` + : `${documentsPath}/${row.id}`; + + return match({ + isOwner, + isRecipient, + isCurrentTeamDocument, + }) + .with({ isOwner: true }, { isCurrentTeamDocument: true }, () => ( + + {row.title} + + )) + .with({ isRecipient: true }, () => ( + + {row.title} + + )) + .otherwise(() => ( + + {row.title} + + )); +}; diff --git a/apps/remix/app/routes/_authenticated+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/documents._index.tsx index 392bd67cb..fa88a7baf 100644 --- a/apps/remix/app/routes/_authenticated+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/documents._index.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; +// imports for tasks import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router'; -import { Link } from 'react-router'; import { z } from 'zod'; import { FolderType } from '@documenso/lib/types/folder-type'; @@ -12,29 +12,22 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { trpc } from '@documenso/trpc/react'; -import { - type TFindDocumentsInternalResponse, - ZFindDocumentsInternalRequestSchema, -} from '@documenso/trpc/server/document-router/schema'; +import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +// Tasks Imports import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog'; import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog'; import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog'; import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog'; import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog'; import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper'; -import { DocumentSearch } from '~/components/general/document/document-search'; -import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentUploadDropzone } from '~/components/general/document/document-upload'; import { FolderCard } from '~/components/general/folder/folder-card'; -import { PeriodSelector } from '~/components/general/period-selector'; -import { DocumentsTable } from '~/components/tables/documents-table'; import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state'; -import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { DocumentsDataTable } from '~/components/tables/documents-table/data-table'; import { useOptionalCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -43,13 +36,23 @@ export function meta() { } const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ - status: true, period: true, page: true, perPage: true, query: true, }).extend({ senderIds: z.string().transform(parseToIntegerArray).optional().catch([]), + status: z + .string() + .transform( + (val) => + val + .split(',') + .map((s) => s.trim()) + .filter(Boolean) as ExtendedDocumentStatus[], + ) + .optional() + .catch(undefined), }); export default function DocumentsPage() { @@ -70,15 +73,6 @@ export default function DocumentsPage() { const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation(); const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation(); - const [stats, setStats] = useState({ - [ExtendedDocumentStatus.DRAFT]: 0, - [ExtendedDocumentStatus.PENDING]: 0, - [ExtendedDocumentStatus.COMPLETED]: 0, - [ExtendedDocumentStatus.REJECTED]: 0, - [ExtendedDocumentStatus.INBOX]: 0, - [ExtendedDocumentStatus.ALL]: 0, - }); - const findDocumentSearchParams = useMemo( () => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {}, [searchParams], @@ -104,28 +98,6 @@ export default function DocumentsPage() { void refetchFolders(); }, [team?.url]); - const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => { - const params = new URLSearchParams(searchParams); - - params.set('status', value); - - if (value === ExtendedDocumentStatus.ALL) { - params.delete('status'); - } - - if (params.has('page')) { - params.delete('page'); - } - - return `${formatDocumentsPath(team?.url)}?${params.toString()}`; - }; - - useEffect(() => { - if (data?.stats) { - setStats(data.stats); - } - }, [data?.stats]); - const navigateToFolder = (folderId?: string | null) => { const documentsPath = formatDocumentsPath(team?.url); @@ -272,44 +244,6 @@ export default function DocumentsPage() { Documents - -
- - - {[ - ExtendedDocumentStatus.INBOX, - ExtendedDocumentStatus.PENDING, - ExtendedDocumentStatus.COMPLETED, - ExtendedDocumentStatus.DRAFT, - ExtendedDocumentStatus.ALL, - ].map((value) => ( - - - - - {value !== ExtendedDocumentStatus.ALL && ( - {stats[value]} - )} - - - ))} - - - - {team && } - -
- -
-
- -
-
@@ -318,10 +252,14 @@ export default function DocumentsPage() { data.count === 0 && (!foldersData?.folders.length || foldersData.folders.length === 0) ? ( ) : ( - 1) { + const statusFilters = status + .map((s) => findDocumentsFilter(s, user, folderId)) + .filter((filter): filter is Prisma.DocumentWhereInput => filter !== null); + if (statusFilters.length > 0) { + filters = { OR: statusFilters }; + } + } if (team) { - filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId); + if (status.length === 1) { + filters = findTeamDocumentsFilter(status[0], team, visibilityFilters, folderId); + } else if (status.length > 1) { + const statusFilters = status + .map((s) => findTeamDocumentsFilter(s, team, visibilityFilters, folderId)) + .filter((filter): filter is Prisma.DocumentWhereInput => filter !== null); + if (statusFilters.length > 0) { + filters = { OR: statusFilters }; + } + } } if (filters === null) { @@ -213,13 +245,60 @@ export const findDocuments = async ({ AND: whereAndClause, }; - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); + if (period && period !== 'all-time') { + const now = DateTime.now(); + let startDate: DateTime; + let endDate: DateTime; - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + switch (period) { + case 'today': + startDate = now.startOf('day'); + endDate = now.endOf('day'); + break; + case 'yesterday': + startDate = now.minus({ days: 1 }).startOf('day'); + endDate = now.minus({ days: 1 }).endOf('day'); + break; + case 'this-week': + startDate = now.startOf('week'); + endDate = now.endOf('week'); + break; + case 'last-week': + startDate = now.minus({ weeks: 1 }).startOf('week'); + endDate = now.minus({ weeks: 1 }).endOf('week'); + break; + case 'this-month': + startDate = now.startOf('month'); + endDate = now.endOf('month'); + break; + case 'last-month': + startDate = now.minus({ months: 1 }).startOf('month'); + endDate = now.minus({ months: 1 }).endOf('month'); + break; + case 'this-quarter': + startDate = now.startOf('quarter'); + endDate = now.endOf('quarter'); + break; + case 'last-quarter': + startDate = now.minus({ quarters: 1 }).startOf('quarter'); + endDate = now.minus({ quarters: 1 }).endOf('quarter'); + break; + case 'this-year': + startDate = now.startOf('year'); + endDate = now.endOf('year'); + break; + case 'last-year': + startDate = now.minus({ years: 1 }).startOf('year'); + endDate = now.minus({ years: 1 }).endOf('year'); + break; + default: + startDate = now.startOf('day'); + endDate = now.endOf('day'); + } whereClause.createdAt = { - gte: startOfPeriod.toJSDate(), + gte: startDate.toJSDate(), + lte: endDate.toJSDate(), }; } diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 754da367a..ba858a9c7 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,7 +1,5 @@ -import { TeamMemberRole } from '@prisma/client'; import type { Prisma, User } from '@prisma/client'; -import { SigningStatus } from '@prisma/client'; -import { DocumentVisibility } from '@prisma/client'; +import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; @@ -27,13 +25,60 @@ export const getStats = async ({ }: GetStatsInput) => { let createdAt: Prisma.DocumentWhereInput['createdAt']; - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); + if (period && period !== 'all-time') { + const now = DateTime.now(); + let startDate: DateTime; + let endDate: DateTime; - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + switch (period) { + case 'today': + startDate = now.startOf('day'); + endDate = now.endOf('day'); + break; + case 'yesterday': + startDate = now.minus({ days: 1 }).startOf('day'); + endDate = now.minus({ days: 1 }).endOf('day'); + break; + case 'this-week': + startDate = now.startOf('week'); + endDate = now.endOf('week'); + break; + case 'last-week': + startDate = now.minus({ weeks: 1 }).startOf('week'); + endDate = now.minus({ weeks: 1 }).endOf('week'); + break; + case 'this-month': + startDate = now.startOf('month'); + endDate = now.endOf('month'); + break; + case 'last-month': + startDate = now.minus({ months: 1 }).startOf('month'); + endDate = now.minus({ months: 1 }).endOf('month'); + break; + case 'this-quarter': + startDate = now.startOf('quarter'); + endDate = now.endOf('quarter'); + break; + case 'last-quarter': + startDate = now.minus({ quarters: 1 }).startOf('quarter'); + endDate = now.minus({ quarters: 1 }).endOf('quarter'); + break; + case 'this-year': + startDate = now.startOf('year'); + endDate = now.endOf('year'); + break; + case 'last-year': + startDate = now.minus({ years: 1 }).startOf('year'); + endDate = now.minus({ years: 1 }).endOf('year'); + break; + default: + startDate = now.startOf('day'); + endDate = now.endOf('day'); + } createdAt = { - gte: startOfPeriod.toJSDate(), + gte: startDate.toJSDate(), + lte: endDate.toJSDate(), }; } diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index ac977de28..3d3806240 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -142,9 +142,23 @@ export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({ export type TFindDocumentsResponse = z.infer; export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({ - period: z.enum(['7d', '14d', '30d']).optional(), + period: z + .enum([ + 'today', + 'yesterday', + 'this-week', + 'last-week', + 'this-month', + 'last-month', + 'this-quarter', + 'last-quarter', + 'this-year', + 'last-year', + 'all-time', + ]) + .optional(), senderIds: z.array(z.number()).optional(), - status: z.nativeEnum(ExtendedDocumentStatus).optional(), + status: z.array(z.nativeEnum(ExtendedDocumentStatus)).optional(), folderId: z.string().optional(), }); diff --git a/packages/ui/primitives/data-table/data-table-faceted-filter.tsx b/packages/ui/primitives/data-table/data-table-faceted-filter.tsx new file mode 100644 index 000000000..ea0270028 --- /dev/null +++ b/packages/ui/primitives/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; + +import type { Column } from '@tanstack/react-table'; +import { Check } from 'lucide-react'; + +import { cn } from '../../lib/utils'; +import { Badge } from '../badge'; +import { Button } from '../button'; +import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + icon?: React.ComponentType<{ className?: string }>; + stats?: Record; + onFilterChange?: (values: string[]) => void; + selectedValues?: string[]; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + color?: string; + bgColor?: string; + }[]; +} + +export function DataTableFacetedFilter({ + column, + title, + icon: Icon, + stats, + onFilterChange, + selectedValues, + options, +}: DataTableFacetedFilterProps) { + const facets = column?.getFacetedUniqueValues(); + const selectedValuesSet = new Set(selectedValues || (column?.getFilterValue() as string[])); + + return ( + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValuesSet.has(option.value); + return ( + { + if (isSelected) { + selectedValuesSet.delete(option.value); + } else { + selectedValuesSet.add(option.value); + } + const filterValues = Array.from(selectedValuesSet); + + if (onFilterChange) { + onFilterChange(filterValues); + } else { + column?.setFilterValue(filterValues.length ? filterValues : undefined); + } + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {(stats?.[option.value] || facets?.get(option.value)) && ( + + {stats?.[option.value] || facets?.get(option.value)} + + )} +
+ ); + })} +
+ {/* Option to clear filters, disabled for now since it makes the ui clanky. */} + {/* {selectedValues.size > 0 && ( + <> + + + column?.setFilterValue(undefined)} + className="justify-center text-center" + > + Clear filters + + + + )} */} +
+
+
+
+ ); +} diff --git a/packages/ui/primitives/data-table/data-table-pagination.tsx b/packages/ui/primitives/data-table/data-table-pagination.tsx new file mode 100644 index 000000000..5ceb372ec --- /dev/null +++ b/packages/ui/primitives/data-table/data-table-pagination.tsx @@ -0,0 +1,83 @@ +import type { Table } from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +import { Button } from '../button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; + +interface DataTablePaginationProps { + table: Table; +} + +export function DataTablePagination({ table }: DataTablePaginationProps) { + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/packages/ui/primitives/data-table/data-table-single-filter.tsx b/packages/ui/primitives/data-table/data-table-single-filter.tsx new file mode 100644 index 000000000..2512015b5 --- /dev/null +++ b/packages/ui/primitives/data-table/data-table-single-filter.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; + +import type { Column } from '@tanstack/react-table'; + +import { cn } from '../../lib/utils'; +import { Badge } from '../badge'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, +} from '../select'; +import { Separator } from '../separator'; + +interface DataTableSingleFilterProps { + column?: Column; + title?: string; + icon?: React.ComponentType<{ className?: string }>; + onFilterChange?: (values: string[]) => void; + selectedValues?: string[]; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + color?: string; + bgColor?: string; + }[]; + groups?: { + label: string; + values: string[]; + }[]; +} + +export function DataTableSingleFilter({ + column, + title, + options, + groups, + icon: Icon, + onFilterChange, + selectedValues, +}: DataTableSingleFilterProps) { + const filterValue = column?.getFilterValue() as string[] | undefined; + const selectedValue = + selectedValues?.[0] || (filterValue && filterValue.length > 0 ? filterValue[0] : undefined); + const selectedOption = options.find((option) => option.value === selectedValue); + + const handleValueChange = (value: string) => { + if (value === selectedValue) { + if (onFilterChange) { + onFilterChange([]); + } else { + column?.setFilterValue(undefined); + } + } else { + if (onFilterChange) { + onFilterChange([value]); + } else { + column?.setFilterValue([value]); + } + } + }; + + const renderOptions = () => { + if (groups) { + return groups.map((group, groupIndex) => ( + + + {group.label} + {options + .filter((option) => group.values.includes(option.value)) + .map((option) => ( + +
+ {option.icon && ( + + )} + {option.label} +
+
+ ))} +
+ {groupIndex < groups.length - 1 && } +
+ )); + } + + return ( + + {options.map((option) => ( + +
+ {option.icon && ( + + )} + {option.label} +
+
+ ))} +
+ ); + }; + + return ( + + ); +} diff --git a/packages/ui/primitives/data-table/data-table-toolbar.tsx b/packages/ui/primitives/data-table/data-table-toolbar.tsx new file mode 100644 index 000000000..ec92440f3 --- /dev/null +++ b/packages/ui/primitives/data-table/data-table-toolbar.tsx @@ -0,0 +1,103 @@ +import type { Table } from '@tanstack/react-table'; +import { Calendar, CircleDashedIcon, ListFilter, X, XCircle } from 'lucide-react'; + +import { Button } from '../button'; +import { Input } from '../input'; +import { DataTableFacetedFilter } from './data-table-faceted-filter'; +import { DataTableSingleFilter } from './data-table-single-filter'; +import { statuses, timePeriodGroups, timePeriods } from './data/data'; + +interface DataTableToolbarProps { + table: Table; + stats?: Record; + onStatusFilterChange?: (values: string[]) => void; + selectedStatusValues?: string[]; + onTimePeriodFilterChange?: (values: string[]) => void; + selectedTimePeriodValues?: string[]; + onResetFilters?: () => void; + isStatusFiltered?: boolean; + isTimePeriodFiltered?: boolean; +} + +export function DataTableToolbar({ + table, + stats, + onStatusFilterChange, + selectedStatusValues, + onTimePeriodFilterChange, + selectedTimePeriodValues, + onResetFilters, + isStatusFiltered, + isTimePeriodFiltered, +}: DataTableToolbarProps) { + const isFiltered = + table.getState().columnFilters.length > 0 || isStatusFiltered || isTimePeriodFiltered; + const searchValue = (table.getColumn('title')?.getFilterValue() as string) ?? ''; + + const handleClearFilter = () => { + table.getColumn('title')?.setFilterValue(''); + }; + + const handleReset = () => { + table.resetColumnFilters(); + onResetFilters?.(); + }; + + return ( +
+
+
+ table.getColumn('title')?.setFilterValue(event.target.value)} + /> +
+
+ {searchValue && ( + + )} +
+ + {table.getColumn('status') && ( + + )} + + {table.getColumn('createdAt') && ( + + )} + + {isFiltered && ( + + )} +
+
+ ); +} diff --git a/packages/ui/primitives/data-table/data-table.tsx b/packages/ui/primitives/data-table/data-table.tsx new file mode 100644 index 000000000..531514921 --- /dev/null +++ b/packages/ui/primitives/data-table/data-table.tsx @@ -0,0 +1,209 @@ +import * as React from 'react'; +import { useMemo } from 'react'; + +import { Trans } from '@lingui/react/macro'; +import type { + ColumnDef, + ColumnFiltersState, + PaginationState, + SortingState, + Updater, + VisibilityState, +} from '@tanstack/react-table'; +import { + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import type { DataTableChildren } from '../data-table'; +import { Skeleton } from '../skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; +import { DataTableToolbar } from './data-table-toolbar'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + columnVisibility?: VisibilityState; + perPage?: number; + currentPage?: number; + totalPages?: number; + onPaginationChange?: (_page: number, _perPage: number) => void; + children?: DataTableChildren; + stats?: Record; + onStatusFilterChange?: (values: string[]) => void; + selectedStatusValues?: string[]; + onTimePeriodFilterChange?: (values: string[]) => void; + selectedTimePeriodValues?: string[]; + onResetFilters?: () => void; + isStatusFiltered?: boolean; + isTimePeriodFiltered?: boolean; + skeleton?: { + enable: boolean; + rows: number; + component?: React.ReactNode; + }; + error?: { + enable: boolean; + component?: React.ReactNode; + }; +} + +export function DataTable({ + columns, + data, + error, + perPage, + currentPage, + totalPages, + skeleton, + onPaginationChange, + children, + stats, + onStatusFilterChange, + selectedStatusValues, + onTimePeriodFilterChange, + selectedTimePeriodValues, + onResetFilters, + isStatusFiltered, + isTimePeriodFiltered, +}: DataTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, setSorting] = React.useState([]); + + const pagination = useMemo(() => { + if (currentPage !== undefined && perPage !== undefined) { + return { + pageIndex: currentPage - 1, + pageSize: perPage, + }; + } + + return { + pageIndex: 0, + pageSize: 0, + }; + }, [currentPage, perPage]); + + const manualPagination = Boolean(currentPage !== undefined && totalPages !== undefined); + + const onTablePaginationChange = (updater: Updater) => { + if (typeof updater === 'function') { + const newState = updater(pagination); + + onPaginationChange?.(newState.pageIndex + 1, newState.pageSize); + } else { + onPaginationChange?.(updater.pageIndex + 1, updater.pageSize); + } + }; + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination: manualPagination ? pagination : undefined, + }, + manualPagination, + pageCount: totalPages, + initialState: { + pagination: { + pageSize: 10, + }, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + onPaginationChange: onTablePaginationChange, + }); + + return ( + <> +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : error?.enable ? ( + + {error.component ?? ( + + Something went wrong. + + )} + + ) : skeleton?.enable ? ( + Array.from({ length: skeleton.rows }).map((_, i) => ( + + {skeleton.component ?? } + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + {children &&
{children(table)}
} + + ); +} diff --git a/packages/ui/primitives/data-table/data/data.tsx b/packages/ui/primitives/data-table/data/data.tsx new file mode 100644 index 000000000..71b56c7c6 --- /dev/null +++ b/packages/ui/primitives/data-table/data/data.tsx @@ -0,0 +1,101 @@ +import { CheckCircle2, Clock, File, Inbox, XCircle } from 'lucide-react'; + +export const statuses = [ + { + value: 'DRAFT', + label: 'Draft', + icon: File, + color: 'text-yellow-500 dark:text-yellow-300', + bgColor: 'bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700', + }, + { + value: 'PENDING', + label: 'Pending', + icon: Clock, + color: 'text-water-700 dark:text-water-300', + bgColor: 'bg-water-100 dark:bg-water-100 text-water-700 dark:text-water-700', + }, + { + value: 'COMPLETED', + label: 'Completed', + icon: CheckCircle2, + color: 'text-documenso-700 dark:text-documenso-300', + bgColor: 'bg-documenso-200 dark:bg-documenso-200 text-documenso-800 dark:text-documenso-800', + }, + { + value: 'REJECTED', + label: 'Rejected', + icon: XCircle, + color: 'text-red-700 dark:text-red-300', + bgColor: 'bg-red-100 dark:bg-red-100 text-red-500 dark:text-red-700', + }, + { + value: 'INBOX', + label: 'Inbox', + icon: Inbox, + color: 'text-blue-700 dark:text-blue-300', + bgColor: 'bg-blue-100 dark:bg-blue-100 text-blue-700 dark:text-blue-700', + }, +]; + +export const timePeriods = [ + { + value: 'today', + label: 'Today', + }, + { + value: 'this-week', + label: 'This Week', + }, + { + value: 'this-month', + label: 'This Month', + }, + { + value: 'this-quarter', + label: 'This Quarter', + }, + { + value: 'this-year', + label: 'This Year', + }, + { + value: 'yesterday', + label: 'Yesterday', + }, + { + value: 'last-week', + label: 'Last Week', + }, + { + value: 'last-month', + label: 'Last Month', + }, + { + value: 'last-quarter', + label: 'Last Quarter', + }, + { + value: 'last-year', + label: 'Last Year', + }, + { + value: 'all-time', + label: 'All Time', + }, +]; + +export const timePeriodGroups = [ + { + label: 'Present', + values: ['today', 'this-week', 'this-month', 'this-quarter', 'this-year'], + }, + { + label: 'Past', + values: ['yesterday', 'last-week', 'last-month', 'last-quarter', 'last-year'], + }, + { + label: '', + values: ['all-time'], + }, +]; diff --git a/packages/ui/primitives/data-table/user-nav.tsx b/packages/ui/primitives/data-table/user-nav.tsx new file mode 100644 index 000000000..f45fab014 --- /dev/null +++ b/packages/ui/primitives/data-table/user-nav.tsx @@ -0,0 +1,56 @@ +import { Avatar, AvatarFallback, AvatarImage } from '../avatar'; +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from '../dropdown-menu'; + +export function UserNav() { + return ( + + + + + + +
+

shadcn

+

m@example.com

+
+
+ + + + Profile + ⇧⌘P + + + Billing + ⌘B + + + Settings + ⌘S + + New Team + + + + Log out + ⇧⌘Q + +
+
+ ); +} diff --git a/packages/ui/primitives/data-table/utils/time-filters.ts b/packages/ui/primitives/data-table/utils/time-filters.ts new file mode 100644 index 000000000..25f39b45f --- /dev/null +++ b/packages/ui/primitives/data-table/utils/time-filters.ts @@ -0,0 +1,113 @@ +import { DateTime } from 'luxon'; + +export type TimePeriod = + | 'today' + | 'this-week' + | 'this-month' + | 'this-quarter' + | 'this-year' + | 'yesterday' + | 'last-week' + | 'last-month' + | 'last-quarter' + | 'last-year' + | 'all-time'; + +export function getDateRangeForPeriod( + period: TimePeriod, +): { start: DateTime; end: DateTime } | null { + const now = DateTime.now(); + + switch (period) { + case 'today': + return { + start: now.startOf('day'), + end: now.endOf('day'), + }; + + case 'yesterday': { + const yesterday = now.minus({ days: 1 }); + return { + start: yesterday.startOf('day'), + end: yesterday.endOf('day'), + }; + } + + case 'this-week': + return { + start: now.startOf('week'), + end: now.endOf('week'), + }; + + case 'last-week': { + const lastWeek = now.minus({ weeks: 1 }); + return { + start: lastWeek.startOf('week'), + end: lastWeek.endOf('week'), + }; + } + + case 'this-month': + return { + start: now.startOf('month'), + end: now.endOf('month'), + }; + + case 'last-month': { + const lastMonth = now.minus({ months: 1 }); + return { + start: lastMonth.startOf('month'), + end: lastMonth.endOf('month'), + }; + } + + case 'this-quarter': + return { + start: now.startOf('quarter'), + end: now.endOf('quarter'), + }; + + case 'last-quarter': { + const lastQuarter = now.minus({ quarters: 1 }); + return { + start: lastQuarter.startOf('quarter'), + end: lastQuarter.endOf('quarter'), + }; + } + + case 'this-year': + return { + start: now.startOf('year'), + end: now.endOf('year'), + }; + + case 'last-year': { + const lastYear = now.minus({ years: 1 }); + return { + start: lastYear.startOf('year'), + end: lastYear.endOf('year'), + }; + } + + case 'all-time': + return null; + + default: + return null; + } +} + +export function isDateInPeriod(date: Date, period: TimePeriod): boolean { + const dateTime = DateTime.fromJSDate(date); + + if (period === 'all-time') { + return true; + } + + const range = getDateRangeForPeriod(period); + if (!range) { + return true; + } + + return dateTime >= range.start && dateTime <= range.end; +}