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;
+}