Dev branch
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user