Teren-app/resources/js/Components/DataTable/DataTableNew2.vue
2025-11-20 18:11:43 +01:00

602 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="px-4 py-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="[
'py-4',
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right'
? 'text-right'
: header.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<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',
]"
>
<!-- 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">
<!-- Server-side pagination -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="meta.from"
:to="meta.to"
:total="meta.total"
/>
</template>
<!-- Client-side pagination -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>