feat: reusable ag grid

This commit is contained in:
2025-09-03 17:28:04 +03:30
parent dafb07375a
commit c382d97d8d
7 changed files with 1311 additions and 560 deletions

View File

@@ -1,12 +1,27 @@
<script setup>
import { onMounted, nextTick } from 'vue'
import { onMounted, nextTick, ref } from 'vue'
import { GridStack } from 'gridstack'
import ProjectActivityBarChart from "@/components/ProjectActivityBarChart.vue";
import AnalysisCard from "@/components/AnalysisCard.vue";
import CostOverview from "@/components/CostOverview.vue";
import GeneratedLeadsCard from "@/views/dashboards/ecommerce/EcommerceGeneratedLeads.vue";
import GanttChart from './gantt.vue'
import AgGridTable from '@/views/demos/forms/tables/data-table/AgGridTable.vue';
const ganttColumns = ref([
{ field: 'id', headerName: 'ID', sortable: true, filter: true, width: 80 },
{ field: 'todo', headerName: 'Task', sortable: true, filter: true, flex: 1 },
{ field: 'userId', headerName: 'User ID', sortable: true, filter: true, width: 100 },
{
field: 'completed', headerName: 'Status', sortable: true, filter: true, width: 120, cellRenderer: (params) => {
return params.value ? '<span style="color: green; font-weight: bold;">✓ Completed</span>' : '<span style="color: orange; font-weight: bold;">⏳ Pending</span>'
}
}
])
const processAPIData = (response) => {
return response.todos || response
}
onMounted(async () => {
const grid = GridStack.init({
@@ -49,9 +64,12 @@ onMounted(async () => {
<AnalysisCard />
</div>
</div>
<div class="grid-stack-item" gs-w="12" gs-h="8" gs-max-h="8">
<div class="grid-stack-item" gs-w="12" gs-h="4" gs-max-h="8">
<div class="grid-stack-item-content">
<GanttChart />
<AgGridTable :columns="ganttColumns" api-url="https://dummyjson.com/todos"
:data-processor="processAPIData" :enable-export="true" :enable-edit="true" :enable-delete="true"
:enable-search="true" height="500px" />
</div>
</div>
</div>
@@ -69,11 +87,11 @@ onMounted(async () => {
margin-bottom: 3.2rem;
}
.grid-stack-item:has(GanttChart) {
.grid-stack-item:has(AgGridTable) {
height: 100vh !important;
}
.grid-stack-item:has(GanttChart) .grid-stack-item-content {
.grid-stack-item:has(AgGridTable) .grid-stack-item-content {
height: 100vh;
}
</style>

View File

@@ -1,9 +1,7 @@
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import data from '@/views/demos/forms/tables/data-table/datatable'
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
import { ExcelExportModule } from 'ag-grid-enterprise'
import DataTableHeader from './components/DataTableHeader.vue'
import UserDetailsDialog from './components/UserDetailsDialog.vue'
import ConfirmDeleteDialog from './components/ConfirmDeleteDialog.vue'
@@ -13,199 +11,561 @@ import { useResponsive } from './composables/useResponsive'
import jsPDF from 'jspdf'
import autoTable from 'jspdf-autotable'
ModuleRegistry.registerModules([AllCommunityModule, ExcelExportModule])
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
}
})
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,
}
})
})
// 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 []
// Collect union of keys across sample rows
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)
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
// ISO or yyyy-mm-dd or timestamps in string
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())
}
return keys.map(k => {
// Detect type by scanning a few rows
let sampleVal = undefined
for (let i = 0; i < rows.length; i++) {
const val = rows[i]?.[k]
if (val !== undefined && val !== null) { sampleVal = val; break }
}
const col = {
field: k,
headerName: toTitle(k),
sortable: true,
filter: true,
editable: false,
minWidth: 100,
flex: 1,
}
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 {
col.flex = 1
}
return col
})
})
// Build grid with inferred columns (or props.columns if provided)
// Note: Passing inferredColumns.value at setup time; if you need live re-init on schema change, we can add a watcher.
let gridSetup = useDataTableGrid(processedData, isMobile, isTablet, inferredColumns)
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 {
gridApi,
columnDefs,
rowData,
defaultColDef,
gridOptions,
onGridReady: gridReady,
onCellValueChanged,
updateColumnVisibility,
updatePagination
} = useDataTableGrid(data, isMobile, isTablet)
const {
setupGlobalHandlers,
cleanupGlobalHandlers,
exportToCSV,
exportToExcel,
saveUser,
confirmDelete,
cancelDelete,
showDetailsDialog,
selectedRowData,
deleteRowData,
showDeleteDialog,
deleteRow
setupGlobalHandlers,
cleanupGlobalHandlers,
exportToCSV,
exportToExcel,
saveUser,
confirmDelete,
cancelDelete,
showDetailsDialog,
selectedRowData,
deleteRowData,
showDeleteDialog,
deleteRow
} = useDataTableActions(gridApi, rowData)
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 || '')
}
if (gridApi.value) {
gridApi.value.setGridOption('quickFilterText', newValue || '')
}
}, { immediate: false })
const onGridReady = params => {
gridReady(params)
setupGlobalHandlers()
}
gridReady(params)
setupGlobalHandlers()
if (props.apiUrl) {
fetchData()
}
const handleSaveUser = updatedData => saveUser(updatedData)
// Observe container size to recalc pagination
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) {
// no-op
}
}
const handleQuickFilterUpdate = (value) => {
quickFilter.value = value || ''
if (gridApi.value) {
gridApi.value.setGridOption('quickFilterText', 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)
setupGlobalHandlers()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
cleanupGlobalHandlers()
window.removeEventListener('resize', handleResize)
cleanupGlobalHandlers()
window.removeEventListener('resize', handleResize)
})
const handleResize = () => {
windowWidth.value = window.innerWidth
if (gridApi.value) {
updateColumnVisibility()
updatePagination()
setTimeout(() => {
if (gridApi.value && gridApi.value.sizeColumnsToFit) {
gridApi.value.sizeColumnsToFit()
}
}, 100)
}
windowWidth.value = window.innerWidth
if (gridApi.value) {
updateColumnVisibility()
updatePagination()
setTimeout(() => {
if (gridApi.value && gridApi.value.sizeColumnsToFit) {
gridApi.value.sizeColumnsToFit()
}
}, 100)
}
}
const exportToPDF = () => {
if (!gridApi.value) {
console.error('Grid API not available')
return
}
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] || ''
if (col.field === 'salary' && value) {
value = `$${Math.floor(Number(value)).toLocaleString()}`
} else if (col.field === 'startDate' && value) {
const date = new Date(value)
if (!isNaN(date.getTime())) {
const day = date.getDate().toString().padStart(2, '0')
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const year = date.getFullYear()
value = `${day}/${month}/${year}`
}
} else if (col.field === 'status') {
const statusMap = {
0: 'Applied',
1: 'Current',
2: 'Professional',
3: 'Rejected',
4: 'Resigned'
}
value = statusMap[value] || 'Applied'
}
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`)
console.log('PDF exported successfully')
} catch (error) {
console.error('Error exporting PDF:', error)
}
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">
<DataTableHeader
:is-mobile="isMobile"
:is-tablet="isTablet"
:quick-filter="quickFilter"
@update:quick-filter="handleQuickFilterUpdate"
@export-csv="exportToCSV"
@export-excel="exportToExcel"
@export-pdf="exportToPDF"
<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
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>
<v-divider />
<UserDetailsDialog
v-if="enableEdit"
v-model="showDetailsDialog"
:selected-row-data="selectedRowData"
:columns="columnDefs"
:is-mobile="isMobile"
@save-user="handleSaveUser"
/>
<v-card-text class="pa-0">
<AgGridVue
class="vuetify-grid ag-theme-alpine-dark"
:style="`height:${isMobile ? '400px' : '600px'}; width:100%`"
:columnDefs="columnDefs"
:rowData="rowData"
:defaultColDef="defaultColDef"
:gridOptions="gridOptions"
@grid-ready="onGridReady"
/>
</v-card-text>
<UserDetailsDialog
v-model="showDetailsDialog"
:selected-row-data="selectedRowData"
:is-mobile="isMobile"
@save-user="handleSaveUser"
/>
<ConfirmDeleteDialog
v-model="showDeleteDialog"
:selected-row-data="deleteRowData"
:is-mobile="isMobile"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</v-card>
<ConfirmDeleteDialog
v-if="enableDelete"
v-model="showDeleteDialog"
:selected-row-data="deleteRowData"
:columns="columnDefs"
:is-mobile="isMobile"
@confirm="handleConfirmDelete"
@cancel="cancelDelete"
/>
</v-card>
</template>

View File

@@ -15,7 +15,7 @@
<v-card-text class="py-6">
<div class="text-body-1 mb-4">
Are you sure you want to delete this user?
Are you sure you want to delete this record?
</div>
<v-card
@@ -24,15 +24,14 @@
class="pa-3 bg-grey-lighten-5"
>
<div class="d-flex flex-column gap-2">
<div class="d-flex align-center">
<VIcon icon="tabler-user" size="small" class="me-2" />
<strong class="me-2">Name:</strong>
{{ selectedRowData.fullName || 'Unknown' }}
</div>
<div class="d-flex align-center">
<VIcon icon="tabler-mail" size="small" class="me-2" />
<strong class="me-2">Email:</strong>
{{ selectedRowData.email || 'Unknown' }}
<div
v-for="(val, key) in previewFields"
:key="key"
class="d-flex align-center"
>
<VIcon icon="tabler-dot" size="small" class="me-2" />
<strong class="me-2">{{ key }}:</strong>
{{ formatValue(val) }}
</div>
</div>
</v-card>
@@ -85,7 +84,7 @@
</template>
<script setup>
defineProps({
const props = defineProps({
modelValue: {
type: Boolean,
default: false
@@ -97,6 +96,10 @@ defineProps({
isMobile: {
type: Boolean,
default: false
},
columns: {
type: [Array, Object],
default: () => []
}
})
@@ -118,6 +121,26 @@ const handleConfirm = () => {
emit('update:model-value', false)
emit('confirm')
}
const previewFields = computed(() => {
const cols = Array.isArray(props.columns) ? props.columns : (props.columns?.value || [])
const prefer = ['title','name','fullName','todo','email','id','userId']
const keys = []
for (const p of prefer) {
if (props.selectedRowData && props.selectedRowData[p] !== undefined) keys.push(p)
}
for (const c of cols) {
if (c.field && !keys.includes(c.field) && c.field !== 'action' && keys.length < 4) keys.push(c.field)
}
const map = {}
keys.forEach(k => { map[k] = props.selectedRowData?.[k] })
return map
})
const formatValue = (v) => {
if (Array.isArray(v) || typeof v === 'object') return JSON.stringify(v)
return v ?? 'N/A'
}
</script>
<style scoped>

View File

@@ -2,7 +2,11 @@
const props = defineProps({
modelValue: Boolean,
selectedRowData: Object,
isMobile: Boolean
isMobile: Boolean,
columns: {
type: [Array, Object],
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'save-user'])
@@ -111,6 +115,11 @@ const getFieldIcon = (field) => {
}
return iconMap[field] || 'tabler-info-circle'
}
const visibleColumns = computed(() => {
const list = Array.isArray(props.columns) ? props.columns : (props.columns?.value || [])
return list.filter(c => c.field && c.field !== 'action')
})
</script>
<template>
@@ -179,7 +188,11 @@ const getFieldIcon = (field) => {
<v-form @keyup.enter="handleEnterKey">
<v-container fluid class="pa-0">
<v-row>
<v-col cols="12" sm="6" class="mb-2">
<v-col
v-for="col in visibleColumns"
:key="col.field"
cols="12" sm="6" class="mb-2"
>
<v-card variant="outlined" class="pa-4 h-100" color="surface-variant">
<div class="d-flex align-center">
<v-avatar
@@ -192,212 +205,35 @@ const getFieldIcon = (field) => {
border: '1px solid rgba(var(--v-theme-primary), 0.3)'
}"
>
<v-icon :icon="getFieldIcon('name')" color="primary" />
<v-icon :icon="getFieldIcon(col.field)" color="primary" />
</v-avatar>
<div class="flex-grow-1">
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">Full Name</v-card-subtitle>
<v-card-title v-if="!isEditMode" class="pa-0 text-h6 text-high-emphasis">
{{ selectedRowData.fullName || 'N/A' }}
</v-card-title>
<v-text-field
v-else
v-model="editedData.fullName"
variant="outlined"
density="compact"
hide-details
class="mt-1"
/>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" class="mb-2">
<v-card variant="outlined" class="pa-4 h-100" color="surface-variant">
<div class="d-flex align-center">
<v-avatar
class="me-4"
size="40"
:style="{
background: 'rgba(var(--v-theme-secondary), 0.2)',
backdropFilter: 'blur(10px)',
webkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(var(--v-theme-secondary), 0.3)'
}"
>
<v-icon :icon="getFieldIcon('email')" color="secondary" />
</v-avatar>
<div class="flex-grow-1">
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">Email Address</v-card-subtitle>
<v-card-title v-if="!isEditMode" class="pa-0 text-body-1 text-high-emphasis">
{{ selectedRowData.email || 'N/A' }}
</v-card-title>
<v-text-field
v-else
v-model="editedData.email"
variant="outlined"
density="compact"
hide-details
type="email"
class="mt-1"
/>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" class="mb-2">
<v-card variant="outlined" class="pa-4 h-100" color="surface-variant">
<div class="d-flex align-center">
<v-avatar
class="me-4"
size="40"
:style="{
background: 'rgba(var(--v-theme-info), 0.2)',
backdropFilter: 'blur(10px)',
webkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(var(--v-theme-info), 0.3)'
}"
>
<v-icon :icon="getFieldIcon('startDate')" color="info" />
</v-avatar>
<div class="flex-grow-1">
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">Start Date</v-card-subtitle>
<v-card-title v-if="!isEditMode" class="pa-0 text-h6 text-high-emphasis">
{{ formatDate(selectedRowData.startDate) }}
</v-card-title>
<v-text-field
v-else
v-model="editedData.startDate"
variant="outlined"
density="compact"
hide-details
type="date"
class="mt-1"
/>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" class="mb-2">
<v-card variant="outlined" class="pa-4 h-100" color="surface-variant">
<div class="d-flex align-center">
<v-avatar
class="me-4"
size="40"
:style="{
background: 'rgba(var(--v-theme-success), 0.2)',
backdropFilter: 'blur(10px)',
webkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(var(--v-theme-success), 0.3)'
}"
>
<v-icon :icon="getFieldIcon('salary')" color="success" />
</v-avatar>
<div class="flex-grow-1">
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">Salary</v-card-subtitle>
<v-card-title v-if="!isEditMode" class="pa-0 text-h6 text-success font-weight-bold">
{{ formatSalary(selectedRowData.salary) }}
</v-card-title>
<v-text-field
v-else
v-model="editedData.salary"
variant="outlined"
density="compact"
hide-details
type="number"
prefix="$"
class="mt-1"
/>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" class="mb-2">
<v-card variant="outlined" class="pa-4 h-100" color="surface-variant">
<div class="d-flex align-center">
<v-avatar
class="me-4"
size="40"
:style="{
background: 'rgba(var(--v-theme-warning), 0.2)',
backdropFilter: 'blur(10px)',
webkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(var(--v-theme-warning), 0.3)'
}"
>
<v-icon :icon="getFieldIcon('age')" color="warning" />
</v-avatar>
<div class="flex-grow-1">
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">Age</v-card-subtitle>
<v-card-title v-if="!isEditMode" class="pa-0 text-h6 text-high-emphasis">
{{ selectedRowData.age ? selectedRowData.age + ' years' : 'N/A' }}
</v-card-title>
<v-text-field
v-else
v-model="editedData.age"
variant="outlined"
density="compact"
hide-details
type="number"
suffix="years"
class="mt-1"
/>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" class="mb-2">
<v-card variant="outlined" class="pa-4 h-100" color="surface-variant">
<div class="d-flex align-center">
<v-avatar
class="me-4"
size="40"
:style="{
background: `rgba(var(--v-theme-${getStatusColor(isEditMode ? editedData.status : selectedRowData.status)}), 0.2)`,
backdropFilter: 'blur(10px)',
webkitBackdropFilter: 'blur(10px)',
border: `1px solid rgba(var(--v-theme-${getStatusColor(isEditMode ? editedData.status : selectedRowData.status)}), 0.3)`
}"
>
<v-icon :icon="getFieldIcon('status')" :color="getStatusColor(isEditMode ? editedData.status : selectedRowData.status)" />
</v-avatar>
<div class="flex-grow-1">
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">Status</v-card-subtitle>
<div v-if="!isEditMode" class="pt-1">
<v-chip
:color="getStatusColor(selectedRowData.status)"
size="default"
variant="flat"
class="font-weight-medium"
>
<v-icon start icon="tabler-check" size="small" />
{{ getStatusLabel(selectedRowData.status) }}
</v-chip>
</div>
<v-select
v-else
v-model="editedData.status"
:items="statusOptions"
variant="outlined"
density="compact"
hide-details
class="mt-1"
>
<template #item="{ props, item }">
<v-list-item v-bind="props" :title="item.title">
<template #prepend>
<v-chip :color="item.raw.color" size="small" class="me-2">{{ item.title }}</v-chip>
</template>
</v-list-item>
</template>
<template #selection="{ item }">
<v-chip :color="item.raw.color" size="small">{{ item.title }}</v-chip>
</template>
</v-select>
<v-card-subtitle class="pa-0 text-caption text-medium-emphasis">{{ col.headerName || col.field }}</v-card-subtitle>
<template v-if="!isEditMode">
<v-card-title class="pa-0 text-body-1 text-high-emphasis">
{{ Array.isArray(selectedRowData[col.field]) || typeof selectedRowData[col.field] === 'object' ? JSON.stringify(selectedRowData[col.field]) : (selectedRowData[col.field] ?? 'N/A') }}
</v-card-title>
</template>
<template v-else>
<v-text-field
v-if="col.editable !== false && col.field !== 'status'"
v-model="editedData[col.field]"
variant="outlined"
density="compact"
hide-details
class="mt-1"
:type="col.field.toLowerCase().includes('date') ? 'date' : (typeof selectedRowData[col.field] === 'number' ? 'number' : 'text')"
/>
<v-select
v-else-if="col.field === 'status'"
v-model="editedData.status"
:items="statusOptions"
variant="outlined"
density="compact"
hide-details
class="mt-1"
/>
</template>
</div>
</div>
</v-card>

View File

@@ -5,15 +5,23 @@ export function useDataTableActions(gridApi, rowData) {
const showDeleteDialog = ref(false)
const selectedRowData = ref(null)
const deleteRowData = ref(null)
const selectedRowNodeId = ref(null)
const deleteRowNodeId = ref(null)
const getRowDataByIndex = (rowIndex) => {
const getRowDataByIndex = (rowIndex, nodeId) => {
if (!gridApi.value) {
console.error('Grid API not available')
return null
}
try {
const node = gridApi.value.getDisplayedRowAtIndex(rowIndex)
let node = null
if (nodeId != null && gridApi.value.getRowNode) {
node = gridApi.value.getRowNode(nodeId)
}
if (!node) {
node = gridApi.value.getDisplayedRowAtIndex(rowIndex)
}
if (node && node.data) {
return node.data
}
@@ -33,9 +41,10 @@ export function useDataTableActions(gridApi, rowData) {
const showDetails = (rowIndex, nodeId) => {
console.log('Show details called with:', { rowIndex, nodeId })
const rowData = getRowDataByIndex(rowIndex)
if (rowData) {
selectedRowData.value = { ...rowData }
const rowDataItem = getRowDataByIndex(rowIndex, nodeId)
if (rowDataItem) {
selectedRowData.value = { ...rowDataItem }
selectedRowNodeId.value = nodeId
showDetailsDialog.value = true
console.log('Selected row data:', selectedRowData.value)
} else {
@@ -52,11 +61,15 @@ export function useDataTableActions(gridApi, rowData) {
}
try {
gridApi.value.startEditingCell({
rowIndex: rowIndex,
colKey: 'fullName'
})
console.log('Started inline editing for row:', rowIndex)
const columnDefs = typeof gridApi.value.getColumnDefs === 'function' ? gridApi.value.getColumnDefs() : []
const firstEditableCol = Array.isArray(columnDefs) ? columnDefs.find(col => col && col.editable && col.field && col.field !== 'action') : null
if (firstEditableCol && typeof gridApi.value.startEditingCell === 'function') {
gridApi.value.ensureIndexVisible?.(rowIndex)
gridApi.value.setFocusedCell?.(rowIndex, firstEditableCol.field)
gridApi.value.startEditingCell({ rowIndex, colKey: firstEditableCol.field })
console.log('Started inline editing for row:', rowIndex)
}
} catch (error) {
console.error('Error starting inline edit:', error)
}
@@ -65,9 +78,10 @@ export function useDataTableActions(gridApi, rowData) {
const deleteRow = (rowIndex, nodeId) => {
console.log('Delete row called with:', { rowIndex, nodeId })
const rowData = getRowDataByIndex(rowIndex)
if (rowData) {
deleteRowData.value = { ...rowData }
const rowDataItem = getRowDataByIndex(rowIndex, nodeId)
if (rowDataItem) {
deleteRowData.value = { ...rowDataItem }
deleteRowNodeId.value = nodeId
showDeleteDialog.value = true
console.log('Delete row data:', deleteRowData.value)
} else {
@@ -78,26 +92,22 @@ export function useDataTableActions(gridApi, rowData) {
const confirmDelete = () => {
if (deleteRowData.value && gridApi.value) {
try {
const indexToDelete = rowData.value.findIndex(item => {
return (
(item.id && item.id === deleteRowData.value.id) ||
(item.email && item.email === deleteRowData.value.email) ||
(item.fullName === deleteRowData.value.fullName &&
item.salary === deleteRowData.value.salary)
)
})
// Prefer removing via grid transaction using the exact object
gridApi.value.applyTransaction({ remove: [deleteRowData.value] })
// Also sync our local rowData list. Try by id first, else fallback to object equality
let indexToDelete = -1
if (deleteRowData.value.id != null) {
indexToDelete = rowData.value.findIndex(item => item && item.id === deleteRowData.value.id)
}
if (indexToDelete === -1) {
indexToDelete = rowData.value.findIndex(item => item === deleteRowData.value)
}
if (indexToDelete !== -1) {
rowData.value.splice(indexToDelete, 1)
gridApi.value.applyTransaction({
remove: [deleteRowData.value]
})
console.log('Row deleted successfully')
} else {
console.error('Row not found for deletion')
}
console.log('Row deleted successfully')
} catch (error) {
console.error('Error deleting row:', error)
}
@@ -105,6 +115,7 @@ export function useDataTableActions(gridApi, rowData) {
showDeleteDialog.value = false
deleteRowData.value = null
deleteRowNodeId.value = null
}
const cancelDelete = () => {
@@ -116,25 +127,33 @@ export function useDataTableActions(gridApi, rowData) {
if (!editedData || !gridApi.value) return
try {
const indexToUpdate = rowData.value.findIndex(item => {
return (
(item.id && item.id === editedData.id) ||
(item.email && item.email === selectedRowData.value.email) ||
(item.fullName === selectedRowData.value.fullName)
)
})
if (indexToUpdate !== -1) {
rowData.value[indexToUpdate] = { ...editedData }
const node = gridApi.value.getRowNode(indexToUpdate)
// Update the grid row via nodeId if available
let updated = false
if (selectedRowNodeId.value != null && gridApi.value.getRowNode) {
const node = gridApi.value.getRowNode(selectedRowNodeId.value)
if (node) {
node.setData(editedData)
updated = true
}
console.log('User updated successfully:', editedData)
}
// Sync local rowData list
let indexToUpdate = -1
if (editedData.id != null) {
indexToUpdate = rowData.value.findIndex(item => item && item.id === editedData.id)
}
if (indexToUpdate === -1 && selectedRowData.value) {
indexToUpdate = rowData.value.findIndex(item => item === selectedRowData.value)
}
if (indexToUpdate !== -1) {
rowData.value[indexToUpdate] = { ...editedData }
updated = true
}
if (updated) {
console.log('Row updated successfully:', editedData)
} else {
console.error('User not found for update')
console.error('Row not found for update')
}
} catch (error) {
console.error('Error updating user:', error)
@@ -142,6 +161,7 @@ export function useDataTableActions(gridApi, rowData) {
showDetailsDialog.value = false
selectedRowData.value = null
selectedRowNodeId.value = null
}
const exportToCSV = () => {
@@ -163,7 +183,7 @@ export function useDataTableActions(gridApi, rowData) {
}
const exportToExcel = () => {
if (gridApi.value) {
if (gridApi.value && gridApi.value.exportDataAsExcel) {
gridApi.value.exportDataAsExcel({
fileName: `datatable-export-${new Date().toISOString().split('T')[0]}.xlsx`,
sheetName: 'Data Export',
@@ -176,6 +196,8 @@ export function useDataTableActions(gridApi, rowData) {
allColumns: false,
onlySelectedAllPages: false
})
} else {
console.warn('Excel export not available - requires AG Grid Enterprise')
}
}

View File

@@ -1,47 +1,52 @@
import { ref, computed } from "vue";
import { ref, computed, watch } from "vue";
export function useDataTableGrid(data, isMobile, isTablet) {
export function useDataTableGrid(
data,
isMobile,
isTablet,
customColumns = null
) {
let gridApi = ref(null);
const rowData = ref([]);
const statusCellRenderer = (params) => {
const s = params.value;
let label = "Applied";
let colorClass = "info";
if (s === 1) {
label = "Current";
colorClass = "primary";
}
if (s === 2) {
label = "Professional";
colorClass = "success";
}
if (s === 3) {
label = "Rejected";
colorClass = "error";
}
if (s === 4) {
label = "Resigned";
colorClass = "warning";
}
const statusCellRenderer = (params) => {
const s = params.value;
let label = "Applied";
let colorClass = "info";
const chipSize = isMobile.value ? "x-small" : "small";
const fontSize = isMobile.value ? "10px" : "12px";
if (s === 1) {
label = "Current";
colorClass = "primary";
}
if (s === 2) {
label = "Professional";
colorClass = "success";
}
if (s === 3) {
label = "Rejected";
colorClass = "error";
}
if (s === 4) {
label = "Resigned";
colorClass = "warning";
}
return `<div class="v-chip v-chip--size-${chipSize} v-chip--variant-flat bg-${colorClass}"
style="
height:${isMobile.value ? "20px" : "24px"};
padding:0 ${isMobile.value ? "6px" : "8px"};
display:inline-flex;
align-items:center;
border-radius:12px;
font-size:${fontSize};
font-weight:500;
color:white;
border: inherit;
">${label}</div>`;
};
const chipSize = isMobile.value ? "x-small" : "small";
const fontSize = isMobile.value ? "10px" : "12px";
return `<div class="v-chip v-chip--size-${chipSize} v-chip--variant-flat bg-${colorClass}"
style="
height:${isMobile.value ? "20px" : "24px"};
padding:0 ${isMobile.value ? "6px" : "8px"};
display:inline-flex;
align-items:center;
border-radius:12px;
font-size:${fontSize};
font-weight:500;
color:white;
border: inherit;
">${label}</div>`;
};
const statusCellEditor = () => {
const statusOptions = [
@@ -239,7 +244,7 @@ const statusCellRenderer = (params) => {
`;
};
const columnDefs = ref([
const createDefaultColumns = () => [
{
headerName: "NAME",
field: "fullName",
@@ -253,9 +258,6 @@ const statusCellRenderer = (params) => {
cellEditorParams: {
maxLength: 50,
},
onCellValueChanged: (params) => {
console.log("Name changed:", params.newValue);
},
},
{
headerName: "EMAIL",
@@ -270,9 +272,6 @@ const statusCellRenderer = (params) => {
cellEditorParams: {
maxLength: 100,
},
onCellValueChanged: (params) => {
console.log("Email changed:", params.newValue);
},
},
{
headerName: "DATE",
@@ -284,26 +283,17 @@ const statusCellRenderer = (params) => {
hide: false,
editable: true,
cellEditor: "agDateCellEditor",
cellEditorParams: {
preventEdit: (params) => {
return false;
},
},
valueFormatter: (params) => {
if (!params.value) return "";
const date = new Date(params.value);
if (isNaN(date.getTime())) return "";
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
},
valueParser: (params) => {
if (!params.newValue) return null;
const dateStr = params.newValue;
if (typeof dateStr === "string" && dateStr.includes("/")) {
const parts = dateStr.split("/");
@@ -311,14 +301,12 @@ const statusCellRenderer = (params) => {
const day = parseInt(parts[0]);
const month = parseInt(parts[1]) - 1;
const year = parseInt(parts[2]);
const date = new Date(year, month, day);
if (!isNaN(date.getTime())) {
return date;
}
}
}
return new Date(dateStr);
},
filterParams: {
@@ -327,14 +315,11 @@ const statusCellRenderer = (params) => {
suppressAndOrCondition: true,
comparator: (filterLocalDateAtMidnight, cellValue) => {
if (!cellValue) return -1;
const cellDate = new Date(cellValue);
cellDate.setHours(0, 0, 0, 0);
if (filterLocalDateAtMidnight.getTime() === cellDate.getTime()) {
return 0;
}
return filterLocalDateAtMidnight < cellDate ? -1 : 1;
},
},
@@ -408,7 +393,6 @@ const statusCellRenderer = (params) => {
precision: 0,
showStepperButtons: false,
},
valueFormatter: (params) => {
if (
params.value === null ||
@@ -420,7 +404,6 @@ const statusCellRenderer = (params) => {
const numValue = Number(params.value);
return isNaN(numValue) ? "" : Math.floor(numValue).toString();
},
onCellValueChanged: (params) => {
const newValue = params.newValue;
@@ -477,11 +460,60 @@ const statusCellRenderer = (params) => {
hide: false,
editable: false,
cellRenderer: actionButtonsRenderer,
suppressMenu: true,
suppressSorting: true,
suppressFilter: true,
suppressHeaderMenuButton: true,
},
]);
];
const resolveColumns = () => {
if (Array.isArray(customColumns)) return customColumns
if (customColumns && Array.isArray(customColumns.value)) return customColumns.value
return []
}
const processCustomColumns = (columns) => {
if (!Array.isArray(columns) || columns.length === 0) {
const base = createDefaultColumns();
return ensureSingleSelectionColumn(base);
}
const processedColumns = columns.map(col => ({
...col,
// Default to inline-editable unless explicitly disabled, skip action column
editable: col.field === 'action' ? false : (col.editable !== undefined ? col.editable : true),
suppressHeaderMenuButton: col.suppressHeaderMenuButton !== undefined ? col.suppressHeaderMenuButton : false,
}));
const withSelection = ensureSingleSelectionColumn(processedColumns);
const hasActionColumn = processedColumns.some(col => col.field === 'action');
if (!hasActionColumn) {
withSelection.push({
headerName: "ACTION",
field: "action",
sortable: false,
filter: false,
flex: 1,
minWidth: 100,
hide: false,
editable: false,
cellRenderer: actionButtonsRenderer,
suppressHeaderMenuButton: true,
});
}
return withSelection;
};
const columnDefs = computed(() => {
const cols = resolveColumns()
if (cols && Array.isArray(cols) && cols.length > 0) {
const processed = processCustomColumns(cols);
return processed;
}
const defaultCols = createDefaultColumns();
return ensureSingleSelectionColumn(defaultCols);
});
const defaultColDef = computed(() => ({
resizable: true,
@@ -494,7 +526,7 @@ const statusCellRenderer = (params) => {
maxWidth: 400,
wrapText: false,
autoHeight: false,
suppressMenu: false,
suppressHeaderMenuButton: false,
}));
const gridOptions = computed(() => ({
@@ -502,12 +534,16 @@ const statusCellRenderer = (params) => {
headerHeight: isMobile.value ? 48 : 56,
rowHeight: isMobile.value ? 44 : 52,
animateRows: true,
rowSelection: { type: "multiRow" },
getRowId: params => {
const value = params.data && (params.data.id ?? params.data.ID ?? params.data._id)
return value != null ? String(value) : String(params.rowIndex)
},
rowSelection: 'multiple',
rowMultiSelectWithClick: true,
suppressRowClickSelection: false,
pagination: true,
paginationPageSize: isMobile.value ? 5 : 10,
paginationPageSizeSelector: isMobile.value ? [5, 10, 20] : [5, 10, 20, 50],
suppressRowClickSelection: false,
rowMultiSelectWithClick: true,
enableCellTextSelection: true,
suppressHorizontalScroll: false,
alwaysShowHorizontalScroll: false,
@@ -527,55 +563,81 @@ const statusCellRenderer = (params) => {
cacheQuickFilter: true,
enableAdvancedFilter: false,
includeHiddenColumnsInAdvancedFilter: false,
suppressBrowserResizeObserver: false,
maintainColumnOrder: true,
suppressMenuHide: true,
enableRangeSelection: true,
enableFillHandle: false,
enableRangeHandle: false,
loading: false,
suppressNoRowsOverlay: false,
}));
const updateColumnVisibility = () => {
if (!gridApi.value) return;
if (isMobile.value) {
gridApi.value.setColumnVisible("email", false);
gridApi.value.setColumnVisible("startDate", false);
gridApi.value.setColumnVisible("age", false);
} else if (isTablet.value) {
gridApi.value.setColumnVisible("email", true);
gridApi.value.setColumnVisible("startDate", false);
gridApi.value.setColumnVisible("age", true);
} else {
gridApi.value.setColumnVisible("email", true);
gridApi.value.setColumnVisible("startDate", true);
gridApi.value.setColumnVisible("age", true);
const columns = ['email', 'startDate', 'age'];
try {
columns.forEach(colId => {
const column = gridApi.value.getColumn ? gridApi.value.getColumn(colId) : null;
if (!column) return;
let visible = true;
if (isMobile.value) {
visible = false;
} else if (isTablet.value) {
visible = colId !== 'startDate';
}
if (gridApi.value.setColumnVisible) {
gridApi.value.setColumnVisible(colId, visible);
} else if (gridApi.value.columnApi && gridApi.value.columnApi.setColumnVisible) {
gridApi.value.columnApi.setColumnVisible(colId, visible);
}
});
} catch (error) {
console.warn('Column visibility update not supported in this AG Grid version');
}
};
const updatePagination = () => {
if (!gridApi.value) return;
const pageSize = isMobile.value ? 5 : 10;
const pageSizeSelector = isMobile.value ? [5, 10, 20] : [5, 10, 20, 50];
try {
const gui = gridApi.value.getGui && gridApi.value.getGui();
const containerHeight = gui ? gui.clientHeight : 0;
gridApi.value.paginationSetPageSize(pageSize);
gridApi.value.setGridOption("paginationPageSizeSelector", pageSizeSelector);
const headerH = Number(gridOptions.value.headerHeight || (isMobile.value ? 48 : 56));
// Estimate footer height based on overrides (mobile/desktop)
const footerH = isMobile.value ? 48 : 56;
const rowH = Number(gridOptions.value.rowHeight || (isMobile.value ? 44 : 52));
const padding = 8;
let available = containerHeight - headerH - footerH - padding;
if (!Number.isFinite(available) || available <= 0) {
available = (isMobile.value ? 360 : 520) - headerH - footerH - padding;
}
let rowsPerPage = Math.floor(available / rowH);
rowsPerPage = Math.max(3, Math.min(rowsPerPage, 100));
if (typeof gridApi.value.paginationSetPageSize === 'function') {
gridApi.value.paginationSetPageSize(rowsPerPage);
}
} catch (error) {
console.warn('Error updating pagination:', error);
}
};
function onGridReady(params) {
console.log('Grid ready called with params:', params)
gridApi.value = params.api;
rowData.value = data.map((item) => ({
...item,
salary:
typeof item.salary === "string" ? parseInt(item.salary) : item.salary,
age: typeof item.age === "string" ? parseInt(item.age) : item.age,
}));
console.log('Grid API set:', gridApi.value)
updateColumnVisibility();
updatePagination();
setTimeout(() => {
gridApi.value.sizeColumnsToFit();
if (gridApi.value && gridApi.value.sizeColumnsToFit) {
gridApi.value.sizeColumnsToFit();
}
}, 100);
}
@@ -600,6 +662,17 @@ const statusCellRenderer = (params) => {
}
}
watch([isMobile, isTablet], () => {
updateColumnVisibility();
updatePagination();
});
watch(data, (newData) => {
console.log('Data watch triggered with:', newData)
rowData.value = newData || [];
console.log('rowData updated to:', rowData.value)
}, { immediate: true, deep: true });
return {
gridApi,
columnDefs,
@@ -611,4 +684,36 @@ const statusCellRenderer = (params) => {
updateColumnVisibility,
updatePagination,
};
}
}
// Helpers
function ensureSingleSelectionColumn(columns) {
if (!Array.isArray(columns)) return columns;
const isSelectionCol = (c) => !!(c && (c.checkboxSelection || c.headerCheckboxSelection || c.field === '__selection__' || c.field === 'selection'));
const firstIndex = columns.findIndex(isSelectionCol);
if (firstIndex === -1) return columns;
const unique = [];
columns.forEach((c, idx) => {
if (isSelectionCol(c)) {
if (unique.some(isSelectionCol)) return; // skip duplicates
unique.push({ ...c, pinned: 'left' });
} else {
unique.push(c);
}
});
// Move the selection column to the first position
const selIdx = unique.findIndex(isSelectionCol);
if (selIdx > 0) {
const [sel] = unique.splice(selIdx, 1);
unique.unshift(sel);
}
return unique;
}
// removed selection column injection

View File

@@ -1,6 +1,320 @@
.ag-theme-alpine-dark .ag-cell {
cursor: text !important;
}
.ag-theme-alpine.vuetify-grid .ag-cell,
.ag-theme-alpine-dark.vuetify-grid .ag-cell {
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
user-select: text !important;
}
/* Inputs (filters, quick filter, tool panels) */
.ag-theme-alpine.vuetify-grid .ag-input-field-input,
.ag-theme-alpine-dark.vuetify-grid .ag-input-field-input {
background-color: rgba(var(--v-theme-on-surface), 0.04) !important;
color: rgb(var(--v-theme-on-surface)) !important;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
border-radius: 8px !important;
padding: 6px 8px !important;
height: auto !important;
line-height: normal !important;
box-sizing: border-box !important;
display: block !important;
width: 100% !important;
}
.ag-theme-alpine.vuetify-grid .ag-input-field-input:focus,
.ag-theme-alpine-dark.vuetify-grid .ag-input-field-input:focus {
outline: none !important;
border-color: rgb(var(--v-theme-primary)) !important;
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.2) !important;
}
/* Checkboxes (row select, set filter items) */
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper {
width: 18px !important;
height: 18px !important;
border-radius: 6px !important;
background: transparent !important;
border: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input {
width: 14px !important;
height: 14px !important;
border-radius: 4px !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"],
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"] {
width: auto !important;
height: auto !important;
border-radius: 0 !important;
border: 0 !important;
background: transparent !important;
position: static !important;
opacity: 1 !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:checked,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:checked { transform: none; }
/* keep AG Grid's own checkmark */
/* Center and tidy header selection checkbox */
.ag-theme-alpine.vuetify-grid .ag-header-select-all,
.ag-theme-alpine-dark.vuetify-grid .ag-header-select-all {
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 100% !important;
}
.ag-theme-alpine.vuetify-grid .ag-header-cell .ag-selection-checkbox,
.ag-theme-alpine-dark.vuetify-grid .ag-header-cell .ag-selection-checkbox {
margin: 0 auto !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
}
/* Center selection checkbox inside body cells as well */
.ag-theme-alpine.vuetify-grid .ag-cell .ag-selection-checkbox,
.ag-theme-alpine-dark.vuetify-grid .ag-cell .ag-selection-checkbox {
margin: 0 auto !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
}
/* Prevent nested visual duplication: wrapper stays transparent, input renders the box */
.ag-theme-alpine.vuetify-grid .ag-header-cell .ag-checkbox-input-wrapper,
.ag-theme-alpine-dark.vuetify-grid .ag-header-cell .ag-checkbox-input-wrapper {
background: transparent !important;
border: 0 !important;
width: 18px !important;
height: 18px !important;
}
/* Unify checkbox input look & interactions */
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper {
width: 18px !important;
height: 18px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"],
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"] {
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:hover,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:hover {
border-color: rgb(var(--v-theme-primary)) !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:focus-visible,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.25) !important;
}
/* Improve alignment for narrow first column without forcing brittle indices */
.ag-theme-alpine.vuetify-grid .ag-header-cell.ag-column-first,
.ag-theme-alpine-dark.vuetify-grid .ag-header-cell.ag-column-first {
padding-left: 6px !important;
padding-right: 6px !important;
}
/* Make selection column narrower and center contents */
.ag-theme-alpine.vuetify-grid .ag-header-cell.ag-column-first,
.ag-theme-alpine-dark.vuetify-grid .ag-header-cell.ag-column-first {
padding-left: 6px !important;
padding-right: 6px !important;
}
.ag-theme-alpine.vuetify-grid .ag-center-cols-container .ag-cell.ag-cell-not-inline-editing.ag-cell-focus.ag-cell-value.ag-cell-normal-height[aria-colindex="1"],
.ag-theme-alpine-dark.vuetify-grid .ag-center-cols-container .ag-cell.ag-cell-not-inline-editing.ag-cell-focus.ag-cell-value.ag-cell-normal-height[aria-colindex="1"] {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Set filter items aesthetics */
.ag-theme-alpine.vuetify-grid .ag-set-filter-item,
.ag-theme-alpine-dark.vuetify-grid .ag-set-filter-item {
padding: 6px 8px !important;
border-radius: 8px !important;
}
.ag-theme-alpine.vuetify-grid .ag-set-filter-item:hover,
.ag-theme-alpine-dark.vuetify-grid .ag-set-filter-item:hover {
background-color: rgba(var(--v-theme-primary), 0.08) !important;
}
/* Status cell: ensure chip text aligns nicely */
.ag-theme-alpine.vuetify-grid .ag-cell[col-id="status"],
.ag-theme-alpine-dark.vuetify-grid .ag-cell[col-id="status"] {
padding-top: 6px !important;
padding-bottom: 6px !important;
}
.ag-theme-alpine .ag-cell,
.ag-theme-alpine-dark .ag-cell {
font-size: 0.95rem !important;
}
/* Set Filter, Column Tool Panel, Picker styles to match panel */
.ag-theme-alpine .ag-menu,
.ag-theme-alpine .ag-filter,
.ag-theme-alpine .ag-set-filter,
.ag-theme-alpine .ag-column-panel,
.ag-theme-alpine .ag-tool-panel-wrapper,
.ag-theme-alpine .ag-picker-field-wrapper {
background-color: rgb(var(--v-theme-surface)) !important;
color: rgb(var(--v-theme-on-surface)) !important;
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.ag-theme-alpine-dark .ag-menu,
.ag-theme-alpine-dark .ag-filter,
.ag-theme-alpine-dark .ag-set-filter,
.ag-theme-alpine-dark .ag-column-panel,
.ag-theme-alpine-dark .ag-tool-panel-wrapper,
.ag-theme-alpine-dark .ag-picker-field-wrapper {
background-color: rgb(var(--v-theme-surface)) !important;
color: rgb(var(--v-theme-on-surface)) !important;
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.ag-theme-alpine .ag-set-filter-item,
.ag-theme-alpine-dark .ag-set-filter-item {
color: rgb(var(--v-theme-on-surface)) !important;
}
/* Target the exact AG Grid checkbox structure you shared */
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-wrapper,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-wrapper {
position: static !important;
width: auto !important;
height: auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border: 0 !important;
background: transparent !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-wrapper:hover,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-wrapper:hover { transform: none; }
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-wrapper.ag-checked,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-wrapper.ag-checked { transform: none; }
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-wrapper.ag-checked::after,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-wrapper.ag-checked::after { content: none !important; }
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-field-input,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-field-input {
position: static !important;
opacity: 1 !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-field-input:focus-visible + .ag-input-wrapper,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-field-input:focus-visible + .ag-input-wrapper { transform: none; }
/* Ensure horizontal/vertical centering of checkbox in header and cells */
.ag-theme-alpine.vuetify-grid .ag-header-cell .ag-checkbox,
.ag-theme-alpine-dark.vuetify-grid .ag-header-cell .ag-checkbox,
.ag-theme-alpine.vuetify-grid .ag-cell .ag-checkbox,
.ag-theme-alpine-dark.vuetify-grid .ag-cell .ag-checkbox {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
height: 100% !important;
}
/* Final neutralization: let AG Grid draw the checkbox visuals */
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"],
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"] {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:checked,
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:checked {
background: transparent !important;
border-color: transparent !important;
}
.ag-theme-alpine .ag-set-filter-item:hover,
.ag-theme-alpine-dark .ag-set-filter-item:hover {
background-color: rgba(var(--v-theme-primary), 0.08) !important;
}
/* Remove generic checkbox box styling to avoid double visuals; rely on theme defaults */
.ag-theme-alpine .ag-checkbox-input-wrapper input[type="checkbox"],
.ag-theme-alpine-dark .ag-checkbox-input-wrapper input[type="checkbox"] {
background: initial !important;
border: initial !important;
}
.ag-theme-alpine .ag-checkbox-input-wrapper input[type="checkbox"]:checked,
.ag-theme-alpine-dark .ag-checkbox-input-wrapper input[type="checkbox"]:checked {
background: initial !important;
border-color: initial !important;
}
.ag-theme-alpine .ag-selection-checkbox,
.ag-theme-alpine-dark .ag-selection-checkbox {
width: 18px !important;
height: 18px !important;
border-radius: 4px !important;
}
.ag-theme-alpine .ag-header-cell-menu-button,
.ag-theme-alpine-dark .ag-header-cell-menu-button {
color: rgb(var(--v-theme-primary)) !important;
}
.ag-theme-alpine .ag-header-cell:hover .ag-icon-filter,
.ag-theme-alpine-dark .ag-header-cell:hover .ag-icon-filter {
color: rgb(var(--v-theme-primary)) !important;
}
.ag-theme-alpine .ag-picker-field-display,
.ag-theme-alpine-dark .ag-picker-field-display {
color: rgb(var(--v-theme-on-surface)) !important;
}
.ag-theme-alpine .ag-select .ag-picker-field-wrapper,
.ag-theme-alpine-dark .ag-select .ag-picker-field-wrapper {
background-color: rgba(var(--v-theme-on-surface), 0.05) !important;
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
border-radius: 6px !important;
padding: 4px 8px !important;
min-height: 32px !important;
}
.ag-theme-alpine .ag-list-item,
.ag-theme-alpine-dark .ag-list-item {
color: rgb(var(--v-theme-on-surface)) !important;
}
.ag-theme-alpine-dark .ag-cell:hover {
cursor: text !important;
@@ -150,45 +464,25 @@
}
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"] {
background-color: rgb(var(--v-theme-surface)) !important;
border: 2px solid rgba(var(--v-theme-on-surface), 0.6) !important;
background: transparent !important;
border: 0 !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"]:checked {
background-color: rgb(var(--v-theme-primary)) !important;
border-color: rgb(var(--v-theme-primary)) !important;
background: transparent !important;
border-color: transparent !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox {
width: 20px !important;
height: 20px !important;
border-radius: 4px !important;
border: 2px solid rgba(var(--v-theme-on-surface), 0.6) !important;
background-color: rgb(var(--v-theme-surface)) !important;
position: relative !important;
transition: all 0.2s ease-in-out !important;
width: 18px !important;
height: 18px !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox:checked {
background-color: rgb(var(--v-theme-primary)) !important;
border-color: rgb(var(--v-theme-primary)) !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox:checked { transform: none; }
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox:checked::after {
content: "" !important;
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
color: rgb(var(--v-theme-on-primary)) !important;
font-size: 12px !important;
font-weight: bold !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox:checked::after { content: none !important; }
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox:hover {
border-color: rgb(var(--v-theme-primary)) !important;
background-color: rgba(var(--v-theme-primary), 0.08) !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox:hover { transform: none; }
.ag-theme-alpine-dark.vuetify-grid .ag-menu {
background-color: rgb(var(--v-theme-surface)) !important;
@@ -313,7 +607,31 @@
justify-content: space-between !important;
transition: all 0.2s ease-in-out !important;
position: relative !important;
overflow: hidden !important;
overflow: visible !important;
}
/* Ensure page-size picker can overflow and is clickable */
.ag-theme-alpine-dark.vuetify-grid .ag-paging-page-size,
.ag-theme-alpine.vuetify-grid .ag-paging-page-size {
position: relative !important;
overflow: visible !important;
z-index: 2 !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-wrapper.ag-picker-field-wrapper,
.ag-theme-alpine.vuetify-grid .ag-wrapper.ag-picker-field-wrapper {
position: relative !important;
pointer-events: auto !important;
}
/* Raise ag-Grid popups above footer */
.ag-theme-alpine-dark.vuetify-grid .ag-popup,
.ag-theme-alpine.vuetify-grid .ag-popup,
.ag-theme-alpine-dark.vuetify-grid .ag-select-list,
.ag-theme-alpine.vuetify-grid .ag-select-list,
.ag-theme-alpine-dark.vuetify-grid .ag-menu,
.ag-theme-alpine.vuetify-grid .ag-menu {
z-index: 9999 !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-paging-panel:hover {
@@ -653,7 +971,7 @@
justify-content: space-between !important;
transition: all 0.2s ease-in-out !important;
position: relative !important;
overflow: hidden !important;
overflow: visible !important;
font-size: 14px !important;
}
@@ -998,4 +1316,73 @@
.ag-theme-alpine-dark.vuetify-grid .ag-paging-page-summary-panel,
.ag-theme-alpine-dark.vuetify-grid .ag-paging-row-summary-panel {
color: rgb(var(--v-theme-on-surface)) !important;
}
/* === FINAL MINIMAL CHECKBOX FIXES (reset visuals, keep centering) === */
/* Reset wrappers/inputs to AG Grid default rendering so no double visuals occur */
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper,
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper {
width: auto !important;
height: auto !important;
background: transparent !important;
border: 0 !important;
display: inline-block !important;
padding: 0 !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox .ag-input-wrapper,
.ag-theme-alpine.vuetify-grid .ag-checkbox .ag-input-wrapper {
width: auto !important;
height: auto !important;
background: transparent !important;
border: 0 !important;
position: static !important;
box-shadow: none !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"],
.ag-theme-alpine.vuetify-grid .ag-checkbox-input-wrapper input[type="checkbox"] {
position: static !important;
width: 16px !important;
height: 16px !important;
opacity: 1 !important;
cursor: pointer !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
/* Center selection checkboxes in header and cells without altering visuals */
.ag-theme-alpine-dark.vuetify-grid .ag-header-cell .ag-header-select-all,
.ag-theme-alpine.vuetify-grid .ag-header-cell .ag-header-select-all {
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 100% !important;
}
/* Ensure checkbox container is centered */
.ag-theme-alpine-dark.vuetify-grid .ag-selection-checkbox,
.ag-theme-alpine.vuetify-grid .ag-selection-checkbox,
.ag-theme-alpine-dark.vuetify-grid .ag-header-select-all,
.ag-theme-alpine.vuetify-grid .ag-header-select-all,
.ag-theme-alpine-dark.vuetify-grid .ag-cell .ag-checkbox,
.ag-theme-alpine.vuetify-grid .ag-cell .ag-checkbox {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.ag-theme-alpine-dark.vuetify-grid .ag-centered,
.ag-theme-alpine.vuetify-grid .ag-centered {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 auto !important;
}
/* Optional: use AG Grid var to unify checkbox size if supported */
.ag-theme-alpine-dark.vuetify-grid,
.ag-theme-alpine.vuetify-grid {
--ag-checkbox-size: 18px;
}