mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
feat: rework document table filters
This commit is contained in:
@ -170,7 +170,7 @@ type DataTableTitleProps = {
|
|||||||
teamUrl?: string;
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|||||||
275
apps/remix/app/components/tables/documents-table/data-table.tsx
Normal file
275
apps/remix/app/components/tables/documents-table/data-table.tsx
Normal file
@ -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 }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: _(msg`Sender`),
|
||||||
|
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Recipient`),
|
||||||
|
accessorKey: 'recipient',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.recipients}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Status`),
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||||
|
size: 140,
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Actions`),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DocumentsTableActionButton row={row.original} />
|
||||||
|
<DocumentsTableActionDropdown
|
||||||
|
row={row.original}
|
||||||
|
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||||
|
}, [team, onMoveDocument]);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<DataTable
|
||||||
|
data={results.data}
|
||||||
|
columns={columns}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
columnVisibility={{
|
||||||
|
sender: team !== undefined,
|
||||||
|
}}
|
||||||
|
stats={data?.stats}
|
||||||
|
onStatusFilterChange={handleStatusFilterChange}
|
||||||
|
selectedStatusValues={selectedStatusValues}
|
||||||
|
onTimePeriodFilterChange={handleTimePeriodFilterChange}
|
||||||
|
selectedTimePeriodValues={selectedTimePeriodValues}
|
||||||
|
onResetFilters={handleResetFilters}
|
||||||
|
isStatusFiltered={isStatusFiltered}
|
||||||
|
isTimePeriodFiltered={isTimePeriodFiltered}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError || false,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading || false,
|
||||||
|
rows: 5,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-40 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-10 w-24 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }, () => (
|
||||||
|
<Link
|
||||||
|
to={formatPath}
|
||||||
|
title={row.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.with({ isRecipient: true }, () => (
|
||||||
|
<Link
|
||||||
|
to={`/sign/${recipient?.token}`}
|
||||||
|
title={row.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||||
|
{row.title}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
};
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
// imports for tasks
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import {
|
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
|
||||||
type TFindDocumentsInternalResponse,
|
|
||||||
ZFindDocumentsInternalRequestSchema,
|
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||||
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
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 { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
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 { 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 { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@ -43,13 +36,23 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||||
status: true,
|
|
||||||
period: true,
|
period: true,
|
||||||
page: true,
|
page: true,
|
||||||
perPage: true,
|
perPage: true,
|
||||||
query: true,
|
query: true,
|
||||||
}).extend({
|
}).extend({
|
||||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
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() {
|
export default function DocumentsPage() {
|
||||||
@ -70,15 +73,6 @@ export default function DocumentsPage() {
|
|||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||||
|
|
||||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
|
||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
|
||||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const findDocumentSearchParams = useMemo(
|
const findDocumentSearchParams = useMemo(
|
||||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||||
[searchParams],
|
[searchParams],
|
||||||
@ -104,28 +98,6 @@ export default function DocumentsPage() {
|
|||||||
void refetchFolders();
|
void refetchFolders();
|
||||||
}, [team?.url]);
|
}, [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 navigateToFolder = (folderId?: string | null) => {
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
@ -272,44 +244,6 @@ export default function DocumentsPage() {
|
|||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
|
||||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
{[
|
|
||||||
ExtendedDocumentStatus.INBOX,
|
|
||||||
ExtendedDocumentStatus.PENDING,
|
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
|
||||||
ExtendedDocumentStatus.DRAFT,
|
|
||||||
ExtendedDocumentStatus.ALL,
|
|
||||||
].map((value) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={value}
|
|
||||||
className="hover:text-foreground min-w-[60px]"
|
|
||||||
value={value}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={getTabHref(value)} preventScrollReset>
|
|
||||||
<DocumentStatus status={value} />
|
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
|
||||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
|
||||||
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<PeriodSelector />
|
|
||||||
</div>
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
@ -318,10 +252,14 @@ export default function DocumentsPage() {
|
|||||||
data.count === 0 &&
|
data.count === 0 &&
|
||||||
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
||||||
<DocumentsTableEmptyState
|
<DocumentsTableEmptyState
|
||||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
status={
|
||||||
|
Array.isArray(findDocumentSearchParams.status)
|
||||||
|
? findDocumentSearchParams.status[0] || ExtendedDocumentStatus.ALL
|
||||||
|
: findDocumentSearchParams.status || ExtendedDocumentStatus.ALL
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DocumentsTable
|
<DocumentsDataTable
|
||||||
data={data}
|
data={data}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isLoadingError={isLoadingError}
|
isLoadingError={isLoadingError}
|
||||||
|
|||||||
@ -10,14 +10,26 @@ import { DocumentVisibility } from '../../types/document-visibility';
|
|||||||
import { type FindResultResponse } from '../../types/search-params';
|
import { type FindResultResponse } from '../../types/search-params';
|
||||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
|
|
||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
export type PeriodSelectorValue =
|
||||||
|
| ''
|
||||||
|
| 'today'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'this-week'
|
||||||
|
| 'last-week'
|
||||||
|
| 'this-month'
|
||||||
|
| 'last-month'
|
||||||
|
| 'this-quarter'
|
||||||
|
| 'last-quarter'
|
||||||
|
| 'this-year'
|
||||||
|
| 'last-year'
|
||||||
|
| 'all-time';
|
||||||
|
|
||||||
export type FindDocumentsOptions = {
|
export type FindDocumentsOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
templateId?: number;
|
templateId?: number;
|
||||||
source?: DocumentSource;
|
source?: DocumentSource;
|
||||||
status?: ExtendedDocumentStatus;
|
status?: ExtendedDocumentStatus[];
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
orderBy?: {
|
orderBy?: {
|
||||||
@ -35,7 +47,7 @@ export const findDocuments = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
templateId,
|
templateId,
|
||||||
source,
|
source,
|
||||||
status = ExtendedDocumentStatus.ALL,
|
status = [ExtendedDocumentStatus.ALL],
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -122,10 +134,30 @@ export const findDocuments = async ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
|
let filters: Prisma.DocumentWhereInput | null = null;
|
||||||
|
|
||||||
|
if (status.length === 1) {
|
||||||
|
filters = findDocumentsFilter(status[0], user, folderId);
|
||||||
|
} else if (status.length > 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) {
|
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) {
|
if (filters === null) {
|
||||||
@ -213,13 +245,60 @@ export const findDocuments = async ({
|
|||||||
AND: whereAndClause,
|
AND: whereAndClause,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (period) {
|
if (period && period !== 'all-time') {
|
||||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
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 = {
|
whereClause.createdAt = {
|
||||||
gte: startOfPeriod.toJSDate(),
|
gte: startDate.toJSDate(),
|
||||||
|
lte: endDate.toJSDate(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import type { Prisma, User } from '@prisma/client';
|
import type { Prisma, User } from '@prisma/client';
|
||||||
import { SigningStatus } from '@prisma/client';
|
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { DocumentVisibility } from '@prisma/client';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -27,13 +25,60 @@ export const getStats = async ({
|
|||||||
}: GetStatsInput) => {
|
}: GetStatsInput) => {
|
||||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||||
|
|
||||||
if (period) {
|
if (period && period !== 'all-time') {
|
||||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
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 = {
|
createdAt = {
|
||||||
gte: startOfPeriod.toJSDate(),
|
gte: startDate.toJSDate(),
|
||||||
|
lte: endDate.toJSDate(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -142,9 +142,23 @@ export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({
|
|||||||
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;
|
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;
|
||||||
|
|
||||||
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
|
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(),
|
senderIds: z.array(z.number()).optional(),
|
||||||
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
|
status: z.array(z.nativeEnum(ExtendedDocumentStatus)).optional(),
|
||||||
folderId: z.string().optional(),
|
folderId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
152
packages/ui/primitives/data-table/data-table-faceted-filter.tsx
Normal file
152
packages/ui/primitives/data-table/data-table-faceted-filter.tsx
Normal file
@ -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<TData, TValue> {
|
||||||
|
column?: Column<TData, TValue>;
|
||||||
|
title?: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
stats?: Record<string, number>;
|
||||||
|
onFilterChange?: (values: string[]) => void;
|
||||||
|
selectedValues?: string[];
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
color?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableFacetedFilter<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
stats,
|
||||||
|
onFilterChange,
|
||||||
|
selectedValues,
|
||||||
|
options,
|
||||||
|
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||||
|
const facets = column?.getFacetedUniqueValues();
|
||||||
|
const selectedValuesSet = new Set(selectedValues || (column?.getFilterValue() as string[]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-1.5 border-dashed px-2.5">
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
{title}
|
||||||
|
{selectedValuesSet.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||||
|
<Badge variant="secondary" className="rounded-sm px-2 py-0.5 font-normal lg:hidden">
|
||||||
|
{selectedValuesSet.size}
|
||||||
|
</Badge>
|
||||||
|
<div className="hidden gap-1 lg:flex">
|
||||||
|
{selectedValuesSet.size > 2 ? (
|
||||||
|
<Badge variant="neutral" className="rounded-sm px-2 py-0.5 font-normal">
|
||||||
|
{selectedValuesSet.size} selected
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter((option) => selectedValuesSet.has(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
'rounded-sm border-none px-2 py-0.5 font-normal',
|
||||||
|
option.bgColor ? option.bgColor : 'variant-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValuesSet.has(option.value);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
className="gap-x-2"
|
||||||
|
onSelect={() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex size-4 items-center justify-center rounded-[4px] border',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary border-primary text-primary-foreground'
|
||||||
|
: 'border-input [&_svg]:invisible',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="text-primary-foreground size-3.5" />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
option.color ? option.color : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{(stats?.[option.value] || facets?.get(option.value)) && (
|
||||||
|
<span className="text-muted-foreground ml-auto flex size-4 items-center justify-center font-mono text-xs">
|
||||||
|
{stats?.[option.value] || facets?.get(option.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
{/* Option to clear filters, disabled for now since it makes the ui clanky. */}
|
||||||
|
{/* {selectedValues.size > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => column?.setFilterValue(undefined)}
|
||||||
|
className="justify-center text-center"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
packages/ui/primitives/data-table/data-table-pagination.tsx
Normal file
83
packages/ui/primitives/data-table/data-table-pagination.tsx
Normal file
@ -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<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="text-muted-foreground flex-1 text-sm">
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||||
|
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Rows per page</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 25, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden size-8 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-8"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-8"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden size-8 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
packages/ui/primitives/data-table/data-table-single-filter.tsx
Normal file
137
packages/ui/primitives/data-table/data-table-single-filter.tsx
Normal file
@ -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<TData, TValue> {
|
||||||
|
column?: Column<TData, TValue>;
|
||||||
|
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<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
groups,
|
||||||
|
icon: Icon,
|
||||||
|
onFilterChange,
|
||||||
|
selectedValues,
|
||||||
|
}: DataTableSingleFilterProps<TData, TValue>) {
|
||||||
|
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) => (
|
||||||
|
<React.Fragment key={group.label}>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>{group.label}</SelectLabel>
|
||||||
|
{options
|
||||||
|
.filter((option) => group.values.includes(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
option.color ? option.color : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
{groupIndex < groups.length - 1 && <SelectSeparator />}
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon
|
||||||
|
className={cn('size-4', option.color ? option.color : 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={selectedValue || ''} onValueChange={handleValueChange}>
|
||||||
|
<SelectTrigger className="border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 w-auto gap-1.5 border border-dashed px-2.5 focus:ring-0">
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
{title}
|
||||||
|
{selectedValue && selectedOption && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
className={cn(
|
||||||
|
'rounded-sm border-none px-2 py-0.5 font-normal',
|
||||||
|
selectedOption.bgColor ? selectedOption.bgColor : 'variant-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedOption.label}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>{renderOptions()}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
packages/ui/primitives/data-table/data-table-toolbar.tsx
Normal file
103
packages/ui/primitives/data-table/data-table-toolbar.tsx
Normal file
@ -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<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
stats?: Record<string, number>;
|
||||||
|
onStatusFilterChange?: (values: string[]) => void;
|
||||||
|
selectedStatusValues?: string[];
|
||||||
|
onTimePeriodFilterChange?: (values: string[]) => void;
|
||||||
|
selectedTimePeriodValues?: string[];
|
||||||
|
onResetFilters?: () => void;
|
||||||
|
isStatusFiltered?: boolean;
|
||||||
|
isTimePeriodFiltered?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({
|
||||||
|
table,
|
||||||
|
stats,
|
||||||
|
onStatusFilterChange,
|
||||||
|
selectedStatusValues,
|
||||||
|
onTimePeriodFilterChange,
|
||||||
|
selectedTimePeriodValues,
|
||||||
|
onResetFilters,
|
||||||
|
isStatusFiltered,
|
||||||
|
isTimePeriodFiltered,
|
||||||
|
}: DataTableToolbarProps<TData>) {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="peer h-8 w-[150px] pe-9 ps-9 lg:w-[250px]"
|
||||||
|
placeholder="Search documents..."
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(event) => table.getColumn('title')?.setFilterValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 peer-disabled:opacity-50">
|
||||||
|
<ListFilter size={16} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md outline-none transition-[color,box-shadow] focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
onClick={handleClearFilter}
|
||||||
|
>
|
||||||
|
<XCircle className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{table.getColumn('status') && (
|
||||||
|
<DataTableFacetedFilter
|
||||||
|
column={table.getColumn('status')}
|
||||||
|
title="Status"
|
||||||
|
options={statuses}
|
||||||
|
icon={CircleDashedIcon}
|
||||||
|
stats={stats}
|
||||||
|
onFilterChange={onStatusFilterChange}
|
||||||
|
selectedValues={selectedStatusValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{table.getColumn('createdAt') && (
|
||||||
|
<DataTableSingleFilter
|
||||||
|
column={table.getColumn('createdAt')}
|
||||||
|
title="Time Period"
|
||||||
|
options={timePeriods}
|
||||||
|
groups={timePeriodGroups}
|
||||||
|
icon={Calendar}
|
||||||
|
onFilterChange={onTimePeriodFilterChange}
|
||||||
|
selectedValues={selectedTimePeriodValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFiltered && (
|
||||||
|
<Button variant="ghost" className="h-8 gap-2" size="sm" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
packages/ui/primitives/data-table/data-table.tsx
Normal file
209
packages/ui/primitives/data-table/data-table.tsx
Normal file
@ -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<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
columnVisibility?: VisibilityState;
|
||||||
|
perPage?: number;
|
||||||
|
currentPage?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
onPaginationChange?: (_page: number, _perPage: number) => void;
|
||||||
|
children?: DataTableChildren<TData>;
|
||||||
|
stats?: Record<string, number>;
|
||||||
|
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<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
perPage,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
skeleton,
|
||||||
|
onPaginationChange,
|
||||||
|
children,
|
||||||
|
stats,
|
||||||
|
onStatusFilterChange,
|
||||||
|
selectedStatusValues,
|
||||||
|
onTimePeriodFilterChange,
|
||||||
|
selectedTimePeriodValues,
|
||||||
|
onResetFilters,
|
||||||
|
isStatusFiltered,
|
||||||
|
isTimePeriodFiltered,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
|
const pagination = useMemo<PaginationState>(() => {
|
||||||
|
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<PaginationState>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<DataTableToolbar
|
||||||
|
table={table}
|
||||||
|
stats={stats}
|
||||||
|
onStatusFilterChange={onStatusFilterChange}
|
||||||
|
selectedStatusValues={selectedStatusValues}
|
||||||
|
onTimePeriodFilterChange={onTimePeriodFilterChange}
|
||||||
|
selectedTimePeriodValues={selectedTimePeriodValues}
|
||||||
|
onResetFilters={onResetFilters}
|
||||||
|
isStatusFiltered={isStatusFiltered}
|
||||||
|
isTimePeriodFiltered={isTimePeriodFiltered}
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : error?.enable ? (
|
||||||
|
<TableRow>
|
||||||
|
{error.component ?? (
|
||||||
|
<TableCell colSpan={columns.length} className="h-32 text-center">
|
||||||
|
<Trans>Something went wrong.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
) : skeleton?.enable ? (
|
||||||
|
Array.from({ length: skeleton.rows }).map((_, i) => (
|
||||||
|
<TableRow key={`skeleton-row-${i}`}>
|
||||||
|
{skeleton.component ?? <Skeleton />}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && <div className="mt-8 w-full">{children(table)}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
packages/ui/primitives/data-table/data/data.tsx
Normal file
101
packages/ui/primitives/data-table/data/data.tsx
Normal file
@ -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'],
|
||||||
|
},
|
||||||
|
];
|
||||||
56
packages/ui/primitives/data-table/user-nav.tsx
Normal file
56
packages/ui/primitives/data-table/user-nav.tsx
Normal file
@ -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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarImage src="/avatars/03.png" alt="@shadcn" />
|
||||||
|
<AvatarFallback>SC</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">shadcn</p>
|
||||||
|
<p className="text-muted-foreground text-xs leading-none">m@example.com</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
Profile
|
||||||
|
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
Billing
|
||||||
|
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
Settings
|
||||||
|
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>New Team</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
Log out
|
||||||
|
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
packages/ui/primitives/data-table/utils/time-filters.ts
Normal file
113
packages/ui/primitives/data-table/utils/time-filters.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user