560 lines
16 KiB
TypeScript
560 lines
16 KiB
TypeScript
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<TData, TValue> {
|
|
columns: ColumnDef<TData, TValue>[];
|
|
data: TData[];
|
|
searchKey?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
onReorder?: (items: TData[]) => void;
|
|
getRowId?: (item: TData) => string | number;
|
|
toolbar?: React.ReactNode;
|
|
renderSubRow?: (row: Row<TData>, 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<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>
|
|
);
|
|
}
|
|
|
|
function useDebounce(callback: (value: string) => void, delay: number) {
|
|
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
return React.useCallback(
|
|
(value: string) => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
callback(value);
|
|
}, delay);
|
|
},
|
|
[callback, delay],
|
|
);
|
|
}
|
|
|
|
export function DataTable<TData, TValue>({
|
|
columns,
|
|
data,
|
|
searchKey,
|
|
searchPlaceholder = 'Ketikkan sesuatu...',
|
|
emptyText = 'Tidak ada data.',
|
|
onReorder,
|
|
getRowId,
|
|
toolbar,
|
|
renderSubRow,
|
|
defaultExpanded = false,
|
|
pagination,
|
|
onPageChange,
|
|
onPerPageChange,
|
|
onSearchChange,
|
|
searchValue,
|
|
}: DataTableProps<TData, TValue>) {
|
|
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
|
if (!defaultExpanded || !data.length) {
|
|
return {};
|
|
}
|
|
|
|
const initial: Record<string, boolean> = {};
|
|
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: () => <span className="block text-center">No</span>,
|
|
cell: ({ row }) => (
|
|
<span className="block text-center">
|
|
{isServerMode
|
|
? (currentPage - 1) * (pagination?.per_page ?? 25) +
|
|
row.index +
|
|
1
|
|
: row.index + 1}
|
|
</span>
|
|
),
|
|
meta: {
|
|
className: 'w-[50px] text-center',
|
|
headerClassName: 'w-[50px] text-center',
|
|
},
|
|
} as ColumnDef<TData, TValue>,
|
|
...(isSortable
|
|
? [
|
|
{
|
|
id: 'drag',
|
|
header: '',
|
|
cell: () => <DragHandleTrigger />,
|
|
meta: {
|
|
className: 'w-[40px]',
|
|
headerClassName: 'w-[40px]',
|
|
},
|
|
} as ColumnDef<TData, TValue>,
|
|
]
|
|
: []),
|
|
...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) && (
|
|
<div className="flex items-center gap-2">
|
|
{searchKey && (
|
|
<Input
|
|
placeholder={searchPlaceholder}
|
|
value={localSearch}
|
|
onChange={(event) =>
|
|
handleSearchChange(event.target.value)
|
|
}
|
|
className="max-w-sm"
|
|
/>
|
|
)}
|
|
{toolbar}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{isServerMode && onPerPageChange && (
|
|
<Select
|
|
value={String(pagination?.per_page ?? 25)}
|
|
onValueChange={(value) =>
|
|
onPerPageChange(Number(value))
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-[70px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="25">25</SelectItem>
|
|
<SelectItem value="50">50</SelectItem>
|
|
<SelectItem value="100">100</SelectItem>
|
|
<SelectItem value="999999">
|
|
Semua
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Card className="shrink-0 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) => (
|
|
<React.Fragment key={row.id}>
|
|
<TableRow
|
|
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>
|
|
{renderSubRow &&
|
|
row.getIsExpanded() && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={
|
|
visibleColumns.length
|
|
}
|
|
className="bg-muted/50 p-0"
|
|
>
|
|
<div className="p-4">
|
|
{renderSubRow(
|
|
row,
|
|
localSearch,
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</React.Fragment>
|
|
))
|
|
)
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={visibleColumns.length}
|
|
className="h-24 text-center"
|
|
>
|
|
{emptyText}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
<span className="text-sm text-muted-foreground">
|
|
{isServerMode
|
|
? `Halaman ${currentPage} dari ${totalPages}`
|
|
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
{isServerMode ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onPageChange(1)}
|
|
disabled={currentPage <= 1}
|
|
>
|
|
<ChevronsLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onPageChange(currentPage - 1)}
|
|
disabled={currentPage <= 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onPageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onPageChange(totalPages)}
|
|
disabled={currentPage >= totalPages}
|
|
>
|
|
<ChevronsRight className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|