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

706 lines
21 KiB
Vue

<script setup>
import { ref, computed, watch, h, useSlots } from "vue";
import { router } from "@inertiajs/vue3";
import {
useVueTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
} from "@tanstack/vue-table";
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
import DataTablePagination from "./DataTablePagination.vue";
import DataTableViewOptions from "./DataTableViewOptions.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 slots = useSlots();
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) =>
col.key &&
col.label &&
typeof col.key === "string" &&
typeof col.label === "string"
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === "number" &&
typeof meta.per_page === "number" &&
typeof meta.total === "number" &&
typeof meta.last_page === "number"),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: "" },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: "page" },
onlyProps: { type: Array, default: () => [] },
// Features
showPagination: { type: Boolean, default: true },
showViewOptions: { type: Boolean, default: false },
rowKey: { type: [String, Function], default: "uuid" },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: "Ni podatkov" },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: {
type: String,
default: "right",
validator: (v) => ["left", "right"].includes(v),
},
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 },
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
"update:search",
"update:sort",
"update:page",
"update:pageSize",
"row:click",
"row:select",
"selection:change",
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// 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);
}
// Convert simple column format to TanStack Table ColumnDef format
const columnDefinitions = computed(() => {
return props.columns.map((col) => ({
accessorKey: col.key,
id: col.key,
header: ({ column }) => {
return h(DataTableColumnHeader, {
column,
title: col.label,
class: col.class,
});
},
cell: ({ row, getValue }) => {
return getValue();
},
enableSorting: col.sortable !== false,
enableHiding: col.hideable !== false,
meta: {
align: col.align || "left",
class: col.class,
},
}));
});
// Add selection column if selectable
const columnsWithSelection = computed(() => {
if (!props.selectable) return columnDefinitions.value;
return [
{
id: "select",
enableHiding: false,
enableSorting: false,
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",
});
},
},
...columnDefinitions.value,
];
});
// Add actions column if showActions
const finalColumns = computed(() => {
if (!props.showActions && !slots.actions) return columnsWithSelection.value;
return [
...columnsWithSelection.value,
{
id: "actions",
enableHiding: false,
enableSorting: false,
header: () => h("span", { class: "sr-only" }, "Actions"),
cell: ({ row }) => {
// Actions will be rendered via slot
return null;
},
},
];
});
// Internal search state
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Internal sorting state
const sorting = computed(() => {
if (!props.sort?.key || !props.sort?.direction) return [];
return [
{
id: props.sort.key,
desc: props.sort.direction === "desc",
},
];
});
// Internal pagination state
const pagination = computed(() => {
if (isServerSide.value) {
return {
pageIndex: (props.meta?.current_page ?? 1) - 1,
pageSize: props.meta?.per_page ?? props.pageSize,
};
}
return {
pageIndex: internalPage.value - 1,
pageSize: internalPageSize.value,
};
});
const internalPage = ref(1);
const internalPageSize = ref(props.pageSize);
// Row selection
const rowSelection = ref({});
// Create TanStack Table instance
const table = useVueTable({
get data() {
return props.rows;
},
get columns() {
return finalColumns.value;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: isClientSide.value ? getPaginationRowModel() : undefined,
getSortedRowModel: isClientSide.value ? getSortedRowModel() : undefined,
getFilteredRowModel: isClientSide.value ? getFilteredRowModel() : undefined,
globalFilterFn: "includesString",
onGlobalFilterChange: (updater) => {
const newFilter =
typeof updater === "function" ? updater(internalSearch.value) : updater;
handleSearchChange(newFilter);
},
onSortingChange: (updater) => {
const newSorting = typeof updater === "function" ? updater(sorting.value) : updater;
if (newSorting.length > 0) {
const sort = newSorting[0];
emit("update:sort", {
key: sort.id,
direction: sort.desc ? "desc" : "asc",
});
if (isServerSide.value) {
doServerRequest({
sort: sort.id,
direction: sort.desc ? "desc" : "asc",
page: 1,
});
}
} else {
emit("update:sort", { key: null, direction: null });
if (isServerSide.value) {
doServerRequest({ sort: null, direction: null, page: 1 });
}
}
},
onPaginationChange: (updater) => {
const newPagination =
typeof updater === "function" ? updater(pagination.value) : updater;
if (isServerSide.value) {
doServerRequest({ page: newPagination.pageIndex + 1 });
} else {
internalPage.value = newPagination.pageIndex + 1;
emit("update:page", newPagination.pageIndex + 1);
}
internalPageSize.value = newPagination.pageSize;
emit("update:pageSize", newPagination.pageSize);
},
onRowSelectionChange: (updater) => {
const newSelection =
typeof updater === "function" ? updater(rowSelection.value) : updater;
rowSelection.value = newSelection;
const selectedKeys = Object.keys(newSelection).filter((key) => newSelection[key]);
emit("selection:change", selectedKeys);
},
manualSorting: isServerSide.value,
manualPagination: isServerSide.value,
manualFiltering: isServerSide.value,
enableRowSelection: props.selectable,
state: {
get sorting() {
return sorting.value;
},
get pagination() {
return pagination.value;
},
get rowSelection() {
return rowSelection.value;
},
get globalFilter() {
return internalSearch.value;
},
},
});
// Server-side request
function doServerRequest(overrides = {}) {
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams,
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction:
overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? "",
};
const pageParam = props.pageParamName || "page";
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,
});
}
function handleSearchChange(value) {
internalSearch.value = value;
emit("update:search", value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSize.value = newSize;
emit("update:pageSize", newSize);
if (isServerSide.value) {
doServerRequest({ perPage: newSize, page: 1 });
} else {
table.setPageSize(newSize);
}
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== "undefined") {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener("resize", checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value) return props.rows;
return table.getRowModel().rows.map((row) => row.original);
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return table.getFilteredRowModel().rows.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return total.value === 0 ? 0 : pageIndex * pageSize + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return Math.min((pageIndex + 1) * pageSize, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? "";
});
return exported;
});
if (format === "csv") {
exportToCSV(data);
} else if (format === "xlsx") {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(","),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return "";
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(",")
),
].join("\n");
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `export_${new Date().toISOString().split("T")[0]}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
exportToCSV(data);
}
// Expose table instance and utilities for toolbar usage
defineExpose({
table,
internalSearch,
internalPageSize,
rowSelection,
handleSearchChange,
handlePageSizeChange,
handleExport,
exportToCSV,
exportToXLSX,
});
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar Slot - Users can build their own toolbar using the table instance -->
<slot
name="toolbar"
:table="table"
:search="internalSearch"
:page-size="internalPageSize"
:row-selection="rowSelection"
/>
<!-- View Options -->
<div v-if="showViewOptions" class="flex items-center space-x-2">
<DataTableViewOptions :table="table" />
</div>
<!-- Table Container -->
<div data-table-container class="relative overflow-hidden">
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<Table>
<TableHeader class="p-4">
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right'
? 'text-right'
: header.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<div v-if="!header.isPlaceholder">
<component :is="header.column.columnDef.header(header.getContext())" />
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- Loading State -->
<template v-if="loading">
<TableRow>
<TableCell
:colspan="
columns.length +
(selectable ? 1 : 0) +
(showActions || slots.actions ? 1 : 0)
"
class="h-24 text-center"
>
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<!-- Empty State -->
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<TableRow>
<TableCell
:colspan="
columns.length +
(selectable ? 1 : 0) +
(showActions || slots.actions ? 1 : 0)
"
class="h-24 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</TableCell>
</TableRow>
</template>
<!-- Rows -->
<template v-else>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() && 'selected'"
:class="
cn(
hoverable && 'cursor-pointer',
striped && row.index % 2 === 1 && 'bg-muted/50'
)
"
@click="
(e) => {
const interactive = e.target.closest(
'button, a, [role=button], [data-dropdown], .relative, input[type=checkbox]'
);
if (interactive) return;
$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',
]"
>
<template v-if="cell.column.id === 'actions'">
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</template>
<template v-else>
<slot
:name="`cell-${cell.column.id}`"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<slot
name="cell"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<component :is="cell.column.columnDef.cell(cell.getContext())" />
</slot>
</slot>
</template>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="row in table.getRowModel().rows"
:key="row.id"
@click="$emit('row:click', row.original, row.index)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row.original" :index="row.index">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row.original"
:column="col"
:value="row.original?.[col.key]"
:index="row.index"
>
{{ row.original?.[col.key] ?? "—" }}
</slot>
</span>
</div>
<div
v-if="showActions || $slots.actions"
class="pt-2 border-t border-gray-100"
>
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination :links="meta.links" :from="from" :to="to" :total="total" />
</template>
<!-- TanStack Table Pagination for client-side -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>