Changes to UI and other stuff
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { h } from 'vue';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff } from 'lucide-vue-next';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
column: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
class: {
|
||||
type: [String, Object, Array],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const getSortIcon = (column) => {
|
||||
if (!column.getIsSorted()) return ArrowUpDown;
|
||||
return column.getIsSorted() === 'asc' ? ArrowUp : ArrowDown;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="column.getCanSort()" :class="cn('flex items-center space-x-2', props.class)">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
<span>{{ title }}</span>
|
||||
<component
|
||||
:is="getSortIcon(column)"
|
||||
class="ml-2 h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="column.toggleSorting(false)">
|
||||
<ArrowUp class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="column.toggleSorting(true)">
|
||||
<ArrowDown class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="column.toggleVisibility(false)">
|
||||
<EyeOff class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div v-else :class="cn('', props.class)">
|
||||
{{ title }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, h } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
flexRender,
|
||||
} from '@tanstack/vue-table';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
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 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
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showFilters: { type: Boolean, default: false },
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false },
|
||||
showOptions: { type: Boolean, default: false },
|
||||
showSelectedCount: { type: Boolean, default: false },
|
||||
showOptionsMenu: { type: Boolean, default: false },
|
||||
showViewOptions: { type: Boolean, default: false },
|
||||
compactToolbar: { type: Boolean, default: false },
|
||||
hasActiveFilters: { 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 && !props.$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,
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:search="internalSearch"
|
||||
:show-search="showSearch"
|
||||
:show-page-size="showPageSize"
|
||||
:page-size="internalPageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:selected-count="Object.keys(rowSelection).filter((key) => rowSelection[key]).length"
|
||||
:show-selected-count="showSelectedCount"
|
||||
:show-export="showExport"
|
||||
:show-add="showAdd"
|
||||
:show-options="showOptions"
|
||||
:show-filters="showFilters"
|
||||
:show-options-menu="showOptionsMenu"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:compact="compactToolbar"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<template #add>
|
||||
<slot name="toolbar-add" />
|
||||
</template>
|
||||
<template #options>
|
||||
<slot name="toolbar-options" />
|
||||
</template>
|
||||
<template #filters>
|
||||
<slot name="toolbar-filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="toolbar-actions" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- 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>
|
||||
<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="flexRender(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 ? 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 ? 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="flexRender(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>
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
<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>
|
||||
@@ -0,0 +1,884 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
|
||||
import EmptyState from '../EmptyState.vue';
|
||||
import Pagination from '../Pagination.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import {
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faSort,
|
||||
faSortUp,
|
||||
faSortDown,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
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
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showFilters: { type: Boolean, default: false },
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
|
||||
showOptions: { type: Boolean, default: false }, // Show custom options slot
|
||||
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
|
||||
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
|
||||
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
|
||||
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
|
||||
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 }, // Tailwind md breakpoint
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Client-side sorting
|
||||
const sortedRows = computed(() => {
|
||||
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
|
||||
return props.rows;
|
||||
}
|
||||
|
||||
const key = props.sort.key;
|
||||
const direction = props.sort.direction;
|
||||
const sorted = [...props.rows];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
let aVal = a?.[key];
|
||||
let bVal = b?.[key];
|
||||
|
||||
// Handle nulls/undefined
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Handle dates
|
||||
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
// Handle strings
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
if (direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
}
|
||||
return bStr.localeCompare(aStr);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
// Client-side pagination
|
||||
const currentPage = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.current_page ?? 1;
|
||||
return internalPage.value;
|
||||
});
|
||||
|
||||
const internalPage = ref(1);
|
||||
// Use computed for pageSize to always reflect the correct value
|
||||
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
|
||||
const internalPageSize = computed({
|
||||
get: () => {
|
||||
if (isServerSide.value && props.meta?.per_page) {
|
||||
return props.meta.per_page;
|
||||
}
|
||||
return internalPageSizeState.value ?? props.pageSize;
|
||||
},
|
||||
set: (value) => {
|
||||
internalPageSizeState.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Internal state for client-side or when user changes page size before server responds
|
||||
const internalPageSizeState = ref(null);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.last_page ?? 1;
|
||||
return Math.ceil(sortedRows.value.length / internalPageSize.value);
|
||||
});
|
||||
|
||||
const paginatedRows = computed(() => {
|
||||
if (isServerSide.value) return props.rows;
|
||||
|
||||
const start = (currentPage.value - 1) * internalPageSize.value;
|
||||
const end = start + internalPageSize.value;
|
||||
return sortedRows.value.slice(start, end);
|
||||
});
|
||||
|
||||
// Client-side search
|
||||
const filteredRows = computed(() => {
|
||||
if (isServerSide.value || !internalSearch.value) {
|
||||
return paginatedRows.value;
|
||||
}
|
||||
|
||||
const searchTerm = internalSearch.value.toLowerCase();
|
||||
return paginatedRows.value.filter((row) => {
|
||||
return props.columns.some((col) => {
|
||||
const value = row?.[col.key];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Search handling
|
||||
const internalSearch = ref(props.search);
|
||||
watch(
|
||||
() => props.search,
|
||||
(newVal) => {
|
||||
internalSearch.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// Selection
|
||||
const selectedRows = ref(new Set());
|
||||
const isAllSelected = computed(() => {
|
||||
if (filteredRows.value.length === 0) return false;
|
||||
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
|
||||
});
|
||||
const isSomeSelected = computed(() => {
|
||||
return (
|
||||
selectedRows.value.size > 0 &&
|
||||
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
|
||||
);
|
||||
});
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
filteredRows.value.forEach((row) => {
|
||||
selectedRows.value.delete(keyOf(row));
|
||||
});
|
||||
} else {
|
||||
filteredRows.value.forEach((row) => {
|
||||
selectedRows.value.add(keyOf(row));
|
||||
});
|
||||
}
|
||||
emit('selection:change', Array.from(selectedRows.value));
|
||||
}
|
||||
|
||||
function toggleSelectRow(row) {
|
||||
const key = keyOf(row);
|
||||
if (selectedRows.value.has(key)) {
|
||||
selectedRows.value.delete(key);
|
||||
} else {
|
||||
selectedRows.value.add(key);
|
||||
}
|
||||
emit('row:select', row, selectedRows.value.has(key));
|
||||
emit('selection:change', Array.from(selectedRows.value));
|
||||
}
|
||||
|
||||
// Sorting
|
||||
function toggleSort(col) {
|
||||
if (!col.sortable) return;
|
||||
|
||||
if (isServerSide.value) {
|
||||
const current = props.sort || { key: null, direction: null };
|
||||
let direction = 'asc';
|
||||
if (current.key === col.key) {
|
||||
direction =
|
||||
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
|
||||
}
|
||||
emit('update:sort', { key: direction ? col.key : null, direction });
|
||||
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
|
||||
} else {
|
||||
const current = props.sort || { key: null, direction: null };
|
||||
let direction = 'asc';
|
||||
if (current.key === col.key) {
|
||||
direction =
|
||||
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
|
||||
}
|
||||
emit('update:sort', { key: direction ? col.key : null, direction });
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon(col) {
|
||||
if (props.sort?.key !== col.key) return faSort;
|
||||
if (props.sort?.direction === 'asc') return faSortUp;
|
||||
if (props.sort?.direction === 'desc') return faSortDown;
|
||||
return faSort;
|
||||
}
|
||||
|
||||
// Server-side request
|
||||
function doServerRequest(overrides = {}) {
|
||||
// Preserve existing query parameters from URL
|
||||
const existingParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const q = {
|
||||
...existingParams, // Preserve all existing query parameters
|
||||
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? 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;
|
||||
}
|
||||
|
||||
// Clean nulls and empty strings
|
||||
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,
|
||||
onSuccess: () => {
|
||||
// Scroll to top of table after server request completes
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
internalPageSizeState.value = newSize;
|
||||
emit('update:pageSize', newSize);
|
||||
|
||||
if (isServerSide.value) {
|
||||
// Reset to page 1 when changing page size to avoid being on a non-existent page
|
||||
doServerRequest({ perPage: newSize, page: 1 });
|
||||
} else {
|
||||
// Calculate total pages with new size
|
||||
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
|
||||
// If current page exceeds new total, reset to last page or page 1
|
||||
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
|
||||
internalPage.value = targetPage;
|
||||
emit('update:page', targetPage);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ page });
|
||||
} else {
|
||||
internalPage.value = page;
|
||||
emit('update:page', page);
|
||||
}
|
||||
|
||||
// Scroll to top of table after page change
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, isServerSide.value ? 100 : 50);
|
||||
}
|
||||
|
||||
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 || !internalSearch.value) {
|
||||
return paginatedRows.value;
|
||||
}
|
||||
return filteredRows.value;
|
||||
});
|
||||
|
||||
const total = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.total ?? 0;
|
||||
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
|
||||
});
|
||||
|
||||
const from = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.from ?? 0;
|
||||
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
|
||||
});
|
||||
|
||||
const to = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.to ?? 0;
|
||||
return Math.min(currentPage.value * internalPageSize.value, 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) {
|
||||
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
|
||||
// Simple implementation: use CSV format with .xlsx extension
|
||||
exportToCSV(data);
|
||||
// In production, you might want to use a library like 'xlsx' or 'exceljs'
|
||||
}
|
||||
|
||||
// Generate visible page numbers with ellipsis
|
||||
function getVisiblePages() {
|
||||
const pages = [];
|
||||
const total = totalPages.value;
|
||||
const current = currentPage.value;
|
||||
const maxVisible = 7;
|
||||
|
||||
if (total <= maxVisible) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate start and end
|
||||
let start = Math.max(2, current - 1);
|
||||
let end = Math.min(total - 1, current + 1);
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= 3) {
|
||||
end = Math.min(4, total - 1);
|
||||
}
|
||||
|
||||
// Adjust if near end
|
||||
if (current >= total - 2) {
|
||||
start = Math.max(2, total - 3);
|
||||
}
|
||||
|
||||
// Add ellipsis after first page if needed
|
||||
if (start > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add middle pages
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis before last page if needed
|
||||
if (end < total - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
if (total > 1) {
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:search="internalSearch"
|
||||
:show-search="showSearch"
|
||||
:show-page-size="showPageSize"
|
||||
:page-size="internalPageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:selected-count="selectedRows.size"
|
||||
:show-selected-count="showSelectedCount"
|
||||
:show-export="showExport"
|
||||
:show-add="showAdd"
|
||||
:show-options="showOptions"
|
||||
:show-filters="showFilters"
|
||||
:show-options-menu="showOptionsMenu"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:compact="compactToolbar"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<template #add>
|
||||
<slot name="toolbar-add" />
|
||||
</template>
|
||||
<template #options>
|
||||
<slot name="toolbar-options" />
|
||||
</template>
|
||||
<template #filters>
|
||||
<slot name="toolbar-filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="toolbar-actions" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div
|
||||
data-table-container
|
||||
class="relative overflow-hidden"
|
||||
>
|
||||
<!-- Desktop Table View -->
|
||||
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<!-- Header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Select All Checkbox -->
|
||||
<th
|
||||
v-if="selectable"
|
||||
class="w-12 px-6 py-3 text-left"
|
||||
scope="col"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isSomeSelected && !isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
|
||||
<!-- Column Headers -->
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
:class="[
|
||||
col.class,
|
||||
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
|
||||
@click="toggleSort(col)"
|
||||
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<FontAwesomeIcon
|
||||
:icon="getSortIcon(col)"
|
||||
class="w-3 h-3 transition-colors"
|
||||
:class="{
|
||||
'text-gray-700': sort?.key === col.key,
|
||||
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</th>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<th
|
||||
v-if="showActions || $slots.actions"
|
||||
scope="col"
|
||||
class="relative w-px px-6 py-3"
|
||||
>
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<tr>
|
||||
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template v-else-if="!loading && displayRows.length === 0">
|
||||
<tr>
|
||||
<td
|
||||
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
|
||||
class="px-6 py-12 text-center"
|
||||
>
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Rows -->
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="(row, idx) in displayRows"
|
||||
:key="keyOf(row)"
|
||||
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
|
||||
class="transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer': !!$attrs.onRowClick,
|
||||
'bg-gray-50': striped && idx % 2 === 1,
|
||||
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
|
||||
'bg-primary-50': selectedRows.has(keyOf(row)),
|
||||
}"
|
||||
>
|
||||
<!-- Select Checkbox -->
|
||||
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.has(keyOf(row))"
|
||||
@click.stop="toggleSelectRow(row)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Data Cells -->
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
:class="[
|
||||
col.class,
|
||||
col.align === 'right'
|
||||
? 'text-right text-gray-900'
|
||||
: col.align === 'center'
|
||||
? 'text-center text-gray-900'
|
||||
: 'text-left text-gray-900',
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:row="row"
|
||||
:column="col"
|
||||
:value="row?.[col.key]"
|
||||
:index="idx"
|
||||
>
|
||||
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
|
||||
<span>{{ row?.[col.key] ?? '—' }}</span>
|
||||
</slot>
|
||||
</slot>
|
||||
</td>
|
||||
|
||||
<!-- Actions Cell -->
|
||||
<td
|
||||
v-if="showActions || $slots.actions"
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
|
||||
>
|
||||
<slot name="actions" :row="row" :index="idx">
|
||||
<slot name="row-actions" :row="row" :index="idx" />
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</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 && displayRows.length === 0">
|
||||
<div class="p-6">
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(row, idx) in displayRows"
|
||||
:key="keyOf(row)"
|
||||
@click="$emit('row:click', row, idx)"
|
||||
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
|
||||
>
|
||||
<slot name="mobile-card" :row="row" :index="idx">
|
||||
<!-- 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"
|
||||
:column="col"
|
||||
:value="row?.[col.key]"
|
||||
:index="idx"
|
||||
>
|
||||
{{ row?.[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
|
||||
<slot name="actions" :row="row" :index="idx">
|
||||
<slot name="row-actions" :row="row" :index="idx" />
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination && totalPages > 1">
|
||||
<!-- Use existing Pagination component for server-side -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:total="total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Custom pagination for client-side -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Prejšnja
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Naslednja
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Prikazano
|
||||
<span class="font-medium">{{ from }}</span>
|
||||
do
|
||||
<span class="font-medium">{{ to }}</span>
|
||||
od
|
||||
<span class="font-medium">{{ total }}</span>
|
||||
rezultatov
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
<button
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="sr-only">Prejšnja</span>
|
||||
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in getVisiblePages()" :key="page">
|
||||
<button
|
||||
v-if="page !== '...'"
|
||||
@click="handlePageChange(page)"
|
||||
:aria-current="page === currentPage ? 'page' : undefined"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
|
||||
:class="
|
||||
page === currentPage
|
||||
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
|
||||
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
|
||||
"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="sr-only">Naslednja</span>
|
||||
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
{{ table.getFilteredSelectedRowModel().rows.length }} of
|
||||
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||
</div>
|
||||
<div class="flex items-center space-x-6 lg:space-x-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
:model-value="`${table.getState().pagination.pageSize}`"
|
||||
@update:model-value="(value) => table.setPageSize(Number(value))"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[70px]">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectItem
|
||||
v-for="pageSize in pageSizeOptions"
|
||||
:key="pageSize"
|
||||
:value="`${pageSize}`"
|
||||
>
|
||||
{{ pageSize }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {{ table.getState().pagination.pageIndex + 1 }} of
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hidden h-8 w-8 p-0 lg:flex"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<span class="sr-only">Go to first page</span>
|
||||
<ChevronsLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 w-8 p-0"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
>
|
||||
<span class="sr-only">Go to previous page</span>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 w-8 p-0"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
<span class="sr-only">Go to next page</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hidden h-8 w-8 p-0 lg:flex"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
<span class="sr-only">Go to last page</span>
|
||||
<ChevronsRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,318 +1,182 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faSearch, faTimes, faDownload, faEllipsisVertical, faGear, faPlus, faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import Dropdown from '../Dropdown.vue';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { computed, ref } from "vue";
|
||||
import { X, Settings2 } from "lucide-vue-next";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/Components/ui/popover';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
} from "@/Components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
|
||||
/**
|
||||
* DataTable Toolbar Component
|
||||
* Simplified toolbar following shadcn-vue patterns for TanStack Table integration
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
search: { type: String, default: '' },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 10 },
|
||||
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
|
||||
selectedCount: { type: Number, default: 0 },
|
||||
showSelectedCount: { type: Boolean, default: false }, // Control visibility of selected count badge
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false }, // Control visibility of add buttons dropdown
|
||||
showOptions: { type: Boolean, default: false }, // Control visibility of custom options slot
|
||||
showFilters: { type: Boolean, default: false }, // Control visibility of filters button
|
||||
showOptionsMenu: { type: Boolean, default: false }, // Control visibility of options menu (three dots)
|
||||
compact: { type: Boolean, default: false }, // New prop to toggle compact menu mode
|
||||
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
|
||||
// TanStack Table instance
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Column to filter on (e.g., 'email', 'name')
|
||||
filterColumn: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
// Placeholder text for filter input
|
||||
filterPlaceholder: {
|
||||
type: String,
|
||||
default: "Filter...",
|
||||
},
|
||||
// Show view options (column visibility toggle)
|
||||
showViewOptions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Show per-page selector
|
||||
showPerPageSelector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Current per page value
|
||||
perPage: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
// Per page options
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => [10, 15, 25, 50, 100],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:search', 'update:page-size', 'export']);
|
||||
const emit = defineEmits(["update:perPage"]);
|
||||
|
||||
const internalSearch = ref(props.search);
|
||||
const menuOpen = ref(false);
|
||||
// Popover state
|
||||
const settingsPopoverOpen = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.search,
|
||||
(newVal) => {
|
||||
internalSearch.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return !!internalSearch.value || props.selectedCount > 0;
|
||||
// Check if any filters are active
|
||||
const isFiltered = computed(() => {
|
||||
if (!props.filterColumn) return false;
|
||||
const column = props.table.getColumn(props.filterColumn);
|
||||
return column && column.getFilterValue();
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
internalSearch.value = '';
|
||||
emit('update:search', '');
|
||||
}
|
||||
// Get/set filter value
|
||||
const filterValue = computed({
|
||||
get() {
|
||||
if (!props.filterColumn) return "";
|
||||
const column = props.table.getColumn(props.filterColumn);
|
||||
return column?.getFilterValue() ?? "";
|
||||
},
|
||||
set(value) {
|
||||
if (!props.filterColumn) return;
|
||||
const column = props.table.getColumn(props.filterColumn);
|
||||
column?.setFilterValue(value);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSearchInput() {
|
||||
emit('update:search', internalSearch.value);
|
||||
}
|
||||
|
||||
function handlePageSizeChange(value) {
|
||||
emit('update:page-size', Number(value));
|
||||
}
|
||||
|
||||
function handleExport(format) {
|
||||
emit('export', format);
|
||||
menuOpen.value = false;
|
||||
// Reset all filters
|
||||
function resetFilters() {
|
||||
props.table.resetColumnFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<!-- Left side: Search and Add buttons dropdown -->
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<!-- Search (always visible if showSearch is true) -->
|
||||
<div v-if="showSearch && !compact" class="flex-1 max-w-sm">
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 z-10">
|
||||
<FontAwesomeIcon :icon="faSearch" class="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
v-model="internalSearch"
|
||||
@input="handleSearchInput"
|
||||
type="text"
|
||||
placeholder="Iskanje..."
|
||||
class="pl-10"
|
||||
:class="internalSearch ? 'pr-10' : ''"
|
||||
/>
|
||||
<Button
|
||||
v-if="internalSearch"
|
||||
@click="clearSearch"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute inset-y-0 right-0 h-full w-auto px-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTimes" class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Left side: Search and Filters -->
|
||||
<div class="flex flex-1 items-center space-x-2">
|
||||
<!-- Filter Input -->
|
||||
<Input
|
||||
v-if="filterColumn"
|
||||
v-model="filterValue"
|
||||
:placeholder="filterPlaceholder"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
|
||||
<!-- Add buttons dropdown (after search input) -->
|
||||
<Dropdown v-if="$slots.add && showAdd && !compact" align="left">
|
||||
<template #trigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
|
||||
<span class="sr-only">Dodaj</span>
|
||||
</Button>
|
||||
</template>
|
||||
<template #content>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
</Dropdown>
|
||||
<!-- Custom filter slots -->
|
||||
<slot name="filters" :table="table" />
|
||||
|
||||
<!-- Custom options dropdown (after search input and add buttons) -->
|
||||
<div v-if="$slots.options && showOptions && !compact" class="flex items-center">
|
||||
<slot name="options" />
|
||||
</div>
|
||||
<!-- Reset filters button -->
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
@click="resetFilters"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filters button (after options, before right side) -->
|
||||
<Popover v-if="showFilters && $slots.filters && !compact">
|
||||
<!-- Right side: Actions and View Options -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Custom action slots -->
|
||||
<slot name="actions" :table="table" />
|
||||
|
||||
<!-- Settings Popover (Per-page selector + View Options) -->
|
||||
<Popover v-model:open="settingsPopoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="relative">
|
||||
<FontAwesomeIcon :icon="faFilter" class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasActiveFilters"
|
||||
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
|
||||
></span>
|
||||
<span class="sr-only">Filtri</span>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Settings2 class="h-4 w-4" />
|
||||
Pogled
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-4" align="start">
|
||||
<slot name="filters" />
|
||||
<PopoverContent class="w-[300px]" align="end">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Nastavitve pogleda</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Per page selector -->
|
||||
<div
|
||||
v-if="showPerPageSelector"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<label class="text-sm whitespace-nowrap">Elementov na stran</label>
|
||||
<Select
|
||||
:model-value="String(perPage)"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
emit('update:perPage', Number(value));
|
||||
settingsPopoverOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem
|
||||
v-for="size in pageSizeOptions"
|
||||
:key="size"
|
||||
:value="String(size)"
|
||||
>
|
||||
{{ size }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Column visibility -->
|
||||
<div v-if="showViewOptions" class="flex items-center justify-between gap-4">
|
||||
<label class="text-sm whitespace-nowrap">Vidnost stolpcev</label>
|
||||
<DataTableViewOptions
|
||||
:table="table"
|
||||
@column-toggle="settingsPopoverOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Selected count, Page size, Menu & Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Selected count badge -->
|
||||
<div
|
||||
v-if="selectedCount > 0 && showSelectedCount"
|
||||
class="inline-flex items-center rounded-md bg-primary-50 px-3 py-1.5 text-sm font-medium text-primary-700"
|
||||
>
|
||||
{{ selectedCount }} izbran{{ selectedCount === 1 ? 'o' : 'ih' }}
|
||||
</div>
|
||||
|
||||
<!-- Page size selector (visible when not in compact mode) -->
|
||||
<div v-if="showPageSize && !compact" class="flex items-center gap-2">
|
||||
<Label for="page-size" class="text-sm text-gray-600 whitespace-nowrap">Na stran:</Label>
|
||||
<Select
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<SelectTrigger id="page-size" class="w-[100px]">
|
||||
<SelectValue :placeholder="String(pageSize)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in pageSizeOptions"
|
||||
:key="opt"
|
||||
:value="String(opt)"
|
||||
>
|
||||
{{ opt }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Table Options Menu (compact mode or always as dropdown) -->
|
||||
<DropdownMenu v-if="showOptionsMenu" v-model:open="menuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:class="hasActiveFilters && !compact ? 'relative' : ''"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasActiveFilters && !compact"
|
||||
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
|
||||
></span>
|
||||
<span class="sr-only">Možnosti tabele</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<!-- Search in menu (only in compact mode) -->
|
||||
<div v-if="compact && showSearch" class="p-2 border-b">
|
||||
<Label for="menu-search" class="text-xs font-medium mb-1.5 block">Iskanje</Label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 z-10">
|
||||
<FontAwesomeIcon :icon="faSearch" class="h-3.5 w-3.5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
id="menu-search"
|
||||
v-model="internalSearch"
|
||||
@input="handleSearchInput"
|
||||
type="text"
|
||||
placeholder="Iskanje..."
|
||||
class="pl-8 h-8 text-sm"
|
||||
:class="internalSearch ? 'pr-8' : ''"
|
||||
/>
|
||||
<Button
|
||||
v-if="internalSearch"
|
||||
@click="clearSearch"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute inset-y-0 right-0 h-full w-auto px-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTimes" class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page size in menu (only in compact mode) -->
|
||||
<div v-if="compact && showPageSize" class="p-2 border-b">
|
||||
<Label for="menu-page-size" class="text-xs font-medium mb-1.5 block">Elementov na stran</Label>
|
||||
<Select
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<SelectTrigger id="menu-page-size" class="w-full h-8 text-sm">
|
||||
<SelectValue :placeholder="String(pageSize)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in pageSizeOptions"
|
||||
:key="opt"
|
||||
:value="String(opt)"
|
||||
>
|
||||
{{ opt }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Export options -->
|
||||
<template v-if="showExport">
|
||||
<DropdownMenuLabel>Izvozi</DropdownMenuLabel>
|
||||
<DropdownMenuItem @select="handleExport('csv')">
|
||||
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
|
||||
CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @select="handleExport('xlsx')">
|
||||
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
|
||||
Excel
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<!-- Custom actions slot in menu -->
|
||||
<template v-if="$slots.actions">
|
||||
<DropdownMenuSeparator />
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<template v-else>
|
||||
<!-- If options menu is hidden but we have content to show, render it inline -->
|
||||
<div v-if="showExport && !compact" class="flex items-center gap-2">
|
||||
<Dropdown v-if="showExport" align="right">
|
||||
<template #trigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4" />
|
||||
Izvozi
|
||||
</Button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleExport('csv')"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleExport('xlsx')"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom actions slot (visible when not in compact mode) -->
|
||||
<div v-if="$slots.actions && !compact" class="flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
|
||||
// Example: Using DataTableToolbar standalone
|
||||
const search = ref('');
|
||||
const pageSize = ref(10);
|
||||
const selectedCount = ref(0);
|
||||
|
||||
const handleSearchChange = (value) => {
|
||||
search.value = value;
|
||||
console.log('Search changed:', value);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (value) => {
|
||||
pageSize.value = value;
|
||||
console.log('Page size changed:', value);
|
||||
};
|
||||
|
||||
const handleExport = (format) => {
|
||||
console.log('Export:', format);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
console.log('Add button clicked');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Standalone DataTableToolbar -->
|
||||
<DataTableToolbar
|
||||
:search="search"
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:page-size="pageSize"
|
||||
:selected-count="selectedCount"
|
||||
:show-selected-count="true"
|
||||
:show-export="true"
|
||||
:show-add="true"
|
||||
:show-filters="true"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<!-- Add button dropdown content -->
|
||||
<template #add>
|
||||
<button
|
||||
@click="handleAdd"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Dodaj novo
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Custom options -->
|
||||
<template #options>
|
||||
<button class="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded">
|
||||
Opcija 1
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<template #filters>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Filtriraj po:</label>
|
||||
<input type="text" class="w-full px-2 py-1 border rounded" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom actions -->
|
||||
<template #actions>
|
||||
<button class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded">
|
||||
Akcija
|
||||
</button>
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Your content here -->
|
||||
<div class="p-4 bg-gray-50 rounded">
|
||||
<p>Search: {{ search }}</p>
|
||||
<p>Page Size: {{ pageSize }}</p>
|
||||
<p>Selected: {{ selectedCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { Settings } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const columns = computed(() =>
|
||||
props.table
|
||||
.getAllColumns()
|
||||
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="ml-auto hidden h-8 lg:flex">
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Pogled
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-[150px]">
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="column in columns"
|
||||
:key="column.id"
|
||||
class="capitalize"
|
||||
:model-value="column.getIsVisible()"
|
||||
@update:model-value="(value) => column.toggleVisibility(!!value)"
|
||||
>
|
||||
{{ column.id }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,291 @@
|
||||
# DataTable Migration Guide
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
The DataTable component has been updated to follow **shadcn-vue** architecture patterns using **TanStack Table v8**. This provides better flexibility, more features, and follows industry-standard patterns.
|
||||
|
||||
## What's New
|
||||
|
||||
### ✅ Components Created/Updated
|
||||
|
||||
1. **`DataTableNew2.vue`** - New main component with shadcn-vue architecture
|
||||
2. **`DataTableColumnHeader.vue`** - Already good, uses lucide-vue-next icons
|
||||
3. **`DataTablePagination.vue`** - Already follows shadcn-vue patterns
|
||||
4. **`DataTableViewOptions.vue`** - Already follows shadcn-vue patterns
|
||||
5. **`DataTableToolbar.vue`** - Already exists with advanced features
|
||||
6. **`columns-example.js`** - Column definition examples
|
||||
7. **`README.md`** - Comprehensive documentation
|
||||
8. **`DataTableExample.vue`** - Working example page
|
||||
|
||||
### ✅ Utilities Added
|
||||
|
||||
- **`valueUpdater()`** in `lib/utils.js` - Helper for TanStack Table state management
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 1. **FlexRender Integration**
|
||||
Now properly uses TanStack Table's FlexRender for column headers and cells:
|
||||
```vue
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. **Better Column Definitions**
|
||||
Supports both simple and advanced formats:
|
||||
|
||||
**Simple:**
|
||||
```javascript
|
||||
{ key: 'name', label: 'Name', sortable: true }
|
||||
```
|
||||
|
||||
**Advanced:**
|
||||
```javascript
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
|
||||
cell: ({ row }) => h('div', {}, row.getValue('name')),
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Enhanced Features**
|
||||
- ✅ Row selection with checkboxes
|
||||
- ✅ Column visibility toggle
|
||||
- ✅ Advanced filtering
|
||||
- ✅ Better loading/empty states
|
||||
- ✅ Custom cell slots
|
||||
- ✅ Flexible toolbar
|
||||
|
||||
### 4. **Better State Management**
|
||||
Uses `valueUpdater()` helper for proper Vue reactivity with TanStack Table:
|
||||
```javascript
|
||||
onSortingChange: (updater) => valueUpdater(updater, sorting)
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Imports
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
import DataTable from '@/Components/DataTable/DataTable.vue';
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
```
|
||||
|
||||
### Step 2: Update Props
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
<DataTable
|
||||
:rows="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
/>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
<DataTable
|
||||
:data="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
route-name="clients.index"
|
||||
/>
|
||||
```
|
||||
|
||||
Main prop changes:
|
||||
- `rows` → `data`
|
||||
- Add `route-name` for server-side pagination
|
||||
|
||||
### Step 3: Column Definitions
|
||||
|
||||
Your existing simple column format still works:
|
||||
```javascript
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
];
|
||||
```
|
||||
|
||||
But you can now use advanced format for more control:
|
||||
```javascript
|
||||
import { h } from 'vue';
|
||||
import DataTableColumnHeader from '@/Components/DataTable/DataTableColumnHeader.vue';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
|
||||
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('name')),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Step 4: Custom Cell Rendering
|
||||
|
||||
**Before:** Required editing component
|
||||
**After:** Use slots!
|
||||
|
||||
```vue
|
||||
<DataTable :columns="columns" :data="data">
|
||||
<template #cell-status="{ value, row }">
|
||||
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The **old DataTable components are still available**:
|
||||
- `DataTable.vue` - Your current enhanced version
|
||||
- `DataTableServer.vue` - Your server-side version
|
||||
- `DataTableOld.vue` - Original version
|
||||
|
||||
You can migrate pages gradually. Both old and new can coexist.
|
||||
|
||||
## Example Migration
|
||||
|
||||
### Before (Client/Index.vue)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTable.vue';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:rows="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
:search="filters.search"
|
||||
:sort="filters.sort"
|
||||
route-name="clients.index"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### After (Using DataTableNew2)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:data="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
:search="filters.search"
|
||||
:sort="filters.sort"
|
||||
route-name="clients.index"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search clients..."
|
||||
:only-props="['clients']"
|
||||
>
|
||||
<!-- Add custom cell rendering -->
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Testing Your Migration
|
||||
|
||||
1. **Check the example page:**
|
||||
```
|
||||
Visit: /examples/datatable
|
||||
```
|
||||
(You'll need to add a route for this)
|
||||
|
||||
2. **Test features:**
|
||||
- ✅ Sorting (click column headers)
|
||||
- ✅ Filtering (use search input)
|
||||
- ✅ Pagination (navigate pages)
|
||||
- ✅ Row selection (if enabled)
|
||||
- ✅ Column visibility (View button)
|
||||
|
||||
3. **Check browser console:**
|
||||
- No errors
|
||||
- Events firing correctly
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: "FlexRender is not defined"
|
||||
**Solution:** Make sure you imported it:
|
||||
```javascript
|
||||
import { FlexRender } from '@tanstack/vue-table';
|
||||
```
|
||||
|
||||
### Issue: Column not sorting
|
||||
**Solution:** Make sure `sortable: true` is set:
|
||||
```javascript
|
||||
{ key: 'name', label: 'Name', sortable: true }
|
||||
```
|
||||
|
||||
### Issue: Server-side not working
|
||||
**Solution:** Provide both `meta` and `route-name`:
|
||||
```vue
|
||||
<DataTable
|
||||
:data="data"
|
||||
:meta="meta"
|
||||
route-name="your.route.name"
|
||||
/>
|
||||
```
|
||||
|
||||
### Issue: Custom cells not rendering
|
||||
**Solution:** Use the correct slot name format:
|
||||
```vue
|
||||
<template #cell-columnKey="{ value, row }">
|
||||
<!-- Your content -->
|
||||
</template>
|
||||
```
|
||||
|
||||
## Need Help?
|
||||
|
||||
1. Check `README.md` for detailed documentation
|
||||
2. Look at `columns-example.js` for column patterns
|
||||
3. Review `DataTableExample.vue` for working examples
|
||||
4. Check TanStack Table docs: https://tanstack.com/table/v8
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you encounter issues, you can always use the old components:
|
||||
```vue
|
||||
import DataTable from '@/Components/DataTable/DataTable.vue';
|
||||
// or
|
||||
import DataTableServer from '@/Components/DataTable/DataTableServer.vue';
|
||||
```
|
||||
|
||||
Nothing breaks your existing code!
|
||||
@@ -0,0 +1,390 @@
|
||||
# DataTable Component - Usage Guide
|
||||
|
||||
This DataTable component follows the shadcn-vue architecture and uses TanStack Table v8 for powerful table functionality.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Client-side and server-side pagination
|
||||
- ✅ Sorting (single column)
|
||||
- ✅ Filtering/Search
|
||||
- ✅ Row selection
|
||||
- ✅ Column visibility toggle
|
||||
- ✅ Customizable column definitions
|
||||
- ✅ Loading states
|
||||
- ✅ Empty states
|
||||
- ✅ Flexible toolbar
|
||||
- ✅ Cell-level customization via slots
|
||||
- ✅ Responsive design
|
||||
- ✅ Laravel Inertia integration
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Format (Recommended for basic tables)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
const data = ref([
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Inactive' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Advanced Format (Full TanStack Table power)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { h } from 'vue';
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { columns } from './columns'; // Import from separate file
|
||||
|
||||
const data = ref([...]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data" />
|
||||
</template>
|
||||
```
|
||||
|
||||
See `columns-example.js` for comprehensive column definition examples.
|
||||
|
||||
## Props
|
||||
|
||||
### Data Props
|
||||
- `columns` (Array, required) - Column definitions (simple or TanStack format)
|
||||
- `data` (Array, default: []) - Array of data objects
|
||||
- `meta` (Object, default: null) - Laravel pagination meta for server-side
|
||||
- `loading` (Boolean, default: false) - Loading state
|
||||
|
||||
### Server-side Props
|
||||
- `routeName` (String) - Laravel route name for server-side requests
|
||||
- `routeParams` (Object) - Additional route parameters
|
||||
- `pageParamName` (String, default: 'page') - Custom page parameter name
|
||||
- `onlyProps` (Array) - Inertia.js only props
|
||||
- `preserveState` (Boolean, default: true)
|
||||
- `preserveScroll` (Boolean, default: true)
|
||||
|
||||
### Sorting & Filtering
|
||||
- `sort` (Object, default: {key: null, direction: null})
|
||||
- `search` (String, default: '')
|
||||
- `filterColumn` (String) - Column to filter on
|
||||
- `filterPlaceholder` (String, default: 'Filter...')
|
||||
|
||||
### Pagination
|
||||
- `showPagination` (Boolean, default: true)
|
||||
- `pageSize` (Number, default: 10)
|
||||
- `pageSizeOptions` (Array, default: [10, 25, 50, 100])
|
||||
|
||||
### Features
|
||||
- `enableRowSelection` (Boolean, default: false)
|
||||
- `showToolbar` (Boolean, default: true)
|
||||
- `striped` (Boolean, default: false)
|
||||
- `hoverable` (Boolean, default: true)
|
||||
- `rowKey` (String|Function, default: 'id')
|
||||
|
||||
### Empty State
|
||||
- `emptyText` (String, default: 'No results.')
|
||||
- `emptyIcon` (String|Object|Array)
|
||||
- `emptyDescription` (String)
|
||||
|
||||
## Events
|
||||
|
||||
- `@update:search` - Emitted when search changes
|
||||
- `@update:sort` - Emitted when sort changes
|
||||
- `@update:page` - Emitted when page changes
|
||||
- `@update:pageSize` - Emitted when page size changes
|
||||
- `@row:click` - Emitted when row is clicked
|
||||
- `@selection:change` - Emitted when selection changes
|
||||
|
||||
## Client-side Example
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
|
||||
const data = ref([
|
||||
// Your data here
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:page-size="10"
|
||||
filter-column="email"
|
||||
filter-placeholder="Filter emails..."
|
||||
enable-row-selection
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Server-side Example (Laravel Inertia)
|
||||
|
||||
### Controller
|
||||
```php
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Client::query();
|
||||
|
||||
// Search
|
||||
if ($request->search) {
|
||||
$query->where('name', 'like', "%{$request->search}%")
|
||||
->orWhere('email', 'like', "%{$request->search}%");
|
||||
}
|
||||
|
||||
// Sort
|
||||
if ($request->sort && $request->direction) {
|
||||
$query->orderBy($request->sort, $request->direction);
|
||||
}
|
||||
|
||||
$clients = $query->paginate($request->per_page ?? 10);
|
||||
|
||||
return Inertia::render('Clients/Index', [
|
||||
'clients' => $clients,
|
||||
'filters' => $request->only(['search', 'sort', 'direction']),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Vue Component
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="clients.data"
|
||||
:meta="clients.meta"
|
||||
:search="filters.search"
|
||||
:sort="{ key: filters.sort, direction: filters.direction }"
|
||||
route-name="clients.index"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search clients..."
|
||||
:only-props="['clients']"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Custom Cell Rendering
|
||||
|
||||
### Using Slots
|
||||
```vue
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data">
|
||||
<!-- Custom cell for status column -->
|
||||
<template #cell-status="{ value, row }">
|
||||
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<!-- Custom cell for actions -->
|
||||
<template #cell-actions="{ row }">
|
||||
<Button @click="editRow(row)">Edit</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using Column Definitions
|
||||
```javascript
|
||||
import { h } from 'vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status');
|
||||
return h(Badge, {
|
||||
variant: status === 'active' ? 'default' : 'secondary'
|
||||
}, () => status);
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Custom Toolbar
|
||||
|
||||
The new toolbar is simplified and follows shadcn-vue patterns:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search emails..."
|
||||
>
|
||||
<!-- Add custom filter controls -->
|
||||
<template #toolbar-filters="{ table }">
|
||||
<select
|
||||
@change="table.getColumn('status')?.setFilterValue($event.target.value)"
|
||||
class="h-8 rounded-md border px-3"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<!-- Add custom action buttons -->
|
||||
<template #toolbar-actions="{ table }">
|
||||
<Button @click="exportData">Export</Button>
|
||||
<Button @click="addNew">Add New</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
Or completely replace the toolbar:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data" :show-toolbar="false">
|
||||
<template #toolbar="{ table }">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Input
|
||||
:model-value="table.getColumn('email')?.getFilterValue()"
|
||||
@update:model-value="table.getColumn('email')?.setFilterValue($event)"
|
||||
placeholder="Filter emails..."
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button @click="exportData">Export</Button>
|
||||
<DataTableViewOptions :table="table" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Row Selection
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const selectedRows = ref([]);
|
||||
|
||||
function handleSelectionChange(keys) {
|
||||
selectedRows.value = keys;
|
||||
console.log('Selected rows:', keys);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
enable-row-selection
|
||||
@selection:change="handleSelectionChange"
|
||||
/>
|
||||
|
||||
<div v-if="selectedRows.length">
|
||||
Selected {{ selectedRows.length }} row(s)
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Row Click Handler
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
function handleRowClick(row, index) {
|
||||
console.log('Clicked row:', row);
|
||||
// Navigate or perform action
|
||||
router.visit(route('clients.show', row.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@row:click="handleRowClick"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Column Keys**: Always use consistent keys/accessorKeys across your data
|
||||
2. **Server-side**: Always provide `meta` and `routeName` props together
|
||||
3. **Performance**: For large datasets, use server-side pagination
|
||||
4. **Styling**: Use column `class` property for custom styling
|
||||
5. **Slots**: Prefer slots for complex cell rendering over h() functions
|
||||
|
||||
## Migration from Old DataTable
|
||||
|
||||
### Before (Old API)
|
||||
```vue
|
||||
<DataTable
|
||||
:rows="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
/>
|
||||
```
|
||||
|
||||
### After (New API)
|
||||
```vue
|
||||
<DataTableNew2
|
||||
:data="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
route-name="clients.index"
|
||||
/>
|
||||
```
|
||||
|
||||
Main changes:
|
||||
- `rows` → `data`
|
||||
- Added `route-name` prop for server-side
|
||||
- More consistent prop naming
|
||||
- Better TypeScript support
|
||||
- More flexible column definitions
|
||||
|
||||
## Component Files
|
||||
|
||||
- `DataTableNew2.vue` - Main table component
|
||||
- `DataTableColumnHeader.vue` - Sortable column header
|
||||
- `DataTablePagination.vue` - Pagination controls
|
||||
- `DataTableViewOptions.vue` - Column visibility toggle
|
||||
- `DataTableToolbar.vue` - Toolbar component
|
||||
- `columns-example.js` - Column definition examples
|
||||
@@ -1,31 +1,36 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Dropdown from '../Dropdown.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
|
||||
import Button from "../ui/button/Button.vue";
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'right', // left, right
|
||||
validator: (v) => ['left', 'right'].includes(v),
|
||||
default: "right", // left, right
|
||||
validator: (v) => ["left", "right"].includes(v),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md', // sm, md
|
||||
validator: (v) => ['sm', 'md'].includes(v),
|
||||
default: "md", // sm, md
|
||||
validator: (v) => ["sm", "md"].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
sm: "h-6 w-6",
|
||||
md: "h-8 w-8",
|
||||
};
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(["action"]);
|
||||
|
||||
function handleAction(action) {
|
||||
emit('action', action);
|
||||
emit("action", action);
|
||||
if (action.onClick) {
|
||||
action.onClick();
|
||||
}
|
||||
@@ -33,23 +38,14 @@ function handleAction(action) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown :align="align" :content-classes="['py-1']">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'inline-flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 transition-colors',
|
||||
sizeClasses[size],
|
||||
]"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" aria-label="Actions">
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent :align="align === 'right' ? 'end' : 'start'" class="py-1">
|
||||
<slot :handle-action="handleAction" />
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { h } from 'vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, ArrowUpDown } from 'lucide-vue-next';
|
||||
|
||||
/**
|
||||
* Example columns definition following shadcn-vue DataTable patterns
|
||||
*
|
||||
* Usage:
|
||||
* import { columns } from './columns'
|
||||
* <DataTable :columns="columns" :data="data" />
|
||||
*
|
||||
* This is a TypeScript-like example for JavaScript.
|
||||
* The columns follow TanStack Table's ColumnDef format.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple format - automatically converted to ColumnDef
|
||||
* Use this for basic tables
|
||||
*/
|
||||
export const simpleColumns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Advanced format - full TanStack Table ColumnDef
|
||||
* Use this for custom rendering, formatting, etc.
|
||||
*/
|
||||
export const advancedColumns = [
|
||||
// Selection column (added automatically if enableRowSelection prop is true)
|
||||
// {
|
||||
// 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,
|
||||
// },
|
||||
|
||||
// ID column
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['ID', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'w-20 font-medium' }, row.getValue('id'));
|
||||
},
|
||||
},
|
||||
|
||||
// Name column
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'font-medium' }, row.getValue('name'));
|
||||
},
|
||||
},
|
||||
|
||||
// Email column with custom rendering
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'lowercase' }, row.getValue('email'));
|
||||
},
|
||||
},
|
||||
|
||||
// Amount column with formatting
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'));
|
||||
const formatted = new Intl.NumberFormat('sl-SI', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, formatted);
|
||||
},
|
||||
},
|
||||
|
||||
// Status column with badge
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status');
|
||||
const variants = {
|
||||
success: 'default',
|
||||
pending: 'secondary',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
return h(
|
||||
Badge,
|
||||
{
|
||||
variant: variants[status] || 'outline',
|
||||
},
|
||||
() => status
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// Actions column
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-right' },
|
||||
h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuTrigger,
|
||||
{ asChild: true },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
class: 'h-8 w-8 p-0',
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h('span', { class: 'sr-only' }, 'Open menu'),
|
||||
h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
],
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{ align: 'end' },
|
||||
{
|
||||
default: () => [
|
||||
h(DropdownMenuLabel, {}, () => 'Actions'),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: () => navigator.clipboard.writeText(item.id),
|
||||
},
|
||||
() => 'Copy ID'
|
||||
),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, {}, () => 'View details'),
|
||||
h(DropdownMenuItem, {}, () => 'Edit'),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Payments example from shadcn-vue docs
|
||||
*/
|
||||
export const paymentColumns = [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status');
|
||||
return h('div', { class: 'capitalize' }, status);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => h('div', { class: 'lowercase' }, row.getValue('email')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'));
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
return h('div', { class: 'text-right font-medium' }, formatted);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Example with custom cell slots
|
||||
* Use template slots in your component:
|
||||
*
|
||||
* <DataTable :columns="columnsWithSlots" :data="data">
|
||||
* <template #cell-status="{ value }">
|
||||
* <Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
* {{ value }}
|
||||
* </Badge>
|
||||
* </template>
|
||||
* </DataTable>
|
||||
*/
|
||||
export const columnsWithSlots = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false }, // Will use #cell-status slot
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
|
||||
export default advancedColumns;
|
||||
Reference in New Issue
Block a user