generated from corrad-software/corrad-af-2024
625 lines
18 KiB
Vue
625 lines
18 KiB
Vue
<script setup>
|
|
import { computed, ref, watch, onMounted } from 'vue';
|
|
import { useDesignSystem } from '~/composables/useDesignSystem';
|
|
import { useTouchInteractions } from '~/composables/useTouchInteractions';
|
|
import LoadingStates from './LoadingStates.vue';
|
|
import RsButton from '~/components/RsButton.vue';
|
|
|
|
const props = defineProps({
|
|
// Data
|
|
data: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
|
|
// Column definitions
|
|
columns: {
|
|
type: Array,
|
|
required: true
|
|
},
|
|
|
|
// Loading state
|
|
loading: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
|
|
// Pagination
|
|
pagination: {
|
|
type: Object,
|
|
default: () => ({
|
|
page: 1,
|
|
pageSize: 10,
|
|
total: 0,
|
|
showSizeChanger: true,
|
|
pageSizes: [10, 25, 50, 100]
|
|
})
|
|
},
|
|
|
|
// Selection
|
|
selectable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
|
|
// Sorting
|
|
sortable: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
|
|
// Filtering
|
|
filterable: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
|
|
// Responsive behavior
|
|
responsive: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
|
|
// Row actions
|
|
actions: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
|
|
// Empty state
|
|
emptyText: {
|
|
type: String,
|
|
default: 'No data available'
|
|
},
|
|
|
|
// Table size
|
|
size: {
|
|
type: String,
|
|
default: 'md',
|
|
validator: (value) => ['sm', 'md', 'lg'].includes(value)
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
'sort-change', 'filter-change', 'page-change', 'selection-change',
|
|
'row-click', 'row-double-click', 'action-click'
|
|
]);
|
|
|
|
// Design system
|
|
const { tokens, utils, layouts } = useDesignSystem();
|
|
|
|
// Table state
|
|
const selectedRows = ref(new Set());
|
|
const sortConfig = ref({ key: null, direction: 'asc' });
|
|
const filters = ref({});
|
|
const currentPage = ref(props.pagination.page);
|
|
const pageSize = ref(props.pagination.pageSize);
|
|
const expandedRows = ref(new Set());
|
|
|
|
// Mobile state
|
|
const isMobile = ref(false);
|
|
const showMobileFilters = ref(false);
|
|
|
|
// Column management
|
|
const visibleColumns = ref([]);
|
|
const columnWidths = ref({});
|
|
|
|
// Filter processed data
|
|
const processedData = computed(() => {
|
|
let result = [...props.data];
|
|
|
|
// Apply filters
|
|
Object.entries(filters.value).forEach(([key, value]) => {
|
|
if (value && value.trim()) {
|
|
result = result.filter(row => {
|
|
const cellValue = row[key];
|
|
if (cellValue === null || cellValue === undefined) return false;
|
|
return cellValue.toString().toLowerCase().includes(value.toLowerCase());
|
|
});
|
|
}
|
|
});
|
|
|
|
// Apply sorting
|
|
if (sortConfig.value.key) {
|
|
result.sort((a, b) => {
|
|
const aVal = a[sortConfig.value.key];
|
|
const bVal = b[sortConfig.value.key];
|
|
|
|
if (aVal === null || aVal === undefined) return 1;
|
|
if (bVal === null || bVal === undefined) return -1;
|
|
|
|
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
|
return sortConfig.value.direction === 'desc' ? -comparison : comparison;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
// Paginated data
|
|
const paginatedData = computed(() => {
|
|
const start = (currentPage.value - 1) * pageSize.value;
|
|
const end = start + pageSize.value;
|
|
return processedData.value.slice(start, end);
|
|
});
|
|
|
|
// Table classes
|
|
const tableClasses = computed(() => {
|
|
const sizeMap = {
|
|
sm: 'text-sm',
|
|
md: 'text-sm',
|
|
lg: 'text-base'
|
|
};
|
|
|
|
return utils.composeClasses(
|
|
'w-full border-collapse',
|
|
sizeMap[props.size],
|
|
props.responsive ? 'table-responsive' : ''
|
|
);
|
|
});
|
|
|
|
// Handle sorting
|
|
const handleSort = (column) => {
|
|
if (!column.sortable && !props.sortable) return;
|
|
|
|
if (sortConfig.value.key === column.key) {
|
|
sortConfig.value.direction = sortConfig.value.direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortConfig.value = { key: column.key, direction: 'asc' };
|
|
}
|
|
|
|
emit('sort-change', sortConfig.value);
|
|
};
|
|
|
|
// Handle filtering
|
|
const handleFilter = (column, value) => {
|
|
filters.value[column.key] = value;
|
|
currentPage.value = 1; // Reset to first page
|
|
emit('filter-change', filters.value);
|
|
};
|
|
|
|
// Handle selection
|
|
const handleRowSelection = (row, selected) => {
|
|
if (selected) {
|
|
selectedRows.value.add(row.id);
|
|
} else {
|
|
selectedRows.value.delete(row.id);
|
|
}
|
|
|
|
emit('selection-change', Array.from(selectedRows.value));
|
|
};
|
|
|
|
const handleSelectAll = (selected) => {
|
|
if (selected) {
|
|
processedData.value.forEach(row => selectedRows.value.add(row.id));
|
|
} else {
|
|
selectedRows.value.clear();
|
|
}
|
|
|
|
emit('selection-change', Array.from(selectedRows.value));
|
|
};
|
|
|
|
// Handle pagination
|
|
const handlePageChange = (page) => {
|
|
currentPage.value = page;
|
|
emit('page-change', { page, pageSize: pageSize.value });
|
|
};
|
|
|
|
const handlePageSizeChange = (size) => {
|
|
pageSize.value = size;
|
|
currentPage.value = 1;
|
|
emit('page-change', { page: 1, pageSize: size });
|
|
};
|
|
|
|
// Mobile responsive
|
|
const checkMobile = () => {
|
|
isMobile.value = window.innerWidth < 768;
|
|
};
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
visibleColumns.value = props.columns.filter(col => !col.hidden);
|
|
checkMobile();
|
|
window.addEventListener('resize', checkMobile);
|
|
});
|
|
|
|
// Touch interactions for mobile
|
|
const { setupTouchInteractions } = useTouchInteractions();
|
|
|
|
// Utility functions
|
|
const formatFileSize = (bytes) => {
|
|
if (!bytes || bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="advanced-data-table h-full flex flex-col">
|
|
<!-- Mobile Filters Toggle -->
|
|
<div v-if="isMobile && filterable" class="mobile-filter-toggle mb-4 flex-shrink-0">
|
|
<RsButton
|
|
variant="secondary-outline"
|
|
size="sm"
|
|
@click="showMobileFilters = !showMobileFilters"
|
|
class="w-full"
|
|
>
|
|
<Icon name="mdi:filter" class="w-4 h-4 mr-2" />
|
|
{{ showMobileFilters ? 'Hide' : 'Show' }} Filters
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Filters Row (Desktop) / Modal (Mobile) -->
|
|
<div
|
|
v-if="filterable && (!isMobile || showMobileFilters)"
|
|
:class="[
|
|
'filters-container mb-4 flex-shrink-0',
|
|
isMobile ? 'mobile-filters' : 'desktop-filters'
|
|
]"
|
|
>
|
|
<div class="grid gap-4" :class="isMobile ? 'grid-cols-1' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'">
|
|
<div
|
|
v-for="column in visibleColumns.filter(col => col.filterable !== false)"
|
|
:key="`filter-${column.key}`"
|
|
class="filter-item"
|
|
>
|
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{{ column.title }}
|
|
</label>
|
|
<input
|
|
v-model="filters[column.key]"
|
|
@input="handleFilter(column, $event.target.value)"
|
|
type="text"
|
|
:placeholder="`Filter ${column.title.toLowerCase()}...`"
|
|
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table Container - Scrollable -->
|
|
<div class="table-container flex-1 overflow-auto min-h-0 border border-gray-200 dark:border-gray-600 rounded-lg">
|
|
<!-- Loading State -->
|
|
<LoadingStates
|
|
v-if="loading"
|
|
type="skeleton-table"
|
|
:count="pageSize"
|
|
class="p-4"
|
|
/>
|
|
|
|
<!-- Table -->
|
|
<table v-else :class="tableClasses" class="min-w-full">
|
|
<!-- Header -->
|
|
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
|
|
<tr>
|
|
<!-- Selection Column -->
|
|
<th v-if="selectable" class="table-cell-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
:checked="selectedRows.size === processedData.length && processedData.length > 0"
|
|
:indeterminate="selectedRows.size > 0 && selectedRows.size < processedData.length"
|
|
@change="handleSelectAll($event.target.checked)"
|
|
class="rounded border-gray-300 dark:border-gray-600"
|
|
/>
|
|
</th>
|
|
|
|
<!-- Data Columns -->
|
|
<th
|
|
v-for="column in visibleColumns"
|
|
:key="`header-${column.key}`"
|
|
:class="[
|
|
'table-header-cell',
|
|
column.sortable !== false && sortable ? 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700' : '',
|
|
column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'
|
|
]"
|
|
:style="{ width: column.width }"
|
|
@click="handleSort(column)"
|
|
>
|
|
<div class="flex items-center space-x-2">
|
|
<span>{{ column.title }}</span>
|
|
<div v-if="column.sortable !== false && sortable" class="sort-indicators">
|
|
<Icon
|
|
name="mdi:chevron-up"
|
|
:class="[
|
|
'w-4 h-4 transition-colors',
|
|
sortConfig.key === column.key && sortConfig.direction === 'asc'
|
|
? 'text-primary'
|
|
: 'text-gray-400'
|
|
]"
|
|
/>
|
|
<Icon
|
|
name="mdi:chevron-down"
|
|
:class="[
|
|
'w-4 h-4 transition-colors -mt-1',
|
|
sortConfig.key === column.key && sortConfig.direction === 'desc'
|
|
? 'text-primary'
|
|
: 'text-gray-400'
|
|
]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
|
|
<!-- Actions Column -->
|
|
<th v-if="actions.length > 0" class="table-header-cell text-center">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<!-- Body -->
|
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
|
|
<!-- Empty State -->
|
|
<tr v-if="paginatedData.length === 0">
|
|
<td :colspan="visibleColumns.length + (selectable ? 1 : 0) + (actions.length > 0 ? 1 : 0)"
|
|
class="table-cell-empty">
|
|
<div class="flex flex-col items-center justify-center py-12">
|
|
<Icon name="mdi:database-off" class="w-12 h-12 text-gray-400 mb-4" />
|
|
<p class="text-gray-500 dark:text-gray-400">{{ emptyText }}</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Data Rows -->
|
|
<tr
|
|
v-for="(row, index) in paginatedData"
|
|
:key="`row-${row.id || index}`"
|
|
:class="[
|
|
'table-row hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer',
|
|
selectedRows.has(row.id) ? 'bg-primary/5 dark:bg-primary/10' : ''
|
|
]"
|
|
@click="emit('row-click', row)"
|
|
@dblclick="emit('row-double-click', row)"
|
|
>
|
|
<!-- Selection Cell -->
|
|
<td v-if="selectable" class="table-cell-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
:checked="selectedRows.has(row.id)"
|
|
@change="handleRowSelection(row, $event.target.checked)"
|
|
@click.stop
|
|
class="rounded border-gray-300 dark:border-gray-600"
|
|
/>
|
|
</td>
|
|
|
|
<!-- Data Cells -->
|
|
<td
|
|
v-for="column in visibleColumns"
|
|
:key="`cell-${row.id || index}-${column.key}`"
|
|
:class="[
|
|
'table-data-cell',
|
|
column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'
|
|
]"
|
|
>
|
|
<!-- Custom Slot -->
|
|
<slot
|
|
v-if="column.slot"
|
|
:name="`column-${column.key}`"
|
|
:row="row"
|
|
:value="row[column.key]"
|
|
:column="column"
|
|
>
|
|
{{ row[column.key] }}
|
|
</slot>
|
|
|
|
<!-- Default Cell Content -->
|
|
<template v-else>
|
|
<!-- Status Badge -->
|
|
<span
|
|
v-if="column.type === 'status'"
|
|
:class="[
|
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
|
row[column.key] === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' :
|
|
row[column.key] === 'inactive' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' :
|
|
'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
|
]"
|
|
>
|
|
{{ row[column.key] }}
|
|
</span>
|
|
|
|
<!-- Date -->
|
|
<span v-else-if="column.type === 'date'" class="text-gray-900 dark:text-gray-100">
|
|
{{ row[column.key] ? new Date(row[column.key]).toLocaleDateString() : '-' }}
|
|
</span>
|
|
|
|
<!-- File Size -->
|
|
<span v-else-if="column.type === 'filesize'" class="text-gray-900 dark:text-gray-100">
|
|
{{ row[column.key] ? formatFileSize(row[column.key]) : '-' }}
|
|
</span>
|
|
|
|
<!-- Default Text -->
|
|
<span v-else class="text-gray-900 dark:text-gray-100">
|
|
{{ row[column.key] || '-' }}
|
|
</span>
|
|
</template>
|
|
</td>
|
|
|
|
<!-- Actions Cell -->
|
|
<td v-if="actions.length > 0" class="table-cell-actions">
|
|
<div class="flex items-center justify-center space-x-2">
|
|
<RsButton
|
|
v-for="action in actions"
|
|
:key="action.key"
|
|
:variant="action.variant || 'secondary-outline'"
|
|
size="sm"
|
|
@click.stop="emit('action-click', action.key, row)"
|
|
:title="action.title"
|
|
>
|
|
<Icon :name="action.icon" class="w-4 h-4" />
|
|
</RsButton>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div
|
|
v-if="pagination && processedData.length > 0"
|
|
class="pagination-container mt-4 flex-shrink-0 flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0"
|
|
>
|
|
<!-- Page Size Selector -->
|
|
<div v-if="pagination.showSizeChanger" class="flex items-center space-x-2">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Show:</span>
|
|
<select
|
|
v-model="pageSize"
|
|
@change="handlePageSizeChange($event.target.value)"
|
|
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
|
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option v-for="size in pagination.pageSizes" :key="size" :value="size">
|
|
{{ size }}
|
|
</option>
|
|
</select>
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">entries</span>
|
|
</div>
|
|
|
|
<!-- Page Info -->
|
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
|
Showing {{ ((currentPage - 1) * pageSize) + 1 }} to
|
|
{{ Math.min(currentPage * pageSize, processedData.length) }} of
|
|
{{ processedData.length }} entries
|
|
</div>
|
|
|
|
<!-- Page Navigation -->
|
|
<div class="flex items-center space-x-2">
|
|
<RsButton
|
|
variant="secondary-outline"
|
|
size="sm"
|
|
:disabled="currentPage === 1"
|
|
@click="handlePageChange(currentPage - 1)"
|
|
>
|
|
<Icon name="mdi:chevron-left" class="w-4 h-4" />
|
|
</RsButton>
|
|
|
|
<div class="flex items-center space-x-1">
|
|
<RsButton
|
|
v-for="page in Math.min(Math.ceil(processedData.length / pageSize), 7)"
|
|
:key="page"
|
|
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
|
|
size="sm"
|
|
@click="handlePageChange(page)"
|
|
class="min-w-[2rem]"
|
|
>
|
|
{{ page }}
|
|
</RsButton>
|
|
</div>
|
|
|
|
<RsButton
|
|
variant="secondary-outline"
|
|
size="sm"
|
|
:disabled="currentPage >= Math.ceil(processedData.length / pageSize)"
|
|
@click="handlePageChange(currentPage + 1)"
|
|
>
|
|
<Icon name="mdi:chevron-right" class="w-4 h-4" />
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.advanced-data-table {
|
|
@apply h-full min-h-0;
|
|
}
|
|
|
|
.table-header-cell {
|
|
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider;
|
|
}
|
|
|
|
.table-data-cell {
|
|
@apply px-6 py-4 whitespace-nowrap;
|
|
}
|
|
|
|
.table-cell-checkbox {
|
|
@apply px-6 py-4 text-center;
|
|
}
|
|
|
|
.table-cell-actions {
|
|
@apply px-6 py-4 text-center;
|
|
}
|
|
|
|
.table-cell-empty {
|
|
@apply px-6 py-4 text-center;
|
|
}
|
|
|
|
.sort-indicators {
|
|
@apply flex flex-col;
|
|
}
|
|
|
|
.mobile-filters {
|
|
@apply p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600;
|
|
}
|
|
|
|
.desktop-filters {
|
|
@apply p-4 bg-gray-50 dark:bg-gray-800 rounded-lg;
|
|
}
|
|
|
|
/* Responsive table */
|
|
@media (max-width: 768px) {
|
|
.table-responsive {
|
|
@apply text-xs;
|
|
}
|
|
|
|
.table-header-cell,
|
|
.table-data-cell {
|
|
@apply px-3 py-2;
|
|
}
|
|
}
|
|
|
|
/* Enhanced scrollbar for table container */
|
|
.table-container::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
|
|
.table-container::-webkit-scrollbar-track {
|
|
background: #f1f5f9;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.table-container::-webkit-scrollbar-thumb {
|
|
background: #cbd5e1;
|
|
border-radius: 4px;
|
|
border: 2px solid #f1f5f9;
|
|
}
|
|
|
|
.table-container::-webkit-scrollbar-thumb:hover {
|
|
background: #94a3b8;
|
|
}
|
|
|
|
.table-container::-webkit-scrollbar-corner {
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
/* Dark mode scrollbar */
|
|
.dark .table-container::-webkit-scrollbar-track {
|
|
background: #374151;
|
|
}
|
|
|
|
.dark .table-container::-webkit-scrollbar-thumb {
|
|
background: #6b7280;
|
|
border-color: #374151;
|
|
}
|
|
|
|
.dark .table-container::-webkit-scrollbar-thumb:hover {
|
|
background: #9ca3af;
|
|
}
|
|
|
|
.dark .table-container::-webkit-scrollbar-corner {
|
|
background: #374151;
|
|
}
|
|
|
|
/* Sticky header enhancement */
|
|
thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: inherit;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
</style> |