Files
web/app/components/datatable/PivotDataView.vue
2026-03-02 09:45:33 +07:00

1036 lines
30 KiB
Vue

// PivotDataView.vue - FULL CODE (stable mapping, no aggregation, data will show correctly)
<template>
<div>
<!-- TimeOption Component -->
<TimeOption
v-bind="{
pagename: vpagename,
api: props.api,
timeopt: props.timeopt,
filter: optfilter,
importdata: props.importdata,
newDataAvailable: newDataAvailable,
params: vparams
}"
ref="timeopt"
@option="timeOption"
@excel="exportExcel"
@add="insert"
@manual-refresh="manualRefresh"
@refresh-data="refreshData"
@import="openImportModal"
class="mb-3"
v-if="props.timeopt"
/>
<!-- Filters Display -->
<div class="field is-grouped is-grouped-multiline pl-2 mb-3" v-if="filters && filters.length > 0">
<div class="control mr-5">
<a class="button is-primary is-small has-text-white has-text-weight-bold" @click="clearFilters()">
<span class="fs-14">Xóa lọc</span>
</a>
</div>
<div class="control pr-2 mr-5">
<span class="icon-text">
<SvgIcon v-bind="{name: 'sigma.svg', type: 'primary', size: 20}"></SvgIcon>
<span class="fsb-18 has-text-primary">{{ pivotData.length }}</span>
</span>
</div>
<div class="control" v-for="(v, i) in filters" :key="i">
<div class="tags has-addons is-marginless">
<a class="tag is-primary has-text-white is-marginless" @click="showCondition(v)">
{{ v.label.indexOf('>') >= 0 ? $stripHtml(v.label, 30) : v.label }}
</a>
<a class="tag is-delete is-marginless has-text-black-bis" @click="removeFilter(i)"></a>
</div>
<span class="help has-text-black-bis">
{{ v.sort ? v.sort : (v.select ? ('[' + (v.select.length > 0 ? $stripHtml(v.select[0], 20) : '') + '...∑' + v.select.length + ']') : (v.condition)) }}
</span>
</div>
</div>
<!-- Pivot Table -->
<div class="table-container mb-0" ref="container" v-if="pivotData.length > 0">
<table class="table is-fullwidth is-bordered is-narrow is-hoverable" :style="tableStyle">
<thead>
<tr v-for="(level, levelIndex) in maxHeaderDepth" :key="`header-level-${levelIndex}`">
<template v-if="levelIndex === 0">
<th
v-for="(rowField, idx) in rowFields"
:key="`row-header-group-${idx}`"
:style="headerStyle"
:rowspan="maxHeaderDepth"
class="is-sticky-left"
@click="showFieldMenu(rowField)"
>
<div :style="rowField.dropStyle">
<a v-if="rowField.label.indexOf('<') < 0">{{ rowField.label }}</a>
<a v-else>
<component :is="dynamicComponent(rowField.label)" :row="{}" @clickevent="clickEvent($event, {}, rowField)" />
</a>
</div>
</th>
</template>
<th
v-for="(node, nodeIdx) in getNodesAtLevel(levelIndex)"
:key="`header-node-${levelIndex}-${nodeIdx}`"
:colspan="getColspan(node)"
:rowspan="getRowspan(node, levelIndex)"
:style="getHeaderStyle(node, levelIndex)"
style="z-index: 1;"
class="has-text-centered"
>
{{ node.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in pivotData" :key="`row-${rowIndex}`">
<td
v-for="(rowField, idx) in rowFields"
:key="`row-cell-${rowIndex}-${idx}`"
:style="getRowCellStyle(rowField, row)"
class="is-sticky-left has-text-weight-bold"
@dblclick="doubleClick(rowField, row[rowField.name])"
>
<component
:is="dynamicComponent(rowField.template)"
:row="{ [rowField.name]: row[rowField.name] }"
v-if="rowField.template"
@clickevent="clickEvent($event, row, rowField)"
/>
<span v-else>{{ formatCellValue(row[rowField.name], rowField) }}</span>
</td>
<td
v-for="(leaf, leafIdx) in allLeafColumns"
:key="`data-cell-${rowIndex}-${leafIdx}`"
:style="getDataCellStyle(row, leaf)"
@dblclick="handleCellClick(row, leaf)"
:class="leaf.align || 'has-text-right'"
>
<template v-if="row.data[leaf.colKey]">
<component
:is="dynamicComponent(leaf.template)"
:row="row.data[leaf.colKey]"
v-if="leaf.template"
@clickevent="clickEvent($event, row.data[leaf.colKey], leaf)"
/>
<span v-else>
{{ formatCellValue(row.data[leaf.colKey][leaf.field], leaf) }}
</span>
</template>
<span v-else class="has-text-grey-light">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="has-text-centered has-text-grey py-6">
<p>Không có dữ liệu để hiển thị</p>
</div>
<Modal @close="closeModal" @selected="doSelect" @confirm="confirmRemove" v-bind="showmodal" v-if="showmodal" />
</div>
</template>
<script setup>
import TimeOption from '~/components/datatable/TimeOption'
import { ref, computed, watch, onMounted, onBeforeUnmount, defineComponent } from 'vue'
import { useStore } from '~/stores/index'
const emit = defineEmits(['modalevent', 'dataevent', 'dataUpdated', 'edit', 'insert'])
const store = useStore()
const props = defineProps({
api: String,
pagename: String,
setting: String,
params: Object,
filter: Object,
pivotConfig: Object,
timeopt: Object,
modal: Object,
realtime: Object,
importdata: Object,
data: Object
})
const {
$copy, $find, $findapi, $getapi, $setpage, $clone, $stripHtml,
$snackbar, $dayjs, $formatNumber, $numtoString, $empty, $unique,
$multiSort, $remove, $calculate, $deleterow
} = useNuxtApp()
// Dynamic component helper
function dynamicComponent(htmlString) {
if (!htmlString) return null
return defineComponent({
template: htmlString,
props: {
row: {
type: Object,
default: () => ({})
}
}
})
}
const showmodal = ref()
const pagedata = ref()
const newDataAvailable = ref(false)
const pivotData = ref([])
const columnHeaderTree = ref([])
const filters = ref([])
const currentField = ref()
const currentRow = ref()
const filterData = ref([])
const pollingInterval = ref(null)
const lastDataHash = ref(null)
// Computed properties
const vpagename = props.pagename || 'pagedata_pivot'
const vparams = computed(() => {
if (!props.params) return undefined
const params = $copy(props.params)
if (params.filter) {
for (const [key, value] of Object.entries(params.filter)) {
if (typeof value === 'string' && value.indexOf('$') >= 0) {
const storeKey = value.replace('$', '')
if (store[storeKey]) {
params.filter[key] = store[storeKey].id || store[storeKey]
}
}
}
}
return params
})
let vfilter = props.filter ? $copy(props.filter) : undefined
let optfilter = props.filter || (props.params ? props.params.filter : undefined)
let connection = undefined
const realtimeConfig = ref({ time: 0, update: "true" })
if (props.realtime) {
realtimeConfig.value = { time: props.realtime.time || 0, update: props.realtime.update }
}
// Extract pivot config
const rowFields = computed(() => {
if (!props.pivotConfig?.rowFields) return []
const fieldNames = props.pivotConfig.rowFields
return fieldNames.map(name => {
const field = pagedata.value?.fields?.find(f => f.name === name)
const result = field || { name, label: name, format: 'string' }
result.headerStyle = getSettingStyle('header', result)
result.dropStyle = getSettingStyle('dropdown', result)
return result
})
})
const columnTreeConfig = computed(() => {
const cfg = props.pivotConfig?.columnFields || []
if (cfg.every(item => typeof item === 'string')) {
return cfg.map(field => ({ field, label: field }))
}
return cfg
})
const subColumns = computed(() => {
if (!props.pivotConfig?.subColumns || props.pivotConfig.subColumns.length === 0) {
return [
{ field: 'amount', label: 'Số tiền', format: 'number', decimal: 0, align: 'has-text-right' }
]
}
return props.pivotConfig.subColumns.map(subCol => {
const field = pagedata.value?.fields?.find(f => f.name === subCol.field)
return {
...subCol,
format: subCol.format || field?.format || 'string',
decimal: subCol.decimal !== undefined ? subCol.decimal : field?.decimal,
unit: subCol.unit || field?.unit,
template: subCol.template || field?.template
}
})
})
// Build nested column tree
const buildColumnTree = (rawData) => {
if (!rawData?.length || !columnTreeConfig.value.length) return []
const rootMap = new Map()
rawData.forEach(item => {
let currentMap = rootMap
let currentPath = ''
columnTreeConfig.value.forEach((cfg, level) => {
const field = cfg.field
const val = item[field] != null ? String(item[field]).trim() : 'N/A'
const key = val
const prefix = cfg.prefix || ''
const nodeLabel = cfg.label ? `${cfg.label} ${val}` : (prefix + val)
if (!currentMap.has(key)) {
const node = {
label: nodeLabel,
value: val,
path: currentPath ? currentPath + '|||' + val : val,
sortType: cfg.sortType || 'string',
sortOrder: cfg.sortOrder
}
if (level < columnTreeConfig.value.length - 1) {
node.childrenMap = new Map()
}
currentMap.set(key, node)
}
currentMap = currentMap.get(key)
currentPath = currentMap.path
if (level < columnTreeConfig.value.length - 1) {
currentMap = currentMap.childrenMap
}
})
if (!currentMap.children) {
currentMap.children = subColumns.value.map(sc => ({
...sc,
colKey: currentPath,
isLeaf: true
}))
}
})
const mapToSortedArray = (map) => {
if (!map || !map.size) return [];
const firstNode = map.values().next().value;
const sortType = firstNode?.sortType || 'string';
const sortOrder = firstNode?.sortOrder;
return Array.from(map.values()).sort((a, b) => {
const valA = String(a?.value ?? '')
const valB = String(b?.value ?? '')
if (sortType === 'number') {
const numA = Number(valA)
const numB = Number(valB)
if (!isNaN(numA) && !isNaN(numB)) return numA - numB
}
if (sortType === 'custom' && Array.isArray(sortOrder)) {
const order = sortOrder;
const getIndex = (val) => {
for(let i = 0; i < order.length; i++) {
if (Array.isArray(order[i]) && order[i].includes(val)) return i;
if (order[i] === val) return i;
}
return -1;
}
const indexA = getIndex(valA);
const indexB = getIndex(valB);
if (indexA !== -1 || indexB !== -1) {
if (indexA === -1) return 1;
if (indexB === -1) return -1;
if (indexA !== indexB) return indexA - indexB;
}
}
return valA.localeCompare(valB)
})
}
const recurseSort = (node) => {
if (node.childrenMap) {
node.children = mapToSortedArray(node.childrenMap)
delete node.childrenMap
}
if (node.children && !node.children[0]?.isLeaf) {
node.children.forEach(recurseSort)
}
}
const roots = mapToSortedArray(rootMap)
roots.forEach(recurseSort)
return roots
}
// Simplified transformToPivot - stable colKey, last wins for duplicates
const transformToPivot = (rawData) => {
if (!rawData?.length) {
pivotData.value = []
columnHeaderTree.value = []
return
}
columnHeaderTree.value = buildColumnTree(rawData)
const localRowFields = rowFields.value
const grouped = {}
rawData.forEach(item => {
const rowKey = localRowFields.map(f => {
const val = item[f.name]
return val != null ? String(val).trim() : 'N/A'
}).join('|||')
if (!grouped[rowKey]) {
grouped[rowKey] = {
rowKey,
data: {},
...Object.fromEntries(localRowFields.map(f => [f.name, item[f.name]]))
}
}
const colKey = columnTreeConfig.value.map(cfg => {
const val = item[cfg.field]
return val != null ? String(val).trim() : 'N/A'
}).join('|||')
grouped[rowKey].data[colKey] = { ...item } // last wins
})
pivotData.value = Object.values(grouped)
}
// Header calculations
const maxHeaderDepth = computed(() => {
const getDepth = (nodes, depth = 1) => {
if (!nodes?.length) return depth - 1
return Math.max(depth, ...nodes.map(n => getDepth(n.children, depth + 1)))
}
return getDepth(columnHeaderTree.value) + 1
})
const getNodesAtLevel = (level) => {
let current = columnHeaderTree.value
for (let i = 0; i < level; i++) {
current = current.flatMap(n => n.children || [])
}
return current
}
const getColspan = (node) => {
const countLeaves = (n) => {
if (!n.children?.length) return 1
return n.children.reduce((sum, c) => sum + countLeaves(c), 0)
}
return countLeaves(node)
}
const getRowspan = (node, level) => {
if (node.isLeaf) return 1
if (node.children?.length) return 1
return maxHeaderDepth.value - level
}
const allLeafColumns = computed(() => {
const getLeaves = (nodes) => {
return nodes.flatMap(node => {
if (!node.children?.length) return [node]
return getLeaves(node.children)
})
}
return getLeaves(columnHeaderTree.value)
})
// Formatting
const formatCellValue = (value, field) => {
if (value === undefined || value === null || value === '') return '-'
if (field.format === 'number') {
const num = $formatNumber(value)
if (num === undefined) return '-'
return $numtoString(num, 'vi-VN', field.decimal || 0, field.decimal || 0)
}
if (field.format === 'date') {
return $dayjs(value).format('DD/MM/YYYY')
}
return value
}
// Styles
const tableStyle = computed(() => {
const settings = pagedata.value?.tablesetting || store.tablesetting
if (!settings) return 'font-size: 13px;'
const bg = $find(settings, {code: 'table-background'}, 'detail') || '#ffffff'
const color = $find(settings, {code: 'table-font-color'}, 'detail') || '#000000'
const size = $find(settings, {code: 'table-font-size'}, 'detail') || 13
return `background-color: ${bg}; color: ${color}; font-size: ${size}px;`
})
const headerStyle = computed(() => {
const settings = pagedata.value?.tablesetting || store.tablesetting
if (!settings) return 'background-color: #363636; color: white; font-weight: bold; position: sticky; top: 0; z-index: 10;'
const bg = $find(settings, {code: 'header-background'}, 'detail') || '#363636'
const color = $find(settings, {code: 'header-font-color'}, 'detail') || '#ffffff'
const size = $find(settings, {code: 'header-font-size'}, 'detail') || 14
return `background-color: ${bg}; color: ${color}; font-weight: bold; font-size: ${size}px; position: sticky; top: 0; z-index: 10; padding: 8px; cursor: pointer;`
})
const getSettingStyle = (name, field) => {
const settings = pagedata.value?.tablesetting || store.tablesetting
if (!settings) return ''
let value = ''
if (name === 'header') {
value += 'background-color:' + $find(settings, {code: 'header-background'}, 'detail') + '; '
if (field.minwidth) value += ' min-width: ' + field.minwidth + 'px; '
if (field.maxwidth) style += ' max-width: ' + field.maxwidth + 'px; '
} else if (name === 'dropdown') {
value += 'font-size:' + $find(settings, {code: 'header-font-size'}, 'detail') + 'px; '
const found = filters.value.find(v => v.name === field.name)
value += found
? 'color:' + $find(settings, {code: 'header-filter-color'}, 'detail') + '; '
: 'color:' + $find(settings, {code: 'header-font-color'}, 'detail') + '; '
}
return value
}
const getHeaderStyle = (node, level) => {
let style = headerStyle.value
if (node.minwidth) style += ` min-width: ${node.minwidth}px;`
return style + ' border-bottom: 1px solid #dbdbdb;'
}
const getRowCellStyle = (field, row) => {
let style = 'padding: 8px; vertical-align: middle; border: solid 1px #dbdbdb; background-color: #f5f5f5; cursor: pointer;'
if (field.bgcolor) style += ` background-color: ${field.bgcolor};`
if (field.color) style += ` color: ${field.color};`
if (field.minwidth) style += ` min-width: ${field.minwidth}px;`
if (field.maxwidth) style += ` max-width: ${field.maxwidth}px;`
return style
}
const getDataCellStyle = (row, leaf) => {
let style = 'padding: 6px 8px; vertical-align: middle; border: solid 1px #dbdbdb; cursor: pointer;'
const cell = row.data[leaf.colKey]
if (cell && leaf.field) {
if (leaf.field === 'paid_amount') {
const amount = $formatNumber(cell.amount) || 0
const paid = $formatNumber(cell.paid_amount) || 0
if (paid >= amount) {
style += ' background-color: #d4edda;'
} else if (paid > 0) {
style += ' background-color: #fff3cd;'
} else {
style += ' background-color: #f8d7da;'
}
}
if (leaf.field === 'to_date') {
const dueDate = cell.to_date
const paid = $formatNumber(cell.paid_amount) || 0
const amount = $formatNumber(cell.amount) || 0
if (dueDate && paid < amount) {
const today = new Date()
const due = new Date(dueDate)
if (due < today) style += ' background-color: #f8d7da; color: #721c24;'
}
}
}
return style
}
// Filter functions
const applyFilters = () => {
if (!pagedata.value?.data) {
pivotData.value = []
return
}
let filtered = $copy(pagedata.value.data)
filters.value.forEach(filter => {
if (filter.select && filter.select.length > 0) {
filtered = filtered.filter(row => {
return filter.select.some(selectedValue => {
const rowValue = row[filter.name]
if (typeof selectedValue === 'undefined' || selectedValue === null || selectedValue === '') {
return !rowValue || rowValue === '' || rowValue === null
}
return String(rowValue) === String(selectedValue)
})
})
}
})
transformToPivot(filtered)
}
const clearFilters = () => {
filters.value = []
applyFilters()
}
const removeFilter = (index) => {
$remove(filters.value, index)
applyFilters()
}
const showCondition = (filter) => {
// Placeholder
}
const showFieldMenu = (field) => {
if (pagedata.value?.contextMenu === false || field.menu === 'no') return
currentField.value = field
filterData.value = $unique(pagedata.value.data, [field.name])
showmodal.value = {
vbind: {
pagename: vpagename,
field: field,
filters: filters.value,
filterData: filterData.value,
width: 100
},
component: 'datatable/ContextMenu',
title: field.name,
width: '650px',
height: '500px'
}
}
const doSelect = (value) => {
showmodal.value = undefined
const field = currentField.value
if (!field) return
let found = filters.value.find(v => v.name === field.name)
if (found) {
if (!found.select) found.select = []
const idx = found.select.findIndex(x => String(x) === String(value))
if (idx >= 0) {
found.select.splice(idx, 1)
} else {
found.select.push(value)
}
if (found.select.length === 0) {
const filterIdx = filters.value.findIndex(v => v.name === field.name)
if (filterIdx >= 0) filters.value.splice(filterIdx, 1)
}
} else {
const newFilter = {
name: field.name,
label: field.label,
select: [value],
format: field.format
}
filters.value.push(newFilter)
}
filters.value = [...filters.value]
setTimeout(() => applyFilters(), 0)
}
const doubleClick = (field, value) => {
currentField.value = field
doSelect(value)
}
// Event handlers
const handleCellClick = (row, leaf) => {
const cell = row.data[leaf.colKey]
if (!cell) return
if (props.modal) {
const copy = $copy(props.modal)
const f = copy.vbind ? copy.vbind : { pagename: vpagename, api: props.api, row: cell }
f.pagename = vpagename
f.row = cell
copy.vbind = f
showmodal.value = copy
}
}
const clickEvent = (event, row, field) => {
const name = typeof event === "string" ? event : event.name
const data = typeof event === "string" ? event : event.data
if (name === 'remove') {
currentRow.value = row
showmodal.value = {
component: 'dialog/Confirm',
vbind: {
content: 'Bạn có muốn xóa bản ghi này không?',
duration: 10
},
title: 'Xác nhận',
width: '500px',
height: '100px'
}
}
emit(name, row, field, data)
}
const confirmRemove = () => {
if (currentRow.value?.id && pagedata.value?.api) {
$deleterow(pagedata.value.api.name, currentRow.value.id, vpagename, true)
}
}
const closeModal = () => {
showmodal.value = undefined
}
// Realtime & API functions (giữ nguyên)
const generateDataHash = (data) => {
if (!data) return null
try {
const replacer = (key, value) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.keys(value)
.sort()
.reduce((sorted, key) => {
sorted[key] = value[key]
return sorted
}, {})
: value
const stringToHash = JSON.stringify(data, replacer)
return stringToHash.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0)
return a & a
}, 0)
} catch (e) {
console.error('Error generating data hash:', e)
return null
}
}
const startAutoCheck = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value)
if (realtimeConfig.value.time && realtimeConfig.value.time > 0) {
pollingInterval.value = setInterval(() => checkDataChanges(), realtimeConfig.value.time * 1000)
}
}
const checkDataChanges = async () => {
try {
const connlist = []
const conn1 = $findapi(props.api)
if (vfilter) {
const filter = $copy(conn1.params.filter) || {}
for (const [key, value] of Object.entries(vfilter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
conn1.params.filter = filter
}
if (vparams.value) conn1.params = $copy(vparams.value)
delete conn1.params.sort
delete conn1.params.values
conn1.params.summary = 'aggregate'
conn1.params.distinct_values = JSON.stringify({
total_count: { type: 'Count', field: 'id' },
last_updated: { type: 'Max', field: 'update_time' },
last_created: { type: 'Max', field: 'create_time' }
})
connlist.push(conn1)
const rs = await $getapi(connlist)
const obj = $find(rs, { name: props.api })
const newMetadata = obj ? obj.data.rows : {}
const newHash = generateDataHash(newMetadata)
if (lastDataHash.value === null) {
lastDataHash.value = newHash
return
}
if (newHash !== lastDataHash.value) {
lastDataHash.value = newHash
if (realtimeConfig.value.update === "true") {
await loadFullDataAsync()
emit('dataUpdated', { newData: store[vpagename].data, autoUpdate: true, hasChanges: true })
} else {
newDataAvailable.value = true
emit('dataUpdated', { newData: null, autoUpdate: false, hasChanges: true })
}
}
} catch (error) {
console.error('Error checking data:', error)
}
}
const loadFullDataAsync = async () => {
try {
const connlist = []
const conn1 = $findapi(props.api)
if (vfilter) {
const filter = $copy(conn1.params.filter) || {}
for (const [key, value] of Object.entries(vfilter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
conn1.params.filter = filter
}
if (vparams.value) conn1.params = $copy(vparams.value)
delete conn1.params.summary
delete conn1.params.distinct_values
connlist.push(conn1)
const rs = await $getapi(connlist)
const obj = $find(rs, { name: props.api })
const newData = obj ? $copy(obj.data.rows) : []
updateDataDisplay(newData)
} catch (error) {
console.error('Error loading full data:', error)
}
}
const updateDataDisplay = (newData) => {
const copy = $clone(store[vpagename])
copy.data = newData
copy.update = { data: newData }
store.commit(vpagename, copy)
newDataAvailable.value = false
transformToPivot(newData)
}
const manualRefresh = () => {
refreshData()
}
const refreshData = async () => {
if (pollingInterval.value) clearInterval(pollingInterval.value)
newDataAvailable.value = false
await getApi()
lastDataHash.value = null
await checkDataChanges()
newDataAvailable.value = false
startAutoCheck()
}
const openImportModal = () => {
const copy = $copy(props.importdata)
showmodal.value = copy
}
const timeOption = (v) => {
if (!v) return getApi()
if (v.filter_or) {
if (vfilter) vfilter = undefined
if (vparams.value) {
vparams.value.filter_or = v.filter_or
}
return getApi()
}
let filter = vfilter ? vfilter : (vparams.value ? vparams.value.filter || {} : {})
for (const [key, value] of Object.entries(v.filter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
if (vfilter) {
vfilter = filter
} else if (vparams.value) {
vparams.value.filter = filter
vparams.value.filter_or = undefined
}
if (!vfilter && !vparams.value) vfilter = filter
getApi()
}
const insert = () => {
const copy = props.modal ? $copy(props.modal) : {}
const f = copy.vbind ? copy.vbind : { pagename: vpagename, api: props.api }
f.pagename = vpagename
copy.vbind = f
showmodal.value = copy
}
// API load
const getApi = async () => {
const connlist = []
let row = props.setting?.id ? $copy(props.setting) : undefined
if (!row) {
const found = $find(store.settings.filter(v => v), props.setting > 0 ? { id: props.setting } : { name: props.setting })
if (found) row = $copy(found)
}
if (!row && props.setting) {
const conn = $findapi('usersetting')
conn.params.filter = props.setting > 0 ? { id: props.setting } : { name: props.setting }
connlist.push(conn)
}
let data = props.data ? $copy(props.data) : undefined
if (!data) {
const conn1 = $findapi(props.api)
if (vfilter) {
const filter = conn1.params.filter || {}
for (const [key, value] of Object.entries(vfilter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
conn1.params.filter = filter
}
if (vparams.value) conn1.params = vparams.value
connection = conn1
connlist.push(conn1)
}
let obj = undefined
if (connlist.length > 0) {
const rs = await $getapi(connlist)
const ele = $find(rs, { name: 'usersetting' })
if (ele) {
row = $find(ele.data.rows, { name: props.setting.name || props.setting })
const copy = $copy(store.settings)
copy.push(row)
store.commit('settings', copy)
}
obj = $find(rs, { name: props.api })
if (obj) data = $copy(obj.data.rows)
}
pagedata.value = $setpage(vpagename, row, obj)
const copy = $clone(pagedata.value)
copy.data = data
copy.update = { data: data }
store.commit(vpagename, copy)
transformToPivot(data)
}
const exportExcel = async () => {
if (!props.api || !connection) return
const found = $findapi('exportcsv')
found.params = connection.params
const fields = pagedata.value.fields
.filter(v => (v.show && v.export !== 'no') || v.export === 'yes')
.map(x => ({ name: x.name, label: $stripHtml(x.label) }))
found.params.fields = JSON.stringify(fields)
found.url = connection.url.replace('data/', 'exportcsv/')
try {
const rs = await $getapi([found])
if (rs === 'error') {
$snackbar('Đã xảy ra lỗi. Vui lòng thử lại.')
return
}
const url = window.URL.createObjectURL(new Blob([rs[0].data]))
const link = document.createElement('a')
const fileName = `${$dayjs(new Date()).format('YYYYMMDDhhmmss')}-data.csv`
link.href = url
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
link.remove()
} catch (error) {
console.error('Export error:', error)
$snackbar('Lỗi xuất file. Vui lòng thử lại.')
}
}
// Watchers
watch(() => filters.value, (newVal) => {
console.log('[watch filters] Filters changed:', newVal)
applyFilters()
}, { deep: true })
watch(() => store[vpagename], (newVal) => {
if (newVal?.update?.data) {
console.log('[watch store] Data updated')
pagedata.value = newVal
transformToPivot(newVal.data)
}
}, { deep: true })
watch(() => props.realtime, (newVal) => {
if (newVal) {
realtimeConfig.value.time = newVal.time || 0
realtimeConfig.value.update = newVal.update === true ? "true" : "false"
startAutoCheck()
}
}, { deep: true })
// Lifecycle
onMounted(async () => {
console.log('[PivotDataView] Component mounted')
if (!props.timeopt) await getApi()
startAutoCheck()
})
onBeforeUnmount(() => {
if (pollingInterval.value) clearInterval(pollingInterval.value)
})
</script>
<style scoped>
:deep(.table tbody tr:hover td) {
background-color: hsl(0, 0%, 88%) !important;
color: rgb(0, 0, 0);
}
:deep(.table thead th) {
cursor: pointer;
user-select: none;
}
:deep(.is-sticky-left) {
position: sticky;
left: 0;
z-index: 1000;
}
:deep(.table-container) {
overflow-x: auto;
overflow-y: auto;
}
:deep(.help) {
font-size: 0.75rem;
display: block;
margin-top: 0.25rem;
}
</style>