609 lines
16 KiB
Vue
609 lines
16 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, h } from "vue";
|
|
import { router } from "@inertiajs/vue3";
|
|
import {
|
|
FlexRender,
|
|
getCoreRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
getFilteredRowModel,
|
|
useVueTable,
|
|
} from "@tanstack/vue-table";
|
|
import { valueUpdater } from "@/lib/utils";
|
|
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
|
import DataTablePagination from "./DataTablePagination.vue";
|
|
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
|
import DataTableToolbar from "./DataTableToolbar.vue";
|
|
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
|
import EmptyState from "../EmptyState.vue";
|
|
import Pagination from "../Pagination.vue";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/Components/ui/table";
|
|
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const props = defineProps({
|
|
// Column definitions using TanStack Table format or simple format
|
|
columns: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
// Data rows
|
|
data: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
// Server-side pagination meta (Laravel pagination)
|
|
meta: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
// Current sort state
|
|
sort: {
|
|
type: Object,
|
|
default: () => ({ key: null, direction: null }),
|
|
},
|
|
// Search/filter value
|
|
search: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
// Loading state
|
|
loading: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
// Page size for client-side pagination
|
|
pageSize: {
|
|
type: Number,
|
|
default: 10,
|
|
},
|
|
pageSizeOptions: {
|
|
type: Array,
|
|
default: () => [10, 25, 50, 100],
|
|
},
|
|
// Server-side routing
|
|
routeName: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
routeParams: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
pageParamName: {
|
|
type: String,
|
|
default: "page",
|
|
},
|
|
perPageParamName: {
|
|
type: String,
|
|
default: "per_page",
|
|
},
|
|
onlyProps: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
preserveState: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
preserveScroll: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
// Features
|
|
showPagination: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
showToolbar: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
filterColumn: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
filterPlaceholder: {
|
|
type: String,
|
|
default: "Filter...",
|
|
},
|
|
rowKey: {
|
|
type: [String, Function],
|
|
default: "id",
|
|
},
|
|
enableRowSelection: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
striped: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
hoverable: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
// Empty state
|
|
emptyText: {
|
|
type: String,
|
|
default: "No results.",
|
|
},
|
|
emptyIcon: {
|
|
type: [String, Object, Array],
|
|
default: null,
|
|
},
|
|
emptyDescription: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
"update:search",
|
|
"update:sort",
|
|
"update:page",
|
|
"update:pageSize",
|
|
"row:click",
|
|
"row:select",
|
|
"selection:change",
|
|
]);
|
|
|
|
// Determine if this is server-side mode
|
|
const isServerSide = computed(() => !!(props.meta && props.routeName));
|
|
|
|
// Convert simple column format to TanStack ColumnDef if needed
|
|
const columnDefinitions = computed(() => {
|
|
return props.columns.map((col) => {
|
|
// If already a full ColumnDef, return as is
|
|
if (col.accessorKey || col.accessorFn) {
|
|
return col;
|
|
}
|
|
|
|
// Convert simple format to ColumnDef
|
|
return {
|
|
accessorKey: col.key,
|
|
id: col.key,
|
|
header: ({ column }) => {
|
|
return h(DataTableColumnHeader, {
|
|
column,
|
|
title: col.label,
|
|
class: col.class,
|
|
});
|
|
},
|
|
cell: ({ row }) => {
|
|
const value = row.getValue(col.key);
|
|
return h("div", { class: col.class }, value);
|
|
},
|
|
enableSorting: col.sortable !== false,
|
|
enableHiding: col.hideable !== false,
|
|
meta: {
|
|
align: col.align || "left",
|
|
class: col.class,
|
|
},
|
|
};
|
|
});
|
|
});
|
|
|
|
// Add selection column if enabled
|
|
const columnsWithSelection = computed(() => {
|
|
if (!props.enableRowSelection) return columnDefinitions.value;
|
|
|
|
return [
|
|
{
|
|
id: "select",
|
|
header: ({ table }) => {
|
|
return h(Checkbox, {
|
|
modelValue: table.getIsAllPageRowsSelected(),
|
|
indeterminate: table.getIsSomePageRowsSelected(),
|
|
"onUpdate:modelValue": (value) => table.toggleAllPageRowsSelected(!!value),
|
|
"aria-label": "Select all",
|
|
});
|
|
},
|
|
cell: ({ row }) => {
|
|
return h(Checkbox, {
|
|
modelValue: row.getIsSelected(),
|
|
"onUpdate:modelValue": (value) => row.toggleSelected(!!value),
|
|
"aria-label": "Select row",
|
|
});
|
|
},
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
},
|
|
...columnDefinitions.value,
|
|
];
|
|
});
|
|
|
|
// Internal state
|
|
const sorting = ref([]);
|
|
const columnFilters = ref([]);
|
|
const columnVisibility = ref({});
|
|
const rowSelection = ref({});
|
|
|
|
// Client-side pagination state
|
|
const clientPagination = ref({
|
|
pageIndex: 0,
|
|
pageSize: props.pageSize,
|
|
});
|
|
|
|
// Initialize sorting from props
|
|
watch(
|
|
() => props.sort,
|
|
(newSort) => {
|
|
if (newSort?.key && newSort?.direction) {
|
|
sorting.value = [
|
|
{
|
|
id: newSort.key,
|
|
desc: newSort.direction === "desc",
|
|
},
|
|
];
|
|
} else {
|
|
sorting.value = [];
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Initialize filter from props
|
|
watch(
|
|
() => props.search,
|
|
(newSearch) => {
|
|
if (props.filterColumn && newSearch) {
|
|
columnFilters.value = [
|
|
{
|
|
id: props.filterColumn,
|
|
value: newSearch,
|
|
},
|
|
];
|
|
} else if (!newSearch) {
|
|
columnFilters.value = [];
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Pagination state
|
|
const pagination = computed(() => {
|
|
if (isServerSide.value) {
|
|
// Check URL for custom per-page parameter
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const perPageParam = props.perPageParamName || "per_page";
|
|
const urlPerPage = urlParams.get(perPageParam);
|
|
const pageSize = urlPerPage
|
|
? Number(urlPerPage)
|
|
: props.meta?.per_page ?? props.pageSize;
|
|
|
|
return {
|
|
pageIndex: (props.meta?.current_page ?? 1) - 1,
|
|
pageSize: pageSize,
|
|
};
|
|
}
|
|
return clientPagination.value;
|
|
});
|
|
|
|
// Watch for prop changes to update client pagination
|
|
watch(
|
|
() => props.pageSize,
|
|
(newSize) => {
|
|
if (!isServerSide.value) {
|
|
clientPagination.value.pageSize = newSize;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Create TanStack Table
|
|
const table = useVueTable({
|
|
get data() {
|
|
return props.data;
|
|
},
|
|
get columns() {
|
|
return columnsWithSelection.value;
|
|
},
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getPaginationRowModel: !isServerSide.value ? getPaginationRowModel() : undefined,
|
|
getSortedRowModel: !isServerSide.value ? getSortedRowModel() : undefined,
|
|
getFilteredRowModel: !isServerSide.value ? getFilteredRowModel() : undefined,
|
|
onSortingChange: (updater) => {
|
|
valueUpdater(updater, sorting);
|
|
const newSort = sorting.value[0];
|
|
if (newSort) {
|
|
emit("update:sort", {
|
|
key: newSort.id,
|
|
direction: newSort.desc ? "desc" : "asc",
|
|
});
|
|
if (isServerSide.value) {
|
|
doServerRequest({
|
|
sort: newSort.id,
|
|
direction: newSort.desc ? "desc" : "asc",
|
|
page: 1,
|
|
});
|
|
}
|
|
} else {
|
|
emit("update:sort", { key: null, direction: null });
|
|
if (isServerSide.value) {
|
|
doServerRequest({ sort: null, direction: null, page: 1 });
|
|
}
|
|
}
|
|
},
|
|
onColumnFiltersChange: (updater) => {
|
|
valueUpdater(updater, columnFilters);
|
|
const filter = columnFilters.value.find((f) => f.id === props.filterColumn);
|
|
const searchValue = filter?.value ?? "";
|
|
emit("update:search", searchValue);
|
|
if (isServerSide.value) {
|
|
clearTimeout(searchTimer.value);
|
|
searchTimer.value = setTimeout(() => {
|
|
doServerRequest({ search: searchValue, page: 1 });
|
|
}, 300);
|
|
}
|
|
},
|
|
onColumnVisibilityChange: (updater) => valueUpdater(updater, columnVisibility),
|
|
onRowSelectionChange: (updater) => {
|
|
valueUpdater(updater, rowSelection);
|
|
const selectedKeys = Object.keys(rowSelection.value).filter(
|
|
(key) => rowSelection.value[key]
|
|
);
|
|
emit("selection:change", selectedKeys);
|
|
},
|
|
onPaginationChange: (updater) => {
|
|
const currentPagination = pagination.value;
|
|
const newPagination =
|
|
typeof updater === "function" ? updater(currentPagination) : updater;
|
|
|
|
// Check if page size changed
|
|
const pageSizeChanged = newPagination.pageSize !== currentPagination.pageSize;
|
|
|
|
if (isServerSide.value) {
|
|
// If page size changed, go back to page 1
|
|
const targetPage = pageSizeChanged ? 1 : newPagination.pageIndex + 1;
|
|
doServerRequest({
|
|
page: targetPage,
|
|
perPage: newPagination.pageSize,
|
|
});
|
|
} else {
|
|
// Update client-side pagination state
|
|
clientPagination.value = {
|
|
pageIndex: newPagination.pageIndex,
|
|
pageSize: newPagination.pageSize,
|
|
};
|
|
}
|
|
|
|
if (pageSizeChanged) {
|
|
emit("update:pageSize", newPagination.pageSize);
|
|
}
|
|
if (newPagination.pageIndex !== currentPagination.pageIndex) {
|
|
emit("update:page", newPagination.pageIndex + 1);
|
|
}
|
|
},
|
|
manualSorting: isServerSide.value,
|
|
manualPagination: isServerSide.value,
|
|
manualFiltering: isServerSide.value,
|
|
enableRowSelection: props.enableRowSelection,
|
|
state: {
|
|
get sorting() {
|
|
return sorting.value;
|
|
},
|
|
get columnFilters() {
|
|
return columnFilters.value;
|
|
},
|
|
get columnVisibility() {
|
|
return columnVisibility.value;
|
|
},
|
|
get rowSelection() {
|
|
return rowSelection.value;
|
|
},
|
|
get pagination() {
|
|
return pagination.value;
|
|
},
|
|
},
|
|
});
|
|
|
|
const searchTimer = ref(null);
|
|
|
|
// Server-side request handler
|
|
function doServerRequest(overrides = {}) {
|
|
if (!props.routeName) return;
|
|
|
|
const existingParams = Object.fromEntries(
|
|
new URLSearchParams(window.location.search).entries()
|
|
);
|
|
|
|
const perPageParam = props.perPageParamName || "per_page";
|
|
const pageParam = props.pageParamName || "page";
|
|
|
|
const q = {
|
|
...existingParams,
|
|
sort: overrides.sort ?? props.sort?.key ?? null,
|
|
direction: overrides.direction ?? props.sort?.direction ?? null,
|
|
search: overrides.search ?? props.search ?? "",
|
|
};
|
|
|
|
// Use custom per_page parameter name
|
|
q[perPageParam] = overrides.perPage ?? props.meta?.per_page ?? props.pageSize;
|
|
if (perPageParam !== "per_page") {
|
|
delete q.per_page;
|
|
}
|
|
|
|
// Use custom page parameter name
|
|
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
|
|
if (pageParam !== "page") {
|
|
delete q.page;
|
|
}
|
|
|
|
Object.keys(q).forEach((k) => {
|
|
if (q[k] === null || q[k] === undefined || q[k] === "") delete q[k];
|
|
});
|
|
|
|
const url = route(props.routeName, props.routeParams || {});
|
|
router.get(url, q, {
|
|
preserveScroll: props.preserveScroll,
|
|
preserveState: props.preserveState,
|
|
replace: true,
|
|
only: props.onlyProps.length ? props.onlyProps : undefined,
|
|
});
|
|
}
|
|
|
|
// Row key helper
|
|
function keyOf(row) {
|
|
if (typeof props.rowKey === "function") return props.rowKey(row);
|
|
if (typeof props.rowKey === "string" && row && row[props.rowKey] != null)
|
|
return row[props.rowKey];
|
|
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full">
|
|
<!-- Toolbar -->
|
|
<DataTableToolbar
|
|
v-if="showToolbar"
|
|
:table="table"
|
|
:filter-column="filterColumn"
|
|
:filter-placeholder="filterPlaceholder"
|
|
:show-per-page-selector="isServerSide"
|
|
:per-page="pagination.pageSize"
|
|
:page-size-options="pageSizeOptions"
|
|
@update:per-page="(value) => table.setPageSize(value)"
|
|
class="p-2 border-t"
|
|
>
|
|
<template #filters="slotProps">
|
|
<slot name="toolbar-filters" v-bind="slotProps" />
|
|
</template>
|
|
<template #actions="slotProps">
|
|
<slot name="toolbar-actions" v-bind="slotProps" />
|
|
</template>
|
|
</DataTableToolbar>
|
|
|
|
<!-- Custom toolbar slot for full control -->
|
|
<slot name="toolbar" :table="table" />
|
|
|
|
<!-- Table -->
|
|
<div class="border-t">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
|
<TableHead
|
|
v-for="header in headerGroup.headers"
|
|
:key="header.id"
|
|
:class="[
|
|
'p-3',
|
|
header.column.columnDef.meta?.class,
|
|
header.column.columnDef.meta?.align === 'right'
|
|
? 'text-right'
|
|
: header.column.columnDef.meta?.align === 'center'
|
|
? 'text-center'
|
|
: 'text-left',
|
|
'bg-muted/50',
|
|
]"
|
|
>
|
|
<FlexRender
|
|
v-if="!header.isPlaceholder"
|
|
:render="header.column.columnDef.header"
|
|
:props="header.getContext()"
|
|
/>
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<!-- Loading State -->
|
|
<template v-if="loading">
|
|
<TableRow>
|
|
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
|
|
<SkeletonTable :rows="5" :cols="columns.length" />
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
|
|
<!-- Empty State -->
|
|
<template v-else-if="table.getRowModel().rows.length === 0">
|
|
<TableRow>
|
|
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
|
|
<EmptyState
|
|
:icon="emptyIcon"
|
|
:title="emptyText"
|
|
:description="emptyDescription"
|
|
size="sm"
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
|
|
<!-- Data Rows -->
|
|
<template v-else>
|
|
<TableRow
|
|
v-for="row in table.getRowModel().rows"
|
|
:key="keyOf(row.original)"
|
|
:data-state="row.getIsSelected() && 'selected'"
|
|
:class="
|
|
cn(
|
|
hoverable && 'cursor-pointer hover:bg-muted/50',
|
|
striped && row.index % 2 === 1 && 'bg-muted/50'
|
|
)
|
|
"
|
|
@click="$emit('row:click', row.original, row.index)"
|
|
>
|
|
<TableCell
|
|
v-for="cell in row.getVisibleCells()"
|
|
:key="cell.id"
|
|
:class="[
|
|
cell.column.columnDef.meta?.class,
|
|
cell.column.columnDef.meta?.align === 'right'
|
|
? 'text-right'
|
|
: cell.column.columnDef.meta?.align === 'center'
|
|
? 'text-center'
|
|
: 'text-left',
|
|
'p-3',
|
|
]"
|
|
>
|
|
<!-- Use slot if provided -->
|
|
<slot
|
|
:name="`cell-${cell.column.id}`"
|
|
:row="row.original"
|
|
:column="cell.column"
|
|
:value="cell.getValue()"
|
|
:index="row.index"
|
|
>
|
|
<!-- Otherwise use FlexRender -->
|
|
<FlexRender
|
|
:render="cell.column.columnDef.cell"
|
|
:props="cell.getContext()"
|
|
/>
|
|
</slot>
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="showPagination" class="border-t border-gray-200 p-4">
|
|
<!-- Server-side pagination -->
|
|
<template v-if="isServerSide && meta?.links">
|
|
<Pagination
|
|
:links="meta.links"
|
|
:from="meta.from"
|
|
:to="meta.to"
|
|
:total="meta.total"
|
|
:current-page="meta.current_page"
|
|
:last-page="meta.last_page"
|
|
:per-page="meta.per_page"
|
|
:page-param="pageParamName"
|
|
:per-page-param="perPageParamName"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Client-side pagination -->
|
|
<template v-else>
|
|
<DataTablePagination :table="table" />
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|