706 lines
21 KiB
Vue
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>
|