681 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|