siakad-itm/resources/js/components/data-table.tsx
Yoga Pangestu 6b36d65eb9
Some checks failed
tests / ci (pull_request) Has been cancelled
feat: update search placeholders and remove empty text in data tables
2026-08-30 20:58:19 +07:00

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>
</>
);
}