Files
2025-09-08 10:45:29 +03:30

639 lines
16 KiB
Vue

<script setup>
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
import DataTableHeader from './components/DataTableHeader.vue'
import UserDetailsDialog from './components/UserDetailsDialog.vue'
import ConfirmDeleteDialog from './components/ConfirmDeleteDialog.vue'
import { useDataTableGrid } from './composables/useDataTableGrid'
import { useDataTableActions } from './composables/useDataTableActions'
import { useResponsive } from './composables/useResponsive'
import jsPDF from 'jspdf'
import autoTable from 'jspdf-autotable'
ModuleRegistry.registerModules([AllCommunityModule])
const props = defineProps({
apiUrl: {
type: String,
default: ''
},
apiHeaders: {
type: Object,
default: () => ({})
},
data: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
height: {
type: String,
default: '600px'
},
mobileHeight: {
type: String,
default: '400px'
},
enableExport: {
type: Boolean,
default: true
},
enableEdit: {
type: Boolean,
default: true
},
enableDelete: {
type: Boolean,
default: true
},
enableSearch: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
customActions: {
type: Array,
default: () => []
},
dataProcessor: {
type: Function,
default: null
},
schemaMap: {
type: Object,
default: () => ({})
},
keepOriginalKeys: {
type: Boolean,
default: false
},
maxCellTextLength: {
type: Number,
default: 30
},
tableId: {
type: String,
default: 'default'
}
})
const emit = defineEmits([
'data-updated',
'row-selected',
'row-deleted',
'row-edited',
'export-completed',
'api-error'
])
const { isMobile, isTablet, windowWidth } = useResponsive()
const gridContainer = ref(null)
const fetchedData = ref([])
const isLoading = ref(false)
const normalizeRecord = (record, index) => {
if (!props.schemaMap || Object.keys(props.schemaMap).length === 0) {
return record
}
const normalized = {}
for (const [canonicalKey, candidates] of Object.entries(props.schemaMap)) {
const keys = Array.isArray(candidates) ? candidates : [candidates]
let found = undefined
for (const k of keys) {
if (record[k] !== undefined) {
found = record[k]
break
}
}
if (canonicalKey === 'id' && (found === undefined || found === null)) {
normalized[canonicalKey] = record.id ?? index + 1
} else {
normalized[canonicalKey] = found
}
}
return props.keepOriginalKeys ? { ...record, ...normalized } : normalized
}
const processedData = computed(() => {
const sourceData = props.data?.length ? props.data : fetchedData.value
if (!Array.isArray(sourceData)) return []
return sourceData.map((item, index) => {
const base = normalizeRecord(item, index)
return {
...base,
id: base?.id ?? item?.id ?? index + 1,
}
})
})
// Custom cell renderer component for long text with tooltip
const LongTextCellRenderer = {
template: `
<div
:title="fullText"
class="long-text-cell"
style="
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: help;
"
>
{{ displayText }}
</div>
`,
setup(props) {
const fullText = props.value || ''
const maxLength = props.colDef?.cellRendererParams?.maxLength || 30
const displayText = fullText.length > maxLength
? fullText.substring(0, maxLength) + '...'
: fullText
return {
fullText,
displayText
}
}
}
// Dynamically infer columns from data when not provided
const inferredColumns = computed(() => {
if (Array.isArray(props.columns) && props.columns.length) return props.columns
const rows = Array.isArray(processedData.value) ? processedData.value.slice(0, 50) : []
if (!rows.length) return []
const keySet = new Set()
rows.forEach(r => {
if (r && typeof r === 'object') {
Object.keys(r).forEach(k => {
if (k !== 'action' && r[k] !== undefined && typeof r[k] !== 'object') keySet.add(k)
})
}
})
const keys = Array.from(keySet).filter(k => k !== 'action')
const toTitle = (k) => k
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, s => s.toUpperCase())
const isLikelyDate = (v) => {
if (typeof v !== 'string') return false
if (/^\d{4}-\d{2}-\d{2}/.test(v) || /T\d{2}:\d{2}:\d{2}/.test(v)) return true
const d = new Date(v)
return !isNaN(d.getTime())
}
const isLongText = (samples) => {
return samples.some(val =>
typeof val === 'string' && val.length > props.maxCellTextLength
)
}
const columns = keys.map(k => {
const sampleValues = []
for (let i = 0; i < Math.min(10, rows.length); i++) {
const val = rows[i]?.[k]
if (val !== undefined && val !== null) {
sampleValues.push(val)
}
}
const sampleVal = sampleValues[0]
const col = {
field: k,
headerName: toTitle(k),
sortable: true,
filter: true,
editable: false,
minWidth: 100,
flex: 1,
suppressMovable: true
}
if (typeof sampleVal === 'number') {
col.filter = true
col.width = 120
col.valueFormatter = p => (typeof p.value === 'number' ? p.value : p.value ?? '')
} else if (typeof sampleVal === 'boolean') {
col.width = 120
col.valueFormatter = p => (p.value ? 'Yes' : 'No')
} else if (isLikelyDate(sampleVal)) {
col.width = 140
col.valueFormatter = p => {
if (!p.value) return ''
const d = new Date(p.value)
return isNaN(d.getTime()) ? String(p.value) : d.toISOString().split('T')[0]
}
} else if (typeof sampleVal === 'string' && isLongText(sampleValues)) {
col.flex = 1
col.cellRenderer = LongTextCellRenderer
col.cellRendererParams = {
maxLength: props.maxCellTextLength
}
col.tooltipField = k
} else {
col.flex = 1
}
return col
})
columns.push({
headerName: "ACTION",
field: "action",
sortable: false,
filter: false,
flex: 1,
minWidth: 100,
hide: false,
editable: false,
cellRenderer: actionButtonsRenderer,
suppressHeaderMenuButton: true,
suppressMovable: true,
lockPosition: true,
colId: "action"
})
return columns
})
let gridSetup = useDataTableGrid(processedData, isMobile, isTablet, inferredColumns, props.tableId)
console.log('AgGridTable props.tableId:', props.tableId);
const gridApi = gridSetup.gridApi
const columnDefs = gridSetup.columnDefs
const rowData = gridSetup.rowData
const defaultColDef = gridSetup.defaultColDef
const gridOptions = gridSetup.gridOptions
const gridReady = gridSetup.onGridReady
const onCellValueChanged = gridSetup.onCellValueChanged
const updateColumnVisibility = gridSetup.updateColumnVisibility
const updatePagination = gridSetup.updatePagination
watch(
processedData,
(newData) => {
if (Array.isArray(newData) && gridApi.value) {
try {
if (typeof gridApi.value.setRowData === 'function') {
gridApi.value.setRowData(newData)
} else if (typeof gridApi.value.setGridOption === 'function') {
gridApi.value.setGridOption("rowData", newData)
}
} catch (error) {
console.warn('Error updating grid data:', error)
}
}
},
{ deep: true, immediate: true }
)
const fetchData = async () => {
if (!props.apiUrl) return
isLoading.value = true
try {
const response = await fetch(props.apiUrl, {
headers: {
'Content-Type': 'application/json',
...props.apiHeaders
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
let processedApiData = []
if (props.dataProcessor && typeof props.dataProcessor === 'function') {
processedApiData = props.dataProcessor(result)
} else {
processedApiData = result.todos || result.data || result.users || result.products || (Array.isArray(result) ? result : [])
}
fetchedData.value = Array.isArray(processedApiData) ? processedApiData : []
console.log('Fetched data set to:', fetchedData.value)
emit('data-updated', fetchedData.value)
} catch (error) {
console.error('Error fetching data:', error)
emit('api-error', error)
} finally {
isLoading.value = false
}
}
const saveToAPI = async (userData, isUpdate = false) => {
if (!props.apiUrl) return
try {
const method = isUpdate ? 'PUT' : 'POST'
const url = isUpdate ? `${props.apiUrl}/${userData.id}` : props.apiUrl
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...props.apiHeaders
},
body: JSON.stringify(userData)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
await fetchData()
return await response.json()
} catch (error) {
console.error('Error saving data:', error)
emit('api-error', error)
throw error
}
}
const deleteFromAPI = async (id) => {
if (!props.apiUrl) return
try {
const response = await fetch(`${props.apiUrl}/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...props.apiHeaders
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
await fetchData()
return true
} catch (error) {
console.error('Error deleting data:', error)
emit('api-error', error)
throw error
}
}
const {
setupGlobalHandlers,
cleanupGlobalHandlers,
exportToCSV,
exportToExcel,
saveUser,
confirmDelete,
cancelDelete,
showDetailsDialog,
selectedRowData,
deleteRowData,
showDeleteDialog,
deleteRow
} = useDataTableActions(gridApi, rowData, props.tableId)
const handleSaveUser = async (updatedData) => {
try {
// Optimistic update: update grid first
saveUser(updatedData)
if (props.apiUrl) {
try {
await saveToAPI(updatedData, updatedData.id ? true : false)
} catch (apiErr) {
console.error('API save failed, refetching to sync:', apiErr)
// Re-sync from server on failure
await fetchData()
emit('api-error', apiErr)
return
}
}
emit('row-edited', updatedData)
} catch (error) {
console.error('Error saving user:', error)
}
}
const handleConfirmDelete = async () => {
try {
// Optimistic remove from grid first
confirmDelete()
if (props.apiUrl && deleteRowData.value?.id) {
try {
await deleteFromAPI(deleteRowData.value.id)
} catch (apiErr) {
console.error('API delete failed, refetching to restore:', apiErr)
await fetchData()
emit('api-error', apiErr)
return
}
}
emit('row-deleted', deleteRowData.value)
} catch (error) {
console.error('Error deleting row:', error)
}
}
const quickFilter = ref('')
watch(quickFilter, (newValue) => {
if (gridApi.value) {
gridApi.value.setGridOption('quickFilterText', newValue || '')
}
}, { immediate: false })
const onGridReady = params => {
gridReady(params)
setupGlobalHandlers()
setTimeout(() => {
const allPageSizeWrappers = document.querySelectorAll('.ag-paging-page-size .ag-picker-field-wrapper');
allPageSizeWrappers.forEach((pageSizeWrapper, index) => {
if (!pageSizeWrapper.hasAttribute('data-fixed')) {
pageSizeWrapper.setAttribute('data-fixed', 'true');
pageSizeWrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) {
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
const isExpanded = pageSizeWrapper.getAttribute('aria-expanded') === 'true';
pageSizeWrapper.setAttribute('aria-expanded', !isExpanded);
pageSizeWrapper.classList.toggle('ag-picker-collapsed', isExpanded);
pageSizeWrapper.classList.toggle('ag-picker-expanded', !isExpanded);
const selectList = pageSizeWrapper.parentNode.querySelector('[id^="ag-select-list"]');
if (selectList) {
selectList.style.display = isExpanded ? 'none' : 'block';
}
});
pageSizeWrapper.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
});
}, 1000);
if (props.apiUrl) {
fetchData()
}
try {
const container = gridContainer?.value
if (container && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => {
if (gridApi.value) {
updatePagination()
setTimeout(() => {
gridApi.value?.sizeColumnsToFit?.()
}, 50)
}
})
ro.observe(container)
}
} catch (e) { }
}
const handleQuickFilterUpdate = (value) => {
quickFilter.value = value || ''
if (gridApi.value) {
gridApi.value.setGridOption('quickFilterText', value || '')
}
}
const refreshData = () => {
if (props.apiUrl) {
fetchData()
}
}
defineExpose({
refreshData,
fetchData,
gridApi
})
onMounted(() => {
setupGlobalHandlers()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
cleanupGlobalHandlers()
window.removeEventListener('resize', handleResize)
})
const handleResize = () => {
windowWidth.value = window.innerWidth
if (gridApi.value) {
updateColumnVisibility()
updatePagination()
setTimeout(() => {
if (gridApi.value) {
gridApi.value.sizeColumnsToFit();
}
}, 100)
}
}
const exportToPDF = () => {
if (!gridApi.value) {
console.error('Grid API not available')
return
}
try {
const doc = new jsPDF()
const rowDataForPDF = []
const headers = []
columnDefs.value.forEach(col => {
if (col.headerName && col.field && col.field !== 'action') {
headers.push(col.headerName)
}
})
gridApi.value.forEachNodeAfterFilterAndSort(node => {
const row = []
columnDefs.value.forEach(col => {
if (col.field && col.field !== 'action') {
let value = node.data[col.field] || ''
row.push(value)
}
})
rowDataForPDF.push(row)
})
autoTable(doc, {
head: [headers],
body: rowDataForPDF,
styles: {
fontSize: 8,
cellPadding: 2
},
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: 'bold'
},
alternateRowStyles: {
fillColor: [245, 245, 245]
},
margin: { top: 20 }
})
doc.save(`data-table-${new Date().toISOString().split('T')[0]}.pdf`)
emit('export-completed', 'pdf')
} catch (error) {
console.error('Error exporting PDF:', error)
}
}
</script>
<template>
<v-card class="elevation-2">
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
</v-overlay>
<DataTableHeader v-if="enableSearch || enableExport" :is-mobile="isMobile" :is-tablet="isTablet"
:quick-filter="quickFilter" :enable-search="enableSearch" :enable-export="enableExport"
@update:quick-filter="handleQuickFilterUpdate" @export-csv="exportToCSV" @export-excel="exportToExcel"
@export-pdf="exportToPDF" />
<v-divider v-if="enableSearch || enableExport" />
<v-card-text class="pa-0">
<AgGridVue ref="gridContainer" class="vuetify-grid ag-theme-alpine-dark"
:style="`height:${isMobile ? mobileHeight : height}; width:100%`" :columnDefs="columnDefs"
:rowData="processedData" :defaultColDef="defaultColDef" :gridOptions="gridOptions" @grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged" />
</v-card-text>
<UserDetailsDialog v-if="enableEdit" v-model="showDetailsDialog" :selected-row-data="selectedRowData"
:columns="columnDefs" :is-mobile="isMobile" @save-user="handleSaveUser" />
<ConfirmDeleteDialog v-if="enableDelete" v-model="showDeleteDialog" :selected-row-data="deleteRowData"
:columns="columnDefs" :is-mobile="isMobile" @confirm="handleConfirmDelete" @cancel="cancelDelete" />
</v-card>
</template>