dress/resources/js/components/data-table.tsx

681 lines
31 KiB
TypeScript

import type {
ColumnDef,
ColumnFiltersState,
PaginationState,
SortingState,
VisibilityState,
} from '@tanstack/react-table';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ListFilter,
Settings2,
X,
} from 'lucide-react';
import React from 'react';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Input } from './ui/input';
interface DataTableFilterOption {
columnId: string;
title: string;
options: { label: string; value: string }[];
}
export interface DataTableBulkAction<TData> {
label: string;
onClick: (rows: TData[]) => void;
icon?: React.ElementType;
variant?: 'default' | 'destructive';
}
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
filters?: DataTableFilterOption[];
bulkActions?: DataTableBulkAction<TData>[];
showNumbering?: boolean;
showSelection?: boolean;
rowSelection?: any;
onRowSelectionChange?: (selection: any) => void;
meta?: any; // PaginatedData<TData> from Inertia
}
export function DataTable<TData, TValue>({
columns,
data,
filters,
bulkActions,
showNumbering = true,
showSelection = true,
rowSelection = {},
onRowSelectionChange,
meta,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [globalFilter, setGlobalFilter] = React.useState('');
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
// Reset to page 1 when filters or search change
React.useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, [globalFilter, columnFilters]);
const finalColumns = React.useMemo(() => {
const computedColumns = [...columns];
if (showNumbering) {
const numberingColumn: ColumnDef<TData, TValue> = {
id: 'numbering',
header: 'No.',
cell: ({ row }) => row.index + 1,
enableHiding: false,
};
computedColumns.unshift(numberingColumn);
}
if (showSelection) {
const selectColumn: ColumnDef<TData, TValue> = {
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() &&
'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
};
computedColumns.unshift(selectColumn);
}
return computedColumns;
}, [columns, showNumbering, showSelection]);
const table = useReactTable({
data,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: onRowSelectionChange,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
pagination,
},
onPaginationChange: setPagination,
autoResetPageIndex: false,
manualPagination: !!meta,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
return (
<div className="space-y-4">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex flex-1 flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<Input
placeholder="Cari..."
value={globalFilter ?? ''}
onChange={(event) =>
setGlobalFilter(event.target.value)
}
className="max-w-sm"
/>
{columnFilters.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{columnFilters.map((filter) => {
const filterDef = filters?.find(
(f) => f.columnId === filter.id,
);
const option = filterDef?.options.find(
(o) =>
String(o.value) ===
String(filter.value),
);
const label = option
? option.label
: String(filter.value);
return (
<Badge
key={filter.id}
variant="secondary"
className="h-8 gap-1 pr-1 font-normal text-nowrap"
>
<span className="mr-1 text-muted-foreground">
{filterDef?.title || filter.id}:
</span>
{label}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-transparent"
onClick={() =>
table
.getColumn(filter.id)
?.setFilterValue(undefined)
}
>
<X className="h-3 w-3" />
</Button>
</Badge>
);
})}
<Button
variant="ghost"
size="sm"
onClick={() => {
setColumnFilters([]);
setGlobalFilter('');
}}
className="h-8 px-2 text-xs"
>
Reset Filter
<X className="ml-1 h-3 w-3" />
</Button>
</div>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{selectedRows.length > 0 && bulkActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
className="animate-in gap-2 transition-all fade-in slide-in-from-left-2"
>
Aksi Massal ({selectedRows.length})
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{bulkActions.map((action, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
action.onClick(
selectedRows.map(
(r) => r.original,
),
)
}
className={
action.variant === 'destructive'
? 'text-red-600 focus:text-red-600'
: ''
}
>
{action.icon && (
<action.icon className="mr-2 h-4 w-4" />
)}
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{filters && filters.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<ListFilter className="h-4 w-4" />
Filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
Filter Berdasarkan
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filters.map((filter) => {
const column = table.getColumn(
filter.columnId,
);
if (!column) {
return null;
}
return (
<DropdownMenuSub key={filter.columnId}>
<DropdownMenuSubTrigger>
<span>{filter.title}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={(e) =>
e.preventDefault()
}
onClick={() =>
column.setFilterValue(
undefined,
)
}
>
Semua
</DropdownMenuItem>
<DropdownMenuSeparator />
{filter.options.map(
(option) => (
<DropdownMenuItem
key={option.value}
onSelect={(e) =>
e.preventDefault()
}
onClick={() => {
if (
option.value ===
'true'
) {
column.setFilterValue(
true,
);
} else if (
option.value ===
'false'
) {
column.setFilterValue(
false,
);
} else {
column.setFilterValue(
option.value,
);
}
}}
>
{option.label}
</DropdownMenuItem>
),
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Settings2 className="h-4 w-4" />
Kolom
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
Tampilan Kolom
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(e) => e.preventDefault()}
>
{(column.columnDef.meta as any)
?.title ||
(typeof column.columnDef
.header === 'string'
? column.columnDef.header
: String(column.id).replace(
/_/g,
' ',
))}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="overflow-x-auto rounded-md border bg-background">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
className={
header.column.id === 'select' ||
header.column.id === 'numbering'
? 'w-10 text-center'
: ''
}
>
{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}
className={
cell.column.id === 'select' ||
cell.column.id === 'numbering'
? 'w-10 text-center'
: ''
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={finalColumns.length}
className="h-24 text-center"
>
<Empty>
<EmptyHeader>
<EmptyTitle>Ooops...</EmptyTitle>
<EmptyDescription>
Tidak ada data yang ditemukan.
</EmptyDescription>
</EmptyHeader>
</Empty>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center justify-between gap-4 py-4 sm:flex-row">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} dari{' '}
{meta
? meta.total
: table.getFilteredRowModel().rows.length}{' '}
baris dipilih.
</div>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
{!meta && (
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
Baris per halaman
</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, 30, 40, 50].map((pageSize) => (
<SelectItem
key={pageSize}
value={`${pageSize}`}
>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center justify-center text-sm font-medium">
{meta ? (
<>
Menampilkan {meta.from || 0} sampai{' '}
{meta.to || 0} dari {meta.total} data
</>
) : table.getFilteredRowModel().rows.length > 0 ? (
<>
Menampilkan{' '}
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}{' '}
sampai{' '}
{Math.min(
(table.getState().pagination.pageIndex +
1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)}{' '}
dari {table.getFilteredRowModel().rows.length}{' '}
data
</>
) : (
'Tidak ada data'
)}
</div>
{meta ? (() => {
const { current_page, last_page, prev_page_url, next_page_url } = meta;
const leftPages = [];
for (let i = current_page - 3; i < current_page; i++) {
if (i > 0) leftPages.push(i);
}
const rightPages = [];
for (let i = current_page + 1; i <= current_page + 2; i++) {
if (i <= last_page) rightPages.push(i);
}
const handlePageChange = (page: number) => {
const url = new URL(window.location.href);
url.searchParams.set('page', page.toString());
import('@inertiajs/react').then(({ router }) => {
router.get(url.toString(), {}, { preserveState: true, preserveScroll: true });
});
};
return (
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
if (prev_page_url) {
import('@inertiajs/react').then(({ router }) => {
router.get(prev_page_url, {}, { preserveState: true, preserveScroll: true });
});
}
}}
disabled={!prev_page_url}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{leftPages.map(p => (
<Button key={p} variant="outline" className="h-8 px-3" onClick={() => handlePageChange(p)}>
{p}
</Button>
))}
<Button variant="outline" className="h-8 px-3 border-none bg-transparent shadow-none" disabled>
...
</Button>
{rightPages.map(p => (
<Button key={p} variant="outline" className="h-8 px-3" onClick={() => handlePageChange(p)}>
{p}
</Button>
))}
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
if (next_page_url) {
import('@inertiajs/react').then(({ router }) => {
router.get(next_page_url, {}, { preserveState: true, preserveScroll: true });
});
}
}}
disabled={!next_page_url}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
})() : (
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
</div>
);
}