388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
import {
|
|
DndContext,
|
|
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors
|
|
} from '@dnd-kit/core';
|
|
import type { DragEndEvent } from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import {
|
|
|
|
|
|
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getFilteredRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
useReactTable
|
|
} from '@tanstack/react-table';
|
|
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
|
|
import { GripVertical } from 'lucide-react';
|
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
|
import * as React from 'react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
|
|
interface DataTableProps<TData, TValue> {
|
|
columns: ColumnDef<TData, TValue>[];
|
|
data: TData[];
|
|
searchKey?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
onReorder?: (items: TData[]) => void;
|
|
getRowId?: (item: TData) => string | number;
|
|
}
|
|
|
|
const DragHandleContext = React.createContext<{
|
|
listeners?: Record<string, unknown>;
|
|
attributes?: Record<string, string>;
|
|
}>({});
|
|
|
|
export function DragHandleTrigger() {
|
|
const { listeners, attributes } = React.useContext(DragHandleContext);
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="-ml-2 cursor-grab active:cursor-grabbing"
|
|
{...listeners}
|
|
{...attributes}
|
|
>
|
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function SortableTableRow({
|
|
id,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.4 : undefined,
|
|
};
|
|
|
|
return (
|
|
<DragHandleContext.Provider value={{ listeners, attributes }}>
|
|
<TableRow
|
|
ref={setNodeRef}
|
|
style={style}
|
|
data-state={isDragging ? 'dragging' : undefined}
|
|
>
|
|
{children}
|
|
</TableRow>
|
|
</DragHandleContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function DataTable<TData, TValue>({
|
|
columns,
|
|
data,
|
|
searchKey,
|
|
searchPlaceholder = 'Cari...',
|
|
emptyText = 'Tidak ada data.',
|
|
onReorder,
|
|
getRowId,
|
|
}: DataTableProps<TData, TValue>) {
|
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
const [columnFilters, setColumnFilters] =
|
|
React.useState<ColumnFiltersState>([]);
|
|
|
|
const isSortable = !!onReorder && !!getRowId;
|
|
|
|
const visibleColumns = isSortable
|
|
? [
|
|
{
|
|
id: 'drag',
|
|
header: '',
|
|
cell: () => <DragHandleTrigger />,
|
|
meta: {
|
|
className: 'w-[40px]',
|
|
headerClassName: 'w-[40px]',
|
|
},
|
|
} as ColumnDef<TData, TValue>,
|
|
...columns,
|
|
]
|
|
: columns;
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
}),
|
|
useSensor(KeyboardSensor),
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns: visibleColumns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
onSortingChange: setSorting,
|
|
getSortedRowModel: getSortedRowModel(),
|
|
onColumnFiltersChange: setColumnFilters,
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
state: {
|
|
sorting,
|
|
columnFilters,
|
|
},
|
|
});
|
|
|
|
const itemIds = React.useMemo(
|
|
() => data.map((item) => String(getRowId ? getRowId(item) : '')),
|
|
[data, getRowId],
|
|
);
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
const { active, over } = event;
|
|
|
|
if (!over || active.id === over.id) {
|
|
return;
|
|
}
|
|
|
|
const oldIndex = itemIds.indexOf(String(active.id));
|
|
const newIndex = itemIds.indexOf(String(over.id));
|
|
|
|
if (oldIndex === -1 || newIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
const reordered = [...data];
|
|
const [moved] = reordered.splice(oldIndex, 1);
|
|
reordered.splice(newIndex, 0, moved);
|
|
|
|
onReorder?.(reordered);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{searchKey && (
|
|
<Input
|
|
placeholder={searchPlaceholder}
|
|
value={
|
|
(table
|
|
.getColumn(searchKey)
|
|
?.getFilterValue() as string) ?? ''
|
|
}
|
|
onChange={(event) =>
|
|
table
|
|
.getColumn(searchKey)
|
|
?.setFilterValue(event.target.value)
|
|
}
|
|
className="max-w-sm"
|
|
/>
|
|
)}
|
|
<Card className="bg-sidebar p-0">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<TableHead
|
|
key={header.id}
|
|
className={
|
|
(
|
|
header.column.columnDef
|
|
.meta as {
|
|
headerClassName?: string;
|
|
}
|
|
)?.headerClassName
|
|
}
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef
|
|
.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{table.getRowModel().rows?.length ? (
|
|
isSortable ? (
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={itemIds}
|
|
strategy={
|
|
verticalListSortingStrategy
|
|
}
|
|
>
|
|
{table
|
|
.getRowModel()
|
|
.rows.map((row) => (
|
|
<SortableTableRow
|
|
key={row.id}
|
|
id={String(
|
|
getRowId!(
|
|
row.original,
|
|
),
|
|
)}
|
|
>
|
|
{row
|
|
.getVisibleCells()
|
|
.map((cell) => (
|
|
<TableCell
|
|
key={
|
|
cell.id
|
|
}
|
|
className={
|
|
(
|
|
cell
|
|
.column
|
|
.columnDef
|
|
.meta as {
|
|
className?: string;
|
|
}
|
|
)
|
|
?.className
|
|
}
|
|
>
|
|
{flexRender(
|
|
cell
|
|
.column
|
|
.columnDef
|
|
.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</SortableTableRow>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
) : (
|
|
table
|
|
.getRowModel()
|
|
.rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={
|
|
row.getIsSelected() &&
|
|
'selected'
|
|
}
|
|
>
|
|
{row
|
|
.getVisibleCells()
|
|
.map((cell) => (
|
|
<TableCell
|
|
key={cell.id}
|
|
className={
|
|
(
|
|
cell
|
|
.column
|
|
.columnDef
|
|
.meta as {
|
|
className?: string;
|
|
}
|
|
)?.className
|
|
}
|
|
>
|
|
{flexRender(
|
|
cell.column
|
|
.columnDef
|
|
.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
)
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={visibleColumns.length}
|
|
className="h-24 text-center"
|
|
>
|
|
{emptyText}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-muted-foreground">
|
|
Halaman {table.getState().pagination.pageIndex + 1} dari{' '}
|
|
{table.getPageCount()}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => table.setPageIndex(0)}
|
|
disabled={!table.getCanPreviousPage()}
|
|
>
|
|
<ChevronsLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => table.previousPage()}
|
|
disabled={!table.getCanPreviousPage()}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => table.nextPage()}
|
|
disabled={!table.getCanNextPage()}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
disabled={!table.getCanNextPage()}
|
|
>
|
|
<ChevronsRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|