import type { DragEndEvent } from '@dnd-kit/core'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table'; import { flexRender, getCoreRowModel, getExpandedRowModel, useReactTable, } from '@tanstack/react-table'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, GripVertical, } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; export interface PaginationState { current_page: number; last_page: number; per_page: number; total: number; } interface DataTableProps { columns: ColumnDef[]; data: TData[]; searchKey?: string; searchPlaceholder?: string; emptyText?: string; onReorder?: (items: TData[]) => void; getRowId?: (item: TData) => string | number; toolbar?: React.ReactNode; renderSubRow?: (row: Row, searchValue?: string) => React.ReactNode; defaultExpanded?: boolean; pagination?: PaginationState; onPageChange?: (page: number) => void; onPerPageChange?: (perPage: number) => void; onSearchChange?: (search: string) => void; searchValue?: string; } const DragHandleContext = React.createContext<{ listeners?: Record; attributes?: Record; }>({}); export function DragHandleTrigger() { const { listeners, attributes } = React.useContext(DragHandleContext); return ( ); } 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 ( {children} ); } function useDebounce(callback: (value: string) => void, delay: number) { const timeoutRef = React.useRef | null>(null); return React.useCallback( (value: string) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { callback(value); }, delay); }, [callback, delay], ); } export function DataTable({ columns, data, searchKey, searchPlaceholder = 'Ketikkan sesuatu...', emptyText = 'Tidak ada data.', onReorder, getRowId, toolbar, renderSubRow, defaultExpanded = false, pagination, onPageChange, onPerPageChange, onSearchChange, searchValue, }: DataTableProps) { const [expanded, setExpanded] = React.useState(() => { if (!defaultExpanded || !data.length) { return {}; } const initial: Record = {}; data.forEach((item, index) => { initial[String(index)] = true; }); return initial; }); const [localSearch, setLocalSearch] = React.useState(searchValue ?? ''); React.useEffect(() => { setLocalSearch(searchValue ?? ''); }, [searchValue]); const isServerMode = !!pagination && !!onPageChange; const isSortable = !!onReorder && !!getRowId; const handleSearchDebounced = useDebounce( (value: string) => onSearchChange?.(value), 300, ); const totalPages = pagination?.last_page ?? 1; const currentPage = pagination?.current_page ?? 1; const visibleColumns = [ { id: 'no', header: () => No, cell: ({ row }) => ( {isServerMode ? (currentPage - 1) * (pagination?.per_page ?? 25) + row.index + 1 : row.index + 1} ), meta: { className: 'w-[50px] text-center', headerClassName: 'w-[50px] text-center', }, } as ColumnDef, ...(isSortable ? [ { id: 'drag', header: '', cell: () => , meta: { className: 'w-[40px]', headerClassName: 'w-[40px]', }, } as ColumnDef, ] : []), ...columns, ]; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), useSensor(KeyboardSensor), ); const table = useReactTable({ data, columns: visibleColumns, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: renderSubRow ? getExpandedRowModel() : undefined, onExpandedChange: setExpanded, state: { expanded, }, }); 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); } function handleSearchChange(value: string) { setLocalSearch(value); if (isServerMode) { handleSearchDebounced(value); } } return ( <> {(searchKey || toolbar || isServerMode) && (
{searchKey && ( handleSearchChange(event.target.value) } className="max-w-sm" /> )} {toolbar}
{isServerMode && onPerPageChange && ( )}
)} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef .header, header.getContext(), )} ))} ))} {table.getRowModel().rows?.length ? ( isSortable ? ( {table .getRowModel() .rows.map((row) => ( {row .getVisibleCells() .map((cell) => ( {flexRender( cell .column .columnDef .cell, cell.getContext(), )} ))} ))} ) : ( table.getRowModel().rows.map((row) => ( {row .getVisibleCells() .map((cell) => ( {flexRender( cell.column .columnDef .cell, cell.getContext(), )} ))} {renderSubRow && row.getIsExpanded() && (
{renderSubRow( row, localSearch, )}
)}
)) ) ) : ( {emptyText} )}
{isServerMode ? `Halaman ${currentPage} dari ${totalPages}` : `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
{isServerMode ? ( <> ) : ( <> )}
); }