Changes to UI and other stuff
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user