mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 17:21:41 +10:00
feat: rework document table filters
This commit is contained in:
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