440 lines
15 KiB
Vue
440 lines
15 KiB
Vue
<template>
|
|
<TimeOption
|
|
v-bind="{ pagename: vpagename, api: api, timeopt: 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="timeopt">
|
|
</TimeOption>
|
|
<DataTable v-bind="{ pagename: vpagename }" @edit="edit" @insert="insert" @dataevent="dataEvent" @displayDataChange="displayDataChange" v-if="pagedata" />
|
|
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal" />
|
|
</template>
|
|
|
|
<script setup>
|
|
import TimeOption from '~/components/datatable/TimeOption'
|
|
import { ref, watch, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
|
|
import { useStore } from '~/stores/index'
|
|
|
|
const emit = defineEmits(['modalevent', 'dataevent', 'dataUpdated', 'changedata'])
|
|
const store = useStore()
|
|
|
|
const props = defineProps({
|
|
pagename: String,
|
|
api: String,
|
|
setting: String,
|
|
filter: Object,
|
|
params: Object,
|
|
data: Object,
|
|
modal: Object,
|
|
timeopt: Object,
|
|
realtime: Object,
|
|
importdata: Object,
|
|
})
|
|
|
|
const { $copy, $find, $findapi, $getapi, $setpage, $clone, $stripHtml, $snackbar, $dayjs, $exportTableExcel } = useNuxtApp()
|
|
|
|
const showmodal = ref()
|
|
const pagedata = ref()
|
|
const newDataAvailable = ref(false)
|
|
const pendingNewData = ref(null)
|
|
const lastDataHash = ref(null)
|
|
const pollingInterval = ref(null)
|
|
|
|
let vpagename = props.pagename
|
|
let vfilter = props.filter ? $copy(props.filter) : undefined
|
|
let vparams = props.params ? $copy(props.params) : undefined
|
|
let connection = undefined
|
|
let optfilter = props.filter || (props.params ? props.params.filter : undefined)
|
|
|
|
const realtimeConfig = ref({ time: 0, update: 'true' })
|
|
if (props.realtime) {
|
|
realtimeConfig.value = { time: props.realtime.time || 0, update: props.realtime.update }
|
|
}
|
|
|
|
if (vparams?.filter) {
|
|
for (const [key, value] of Object.entries(vparams.filter)) {
|
|
if (value.toString().indexOf('$') >= 0)
|
|
vparams.filter[key] = store[value.replace('$', '')].id
|
|
}
|
|
}
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
function resolveStoreVars(filter) {
|
|
for (const [key, value] of Object.entries(filter)) {
|
|
if (value?.toString().indexOf('$') >= 0)
|
|
filter[key] = store[value.replace('$', '')].id
|
|
}
|
|
return filter
|
|
}
|
|
|
|
function buildFilteredConn(apiName) {
|
|
const conn = $findapi(apiName)
|
|
if (vfilter) {
|
|
const filter = { ...(conn.params.filter || {}) }
|
|
for (const [k, v] of Object.entries(vfilter)) filter[k] = v
|
|
conn.params.filter = resolveStoreVars(filter)
|
|
}
|
|
if (vparams) conn.params = $copy(vparams)
|
|
return conn
|
|
}
|
|
|
|
// ── enrich array fields: lookup FK từ bảng khác, inject vào array items ──────
|
|
// Cấu hình trong field config:
|
|
// {
|
|
// name: "allocation_detail", split: "stack", splitField: "schedule_code",
|
|
// lookup: {
|
|
// api: "payment_schedule", localKey: "schedule_id", remoteKey: "id",
|
|
// values: "id,code,batch_date",
|
|
// inject: { "schedule_code": "code", "schedule_date": "batch_date" }
|
|
// }
|
|
// }
|
|
const lookupCache = {}
|
|
|
|
function collectLookupTasks(fields) {
|
|
const tasks = []
|
|
fields.forEach(field => {
|
|
if (!field.name) return
|
|
if (field.lookup && field.split === 'stack') {
|
|
tasks.push({
|
|
fieldName: field.name,
|
|
lookup: {
|
|
localKey: field.lookup.localKey || field.splitField || 'id',
|
|
remoteKey: field.lookup.remoteKey || 'id',
|
|
api: field.lookup.api,
|
|
values: field.lookup.values,
|
|
inject: field.lookup.inject || {},
|
|
},
|
|
})
|
|
}
|
|
if (field.split === 'columns' && field.subfields?.length) {
|
|
field.subfields.forEach(sf => {
|
|
if (!sf.lookup) return
|
|
tasks.push({
|
|
fieldName: field.name,
|
|
subfieldName: sf.name,
|
|
lookup: {
|
|
localKey: sf.name,
|
|
remoteKey: sf.lookup.remoteKey || 'id',
|
|
api: sf.lookup.api,
|
|
values: sf.lookup.values,
|
|
display: sf.lookup.display,
|
|
inject: sf.lookup.inject || {},
|
|
},
|
|
})
|
|
})
|
|
}
|
|
})
|
|
return tasks
|
|
}
|
|
|
|
async function fetchLookupMaps(tasks, rows) {
|
|
const groups = {}
|
|
tasks.forEach(task => {
|
|
const { api, remoteKey, localKey, values } = task.lookup
|
|
const cacheKey = `${api}:${remoteKey}`
|
|
if (!groups[cacheKey]) groups[cacheKey] = { api, remoteKey, values, ids: new Set() }
|
|
rows.forEach(row => {
|
|
const arr = row[task.fieldName]
|
|
if (!arr?.length) return
|
|
arr.forEach(item => {
|
|
const id = item[localKey]
|
|
if (id != null && !lookupCache[cacheKey]?.has(id))
|
|
groups[cacheKey].ids.add(id)
|
|
})
|
|
})
|
|
})
|
|
await Promise.all(Object.entries(groups).map(async ([cacheKey, group]) => {
|
|
if (!group.ids.size) return
|
|
try {
|
|
const conn = $findapi(group.api)
|
|
conn.params = {
|
|
filter: { [`${group.remoteKey}__in`]: [...group.ids] },
|
|
page: -1,
|
|
...(group.values ? { values: group.values } : {}),
|
|
}
|
|
const rs = await $getapi([conn])
|
|
const data = rs?.[0]?.data?.rows || rs?.[0]?.data || []
|
|
if (!lookupCache[cacheKey]) lookupCache[cacheKey] = new Map()
|
|
const map = lookupCache[cacheKey]
|
|
;(Array.isArray(data) ? data : []).forEach(r => map.set(r[group.remoteKey], r))
|
|
} catch (e) {
|
|
console.warn(`[DataView] lookup failed for ${group.api}:`, e)
|
|
}
|
|
}))
|
|
return lookupCache
|
|
}
|
|
|
|
function applyLookups(rows, tasks, maps) {
|
|
if (!tasks.length) return rows
|
|
return rows.map(row => {
|
|
const newRow = { ...row }
|
|
tasks.forEach(task => {
|
|
const arr = row[task.fieldName]
|
|
if (!arr?.length) return
|
|
const { localKey, remoteKey, inject, display } = task.lookup
|
|
const cacheKey = `${task.lookup.api}:${remoteKey}`
|
|
const map = maps[cacheKey]
|
|
if (!map) return
|
|
newRow[task.fieldName] = arr.map(item => {
|
|
const remote = map.get(item[localKey])
|
|
if (!remote) return item
|
|
const enriched = { ...item }
|
|
if (inject) Object.entries(inject).forEach(([lf, rf]) => { enriched[lf] = remote[rf] })
|
|
if (display && task.subfieldName) enriched[`_${task.subfieldName}_display`] = remote[display]
|
|
return enriched
|
|
})
|
|
})
|
|
return newRow
|
|
})
|
|
}
|
|
|
|
async function enrichArrayFields(rows) {
|
|
if (!rows?.length) return rows
|
|
const fields = store[vpagename]?.fields || pagedata.value?.fields || []
|
|
const tasks = collectLookupTasks(fields)
|
|
if (!tasks.length) return rows
|
|
const maps = await fetchLookupMaps(tasks, rows)
|
|
return applyLookups(rows, tasks, maps)
|
|
}
|
|
|
|
// ── hash & polling ────────────────────────────────────────────────────────────
|
|
const generateDataHash = (data) => {
|
|
if (!data) return null
|
|
try {
|
|
const replacer = (key, value) =>
|
|
value && typeof value === 'object' && !Array.isArray(value)
|
|
? Object.keys(value).sort().reduce((s, k) => { s[k] = value[k]; return s }, {})
|
|
: value
|
|
const str = JSON.stringify(data, replacer)
|
|
return str.split('').reduce((a, b) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a }, 0)
|
|
} catch (e) { return null }
|
|
}
|
|
|
|
const stopAutoCheck = () => {
|
|
if (pollingInterval.value) { clearInterval(pollingInterval.value); pollingInterval.value = null }
|
|
}
|
|
|
|
const startAutoCheck = () => {
|
|
stopAutoCheck()
|
|
if (realtimeConfig.value.time && realtimeConfig.value.time > 0)
|
|
pollingInterval.value = setInterval(() => checkDataChanges(), realtimeConfig.value.time * 1000)
|
|
}
|
|
|
|
const checkDataChanges = async () => {
|
|
try {
|
|
const conn = buildFilteredConn(props.api)
|
|
delete conn.params.sort
|
|
delete conn.params.values
|
|
conn.params.summary = 'aggregate'
|
|
conn.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' },
|
|
})
|
|
const rs = await $getapi([conn])
|
|
const obj = $find(rs, { name: props.api })
|
|
const newHash = generateDataHash(obj ? obj.data.rows : {})
|
|
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 conn = buildFilteredConn(props.api)
|
|
delete conn.params.summary
|
|
delete conn.params.distinct_values
|
|
const rs = await $getapi([conn])
|
|
const obj = $find(rs, { name: props.api })
|
|
const rawData = obj ? $copy(obj.data.rows) : []
|
|
const data = await enrichArrayFields(rawData)
|
|
updateDataDisplay(data)
|
|
} 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
|
|
pendingNewData.value = null
|
|
}
|
|
|
|
const openImportModal = () => { showmodal.value = $copy(props.importdata) }
|
|
const manualRefresh = () => { if (pendingNewData.value) updateDataDisplay(pendingNewData.value) }
|
|
|
|
const refreshData = async () => {
|
|
stopAutoCheck()
|
|
newDataAvailable.value = false
|
|
pendingNewData.value = null
|
|
await getApi()
|
|
lastDataHash.value = null
|
|
await checkDataChanges()
|
|
newDataAvailable.value = false
|
|
startAutoCheck()
|
|
}
|
|
|
|
watch(() => props.realtime, (newVal) => {
|
|
if (newVal) {
|
|
realtimeConfig.value.time = newVal.time || 0
|
|
realtimeConfig.value.update = newVal.update === true
|
|
startAutoCheck()
|
|
}
|
|
}, { deep: true })
|
|
|
|
onDeactivated(() => stopAutoCheck())
|
|
onActivated(() => startAutoCheck())
|
|
onBeforeUnmount(() => stopAutoCheck())
|
|
|
|
// ── timeOption ────────────────────────────────────────────────────────────────
|
|
const timeOption = (v) => {
|
|
if (!v) return getApi()
|
|
if (v.filter_or) {
|
|
if (vfilter) vfilter = undefined
|
|
if (vparams) {
|
|
vparams.filter_or = v.filter_or
|
|
} else {
|
|
const found = $copy($findapi(props.api))
|
|
found.params.filter_or = v.filter_or
|
|
if (props.filter) found.params.filter = resolveStoreVars($copy(props.filter))
|
|
vparams = found.params
|
|
}
|
|
return getApi()
|
|
}
|
|
let filter = vfilter ? vfilter : (props.params ? props.params.filter || {} : {})
|
|
for (const [key, value] of Object.entries(v.filter)) filter[key] = value
|
|
resolveStoreVars(filter)
|
|
if (vfilter) { vfilter = filter; vparams = undefined }
|
|
else if (vparams) { vparams.filter = filter; vparams.filter_or = undefined }
|
|
if (!vfilter && !vparams) vfilter = filter
|
|
getApi()
|
|
}
|
|
|
|
// ── edit / insert / events ────────────────────────────────────────────────────
|
|
const edit = (v) => {
|
|
const copy = props.modal ? $copy(props.modal) : {}
|
|
const f = copy.vbind ? copy.vbind : { pagename: vpagename, api: props.api, row: v }
|
|
f.pagename = vpagename
|
|
f.row = v
|
|
copy.vbind = f
|
|
showmodal.value = copy
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const dataEvent = (v, field, data) => {
|
|
if (data?.modal) {
|
|
const copy = $copy(data.modal)
|
|
const f = copy.vbind ? copy.vbind : {}
|
|
if (!f.api) f.api = props.api
|
|
f.pagename = vpagename
|
|
if (!f.row) f.row = v
|
|
copy.vbind = f
|
|
copy.field = field
|
|
showmodal.value = copy
|
|
}
|
|
emit('modalevent', { name: 'dataevent', data: { row: v, field: field } })
|
|
emit('dataevent', v, field)
|
|
}
|
|
|
|
const displayDataChange = (args) => {
|
|
emit('displayDataChange', args);
|
|
}
|
|
|
|
// ── getApi ────────────────────────────────────────────────────────────────────
|
|
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) {
|
|
const conn = $findapi('usersetting')
|
|
conn.params.filter = props.setting > 0 ? { id: props.setting } : { name: props.setting }
|
|
connlist.push(conn)
|
|
}
|
|
|
|
let rawData = props.data ? $copy(props.data) : undefined
|
|
|
|
if (!rawData) {
|
|
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) conn1.params = vparams
|
|
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) rawData = $copy(obj.data.rows)
|
|
}
|
|
|
|
pagedata.value = $setpage(vpagename, row, obj)
|
|
const data = await enrichArrayFields(rawData)
|
|
const copy = $clone(pagedata.value)
|
|
copy.data = data
|
|
copy.update = { data }
|
|
store.commit(vpagename, copy)
|
|
}
|
|
|
|
// ── exportExcel ───────────────────────────────────────────────────────────────
|
|
const exportExcel = async () => {
|
|
if (!pagedata.value) return
|
|
|
|
const ACTION = ['DebtCheckbox', 'SvgIcon', "name: 'dataevent'", '$emit(', 'modal/']
|
|
const fields = (pagedata.value.fields || []).filter(f => {
|
|
if (f.export === 'yes') return true
|
|
if (!f.show && f.show !== 'true') return false
|
|
if (f.export === 'no') return false
|
|
if (f.template && ACTION.some(k => f.template.includes(k))) return false
|
|
return true
|
|
})
|
|
|
|
const allData = $copy(store[vpagename]?.data || [])
|
|
if (!allData.length) { $snackbar('Không có dữ liệu để xuất.'); return }
|
|
|
|
const filename = $dayjs(new Date()).format('YYYYMMDDHHmmss') + '-data'
|
|
|
|
await $exportTableExcel({
|
|
fields,
|
|
data: allData,
|
|
tablesetting: pagedata.value.tablesetting || [],
|
|
filename,
|
|
})
|
|
}
|
|
if (!props.timeopt) await getApi()
|
|
startAutoCheck()
|
|
</script> |