1036 lines
30 KiB
Vue
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> |