Files
hrm/app/components/datatable/DataTable.vue
2026-04-06 15:53:14 +07:00

693 lines
25 KiB
Vue

<template>
<div class="field is-grouped is-grouped-multiline pl-2" v-if="filters?.length > 0">
<div class="control mr-5">
<a class="button is-primary is-small has-text-white has-text-weight-bold"
@click="updateData({ filters: [] })">Xóa lọc</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">{{ totalRows }}</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.select
? '[' + (v.select[0] ? $stripHtml(v.select[0], 20) : '') + '' + v.select.length + ']'
: v.condition) }}
</span>
</div>
</div>
<div class="table-container mb-0 dtp-wrap" ref="container">
<table class="table is-fullwidth is-bordered is-narrow is-hoverable dtp-table" :style="tableStyle">
<!-- ═══════════ THEAD ═══════════ -->
<thead>
<!-- ROW 1: group headers (chỉ render nếu có ít nhất 1 field có groupHeader) -->
<tr v-if="hasGroupHeaders" class="dtp-header-group">
<th
v-for="(col, i) in groupHeaderRow"
:key="i"
:colspan="col.colspan"
:rowspan="col.rowspan"
:style="col.style"
:class="['dtp-th-group', col.isGroup ? 'dtp-th-group--label' : 'dtp-th-group--empty']"
>
<div v-if="col.isGroup">{{ col.label }}</div>
<div v-else :style="col.dropStyle" @click="showField(col.field)">
<a v-if="col.label?.indexOf('<') < 0">{{ col.label }}</a>
<a v-else>
<component :is="dynamicComponent(col.label)" :row="{}"
@clickevent="clickEvent($event, {}, col.field)" />
</a>
</div>
</th>
</tr>
<!-- ROW 2: chỉ render fields CÓ groupHeader (field không có groupHeader đã rowspan=2 ở Row 1) -->
<tr class="dtp-header-fields" v-if="hasGroupHeaders">
<th
v-for="(field, i) in displayFields.filter(f => f.groupHeader)"
:key="i"
:style="field.headerStyle"
:class="{ 'dtp-th--sorted': isSorted(field) }"
@click="showField(field)"
>
<div :style="field.dropStyle">
<a v-if="field.label?.indexOf('<') < 0">{{ field.label }}</a>
<a v-else>
<component :is="dynamicComponent(field.label)" :row="{}"
@clickevent="clickEvent($event, {}, field)" />
</a>
</div>
</th>
</tr>
<!-- ROW đơn (không có groupHeader nào): render bình thường -->
<tr class="dtp-header-fields" v-else>
<th
v-for="(field, i) in displayFields"
:key="i"
:style="field.headerStyle"
:class="{ 'dtp-th--sorted': isSorted(field) }"
@click="showField(field)"
>
<div :style="field.dropStyle">
<a v-if="field.label?.indexOf('<') < 0">{{ field.label }}</a>
<a v-else>
<component :is="dynamicComponent(field.label)" :row="{}"
@clickevent="clickEvent($event, {}, field)" />
</a>
</div>
</th>
</tr>
</thead>
<!-- ═══════════ TBODY ═══════════ -->
<tbody>
<tr v-for="(v, i) in displayData" :key="i">
<td
v-for="(field, j) in displayFields"
:key="j"
:style="tdStyle(field, v)"
:class="[
'dtp-td',
field.split === 'stack' ? 'dtp-td--stack' : '',
field.split === 'columns' ? 'dtp-td--columns' : '',
field.textalign === 'right' ? 'dtp-td--right' : '',
]"
@dblclick="doubleClick(field, v)"
>
<!-- ── Custom template ── -->
<component
v-if="field.template"
:is="dynamicComponent(field.template)"
:row="v"
@clickevent="clickEvent($event, v, field)"
/>
<!-- ── split: "stack" ── -->
<template v-else-if="field.split === 'stack'">
<div class="dtp-stack">
<template v-if="v[field.name]?.length > 0">
<div
class="dtp-stack-row"
v-for="(item, si) in v[field.name]"
:key="si"
>
<!-- splitExpression: "{inv_amount} - {plan_amount}" → tính toán số học -->
<span v-if="field.splitExpression">
<CellValue
:value="evalSplitExpression(field.splitExpression, item)"
:type="field.splitType || 'number'"
:align="field.textalign"
/>
</span>
<!-- splitTemplate: "Đợt {cycle} - {code}" → ghép nhiều field -->
<span v-else-if="field.splitTemplate" class="dtp-split-tpl">
{{ resolveSplitTemplate(field.splitTemplate, item) }}
</span>
<CellValue
v-else
:value="item[field.splitField]"
:type="field.splitType || detectType(item[field.splitField])"
:align="field.textalign"
/>
</div>
</template>
<div v-else class="dtp-stack-row dtp-stack-row--empty">
<span class="dtp-null">—</span>
</div>
</div>
</template>
<!-- ── split: "columns" ── -->
<template v-else-if="field.split === 'columns'">
<div v-if="!v[field.name] || v[field.name].length === 0"
class="dtp-cols-empty">Không có dữ liệu</div>
<table v-else class="dtp-minitable">
<thead v-if="field.showSubHeader !== false">
<tr>
<th v-for="(sf, si) in resolveSubfields(field, v[field.name])" :key="si">
{{ sf.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, si) in v[field.name]" :key="si">
<td
v-for="(sf, sj) in resolveSubfields(field, v[field.name])"
:key="sj"
:class="['dtp-minitd', sf.type === 'currency' || sf.type === 'number' ? 'dtp-minitd--right' : '']"
>
<CellValue :value="item[sf.name]" :type="sf.type" />
</td>
</tr>
</tbody>
<!-- mini footer sum -->
<tfoot v-if="field.showSubFooter !== false && hasSummableSubs(field, v[field.name])">
<tr>
<td
v-for="(sf, si) in resolveSubfields(field, v[field.name])"
:key="si"
:class="sf.type === 'currency' || sf.type === 'number' ? 'dtp-minitd--right' : ''"
class="dtp-minifoot"
>
<template v-if="si === 0">
<span class="dtp-null">∑</span>
</template>
<template v-else-if="sf.type === 'currency' || sf.type === 'number'">
<CellValue :value="sumSubField(v[field.name], sf.name)" :type="sf.type" />
</template>
</td>
</tr>
</tfoot>
</table>
</template>
<!-- ── Normal cell (giữ nguyên logic gốc) ── -->
<template v-else>
<CellValue
v-if="field.type"
:value="v[field.name]"
:type="field.type"
:field="field"
/>
<span v-else>{{ v[field.name] ?? '' }}</span>
</template>
</td>
</tr>
</tbody>
<!-- ═══════════ TFOOT (summary) ═══════════ -->
<tfoot v-if="showSummary">
<tr>
<td
v-for="(field, i) in displayFields"
:key="i"
:style="field.headerStyle"
class="dtp-foot"
:class="field.textalign === 'right' ? 'dtp-td--right' : ''"
>
<span v-if="i === 0" class="dtp-foot-label">∑</span>
<CellValue
v-else-if="(field.summary === 'sum' || field.type === 'currency') && !field.split"
:value="sumColumn(field)"
type="currency"
/>
<span v-else-if="field.summary === 'count'">{{ totalRows }}</span>
</td>
</tr>
</tfoot>
</table>
<DatatablePagination
v-bind="{ data: data, perPage: perPage }"
@changepage="changePage"
v-if="showPaging"
/>
</div>
<Modal
@close="close"
@selected="doSelect"
@confirm="confirmRemove"
v-bind="showmodal"
v-if="showmodal"
/>
</template>
<!-- ════════════════════════ CELL VALUE COMPONENT ════════════════════════ -->
<script>
export const CellValue = {
name: 'CellValue',
props: {
value: { default: null },
type: { type: String, default: 'string' },
field: { type: Object, default: () => ({}) },
align: { type: String, default: '' },
},
setup(props) {
const { $formatNumber } = useNuxtApp()
function fmtCurrency(v) {
if (v === null || v === undefined) return null
return Number(v).toLocaleString('vi-VN')
}
return { fmtCurrency }
},
template: `
<span v-if="value === null || value === undefined" class="dtp-null">—</span>
<span v-else-if="type === 'currency'"
:class="['dtp-currency', Number(value) < 0 ? 'dtp-currency--neg' : Number(value) === 0 ? 'dtp-currency--zero' : '']"
>{{ fmtCurrency(value) }}</span>
<span v-else-if="type === 'number'"
class="dtp-number">{{ Number(value).toLocaleString('vi-VN') }}</span>
<span v-else-if="type === 'date'"
class="dtp-date">{{ value }}</span>
<span v-else-if="type === 'code'"
class="dtp-code">{{ value }}</span>
<span v-else-if="type === 'badge' && field.badgeMap"
:class="['dtp-badge', field.badgeColorMap?.[value] ?? '']"
>{{ field.badgeMap?.[value] ?? value }}</span>
<span v-else>{{ value }}</span>
`
}
</script>
<script setup>
import { ref, computed, reactive, watch } from "vue/dist/vue.esm-bundler.js"
import { defineComponent } from "vue"
import { useStore } from "~/stores/index"
const emit = defineEmits(["edit", "insert", "dataevent", "changedata", "changefilter", "displayDataChange"])
const {
$calc, $calculate, $calculateData, $copy, $deleterow, $empty,
$find, $getEditRights, $formatNumber, $multiSort, $remove, $stripHtml, $unique,
} = useNuxtApp()
const store = useStore()
const props = defineProps({ pagename: String })
// ── helpers ─────────────────────────────────────────────────────────────────
function dynamicComponent(html) {
return defineComponent({
template: html,
props: { row: { type: Object, default: () => ({}) } },
})
}
function detectType(value) {
if (typeof value === 'number') return 'number'
return 'string'
}
function resolveSubfields(field, rows) {
if (field.subfields) return field.subfields
if (!rows?.length) return []
return Object.keys(rows[0]).map(k => ({
name: k, label: k,
type: typeof rows[0][k] === 'number' ? 'number' : 'string',
}))
}
function hasSummableSubs(field, rows) {
if (!rows?.length) return false
return resolveSubfields(field, rows).some(sf => sf.type === 'currency' || sf.type === 'number')
}
function sumSubField(rows, name) {
return rows.reduce((s, r) => s + (Number(r[name]) || 0), 0)
}
function sumColumn(field) {
return (data || []).reduce((s, r) => s + (Number(r[field.name]) || 0), 0)
}
function isSorted(field) {
return filters?.some(f => f.name === field.name && f.sort)
}
function resolveSplitTemplate(template, item) {
return template.replace(/\{(\w+)\}/g, (_, key) => item[key] ?? '')
}
function evalSplitExpression(expression, item) {
try {
const expr = expression.replace(/\{(\w+)\}/g, (_, key) => {
const v = item[key]
return (v === null || v === undefined) ? 'null' : Number(v)
})
const exprFull = expr.replace(/\b([a-zA-Z_]\w*)\b/g, (match) => {
if (!(match in item)) return match
const v = item[match]
return (v === null || v === undefined) ? 'null' : Number(v)
})
return Function('"use strict"; return (' + exprFull + ')')()
} catch {
return null
}
}
// ── state ────────────────────────────────────────────────────────────────────
var timer
var showPaging = ref(false)
var totalRows = ref(0)
var currentPage = 1
var displayFields = ref([])
var displayData = ref([])
var pagedata = store[props.pagename]
var tablesetting = $copy(pagedata.tablesetting || store.tablesetting)
var perPage = Number($find(tablesetting, { code: 'per-page' }, 'detail')) || 20
var filters = $copy(pagedata.filters || [])
var currentField
var filterData = []
var currentRow
var data = $copy(pagedata.data)
var showmodal = ref()
const hasGroupHeaders = computed(() =>
displayFields.value.some(f => f.groupHeader)
)
const groupHeaderRow = computed(() => {
const fields = displayFields.value
if (!fields.length) return []
const result = []
let i = 0
while (i < fields.length) {
const f = fields[i]
if (!f.groupHeader) {
result.push({
label: f.label,
colspan: 1,
rowspan: 2,
isGroup: false,
style: f.headerStyle || '',
dropStyle: f.dropStyle || '',
field: f,
})
i++
} else {
let count = 1
while (i + count < fields.length && fields[i + count].groupHeader === f.groupHeader) {
count++
}
result.push({
label: f.groupHeader,
colspan: count,
rowspan: 1,
isGroup: true,
style: f.headerStyle || '',
})
i += count
}
}
return result
})
// ── summary ──────────────────────────────────────────────────────────────────
const showSummary = computed(() =>
pagedata?.showSummary !== false &&
displayFields.value.some(f => f.summary || f.type === 'currency')
)
function tdStyle(field, record) {
let val = getStyle(field, record)
if (field.split === 'stack' || field.split === 'columns') {
val = val.replace(/padding:[^;]+;?/g, '') + 'padding:0;vertical-align:top;'
}
return val
}
// ── watch ────────────────────────────────────────────────────────────────────
watch(() => store[props.pagename], () => { updateChange() })
function updateChange() {
pagedata = store[props.pagename]
if (!pagedata.update) return
if (pagedata.update.data) data = $copy(pagedata.update.data)
if (pagedata.update.filters) { doFilter(pagedata.update.filters); updateShow(); return }
if (filters.length > 0) doFilter(filters)
if (pagedata.update.fields || pagedata.update.data) updateShow()
}
const updateShow = function (full_data) {
const allowedFns = { '$getEditRights()': $getEditRights }
const arr = pagedata.fields.filter(({ show }) => {
if (typeof show === 'boolean') return show
if (show === 'true') return true
if (show === 'false') return false
return allowedFns[show]?.() || false
})
if (full_data === false) {
displayData.value = $copy(data)
} else {
displayData.value = $copy(
data.filter((_, idx) =>
idx >= (currentPage - 1) * perPage && idx < currentPage * perPage
)
)
}
displayData.value.forEach((v, idx) => {
v.__stt = (currentPage - 1) * perPage + idx + 1
})
arr.forEach(v => {
v.headerStyle = getSettingStyle('header', v)
v.dropStyle = getSettingStyle('dropdown', v)
})
displayFields.value = arr
showPagination()
}
function confirmRemove() {
$deleterow(pagedata.api.name, currentRow.id, props.pagename, true)
}
const clickEvent = (event, row, field) => {
const name = typeof event === 'string' ? event : event.name
const evdata = typeof event === 'string' ? event : event.data
if (name === 'remove') {
currentRow = row
showmodal.value = {
component: 'dialog/Confirm',
vbind: { content: 'Bạn 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, evdata)
}
const showField = async (field) => {
if (pagedata.contextMenu === false || field.menu === 'no') return
currentField = field
filterData = $unique(pagedata.data, [field.name])
showmodal.value = {
vbind: { pagename: props.pagename, field, filters, filterData, width: 100 },
component: 'datatable/ContextMenu',
title: field.name, width: '650px', height: '500px',
}
}
const getStyle = (field, record) => {
let val = (tablesetting.find(v => v.code === 'td-border')?.detail ?? '') + ';'
let stop = false
if (!Array.isArray(field.bgcolor) && field.bgcolor) {
val += ` background-color:${field.bgcolor};`
} else if (Array.isArray(field.bgcolor)) {
for (const v of field.bgcolor) {
if (stop) break
const match = v.type === 'search'
? record[field.name]?.toLowerCase().includes(v.keyword?.toLowerCase())
: $calculate(record, v.tags, v.expression)?.value
if (match) { val += ` background-color:${v.color};`; stop = true }
}
}
stop = false
if (!Array.isArray(field.color) && field.color) {
val += ` color:${field.color};`
} else if (Array.isArray(field.color)) {
for (const v of field.color) {
if (stop) break
if ($calculate(record, v.tags, v.expression)?.value) { val += ` color:${v.color};`; stop = true }
}
}
const fs = tablesetting.find(v => v.code === 'table-font-size')?.detail ?? '12'
val += ` font-size:${field.textsize ?? fs}px;`
if (field.textalign) val += ` text-align:${field.textalign};`
if (field.minwidth) val += ` min-width:${field.minwidth}px;`
if (field.maxwidth) val += ` max-width:${field.maxwidth}px;`
return val
}
const getSettingStyle = (name, field) => {
let val = ''
if (name === 'header') {
val += `background-color:${tablesetting.find(v => v.code === 'header-background')?.detail};`
if (field?.minwidth) val += ` min-width:${field.minwidth}px;`
if (field?.maxwidth) val += ` max-width:${field.maxwidth}px;`
} else if (name === 'dropdown') {
val += `font-size:${tablesetting.find(v => v.code === 'header-font-size')?.detail}px;`
val += filters.find(v => v.name === field?.name)
? `color:${tablesetting.find(v => v.code === 'header-filter-color')?.detail};`
: `color:${tablesetting.find(v => v.code === 'header-font-color')?.detail};`
}
return val
}
const changePage = (page) => { currentPage = page; updateShow() }
const showPagination = () => {
showPaging.value = pagedata.pagination !== false
totalRows.value = data.length
if (showPaging.value && pagedata.api) {
if (pagedata.api.full_data === false) totalRows.value = pagedata.api.total_rows
showPaging.value = totalRows.value > perPage
}
}
const close = () => { showmodal.value = undefined }
const frontendFilter = (newVal) => {
newVal = $copy(newVal)
let filtered = $copy(pagedata.data)
newVal.filter(m => m.select || m.filter).forEach(v => {
if (v.select) {
filtered = filtered.filter(x =>
v.select.findIndex(y => ($empty(y) ? $empty(x[v.name]) : y === x[v.name])) > -1
)
} else if (v.filter) {
filtered = filtered.filter(x => {
if ($empty(x[v.name])) return false
let text = ''
v.filter.forEach((y, k) => {
text += `${k > 0 ? (v.filter[k-1].operator === 'and' ? ' &&' : ' ||') : ''}
${$formatNumber(x[v.name])} ${y.condition === '=' ? '==' : y.condition === '<>' ? '!==' : y.condition} ${$formatNumber(y.value)}`
})
return $calc(text)
})
}
})
const sortList = filters.filter(x => x.sort)
if (!sortList.length) return filtered
const sort = {}, format = {}
sortList.forEach(v => { sort[v.name] = v.sort === 'az' ? 'asc' : 'desc'; format[v.name] = v.format })
return $multiSort(filtered, sort, format)
}
const doFilter = (newVal, nonset) => {
if (currentPage > 1 && nonset !== true) currentPage = 1
if (pagedata.api?.full_data) {
data = frontendFilter(newVal)
pagedata.dataFilter = $copy(data)
store.commit(props.pagename, pagedata)
emit('changedata', newVal)
}
pagedata.filters = newVal
store.commit(props.pagename, pagedata)
emit('changefilter', newVal?.length > 0)
}
const doSelect = (value) => {
showmodal.value = undefined
const field = currentField
let found = filters.find(v => v.name === field.name)
if (found) {
if (!found.select) found.select = []
const idx = found.select.findIndex(x => x === value)
idx >= 0 ? $remove(found.select, idx) : found.select.push(value)
if (found.select.length === 0) {
const i = filters.findIndex(v => v.name === field.name)
if (i >= 0) $remove(filters, i)
}
} else {
filters.push({ name: field.name, label: field.label, select: [value], format: field.format })
}
doFilter(filters)
updateShow()
}
const removeFilter = (i) => { $remove(filters, i); doFilter(filters); updateShow() }
const updateData = (newVal) => {
if (newVal.filters) filters = $copy(newVal.filters)
else if (pagedata.filters) filters = $copy(pagedata.filters)
if (newVal.data || newVal.fields || newVal.filters) doFilter(filters)
if (newVal.data || newVal.fields || newVal.filters || newVal.tablesetting) updateShow()
}
const doubleClick = (field, v) => { currentField = field; doSelect(v[field.name]) }
var tableStyle = getSettingStyle('table')
setTimeout(() => updateShow(), 200)
watch(displayData, (val) => {
emit('displayDataChange', toRaw(val))
})
</script>
<style scoped>
/* ── Group headers ─────────────────────────────────────────────── */
.dtp-th-group--label {
text-align: center;
font-weight: 700;
letter-spacing: 0.04em;
color:#ffffff;
border-bottom: 2px solid rgba(255,255,255,0.3) !important;
}
/* ── Hover (giữ nguyên) ───────────────────────────────────────── */
:deep(.table tbody tr:hover td, .table tbody tr:hover th) {
background-color: hsl(0, 0%, 78%);
color: rgb(0, 0, 0);
}
/* ── Stack split ──────────────────────────────────────────────── */
.dtp-td--stack { padding: 0 !important; vertical-align: top; }
.dtp-stack { display: flex; flex-direction: column; }
.dtp-stack-row {
padding: 4px 8px;
min-height: 24px;
display: flex;
align-items: center;
border-bottom: 1px solid #e8ecf0;
}
.dtp-stack-row:last-child { border-bottom: none; }
.dtp-stack-row--empty { color: #bbb; font-style: italic; }
.dtp-split-tpl { font-size: 11px; }
/* ── Columns split (mini-table) ───────────────────────────────── */
.dtp-td--columns { padding: 0 !important; vertical-align: top; }
.dtp-cols-empty { padding: 6px 10px; color: #bbb; font-style: italic; }
.dtp-minitable { width: 100%; border-collapse: collapse; }
.dtp-minitable thead th {
padding: 3px 8px;
border-right: 1px solid #dde2e8;
border-bottom: 1px solid #c8cdd3;
white-space: nowrap;
text-transform: uppercase;
}
.dtp-minitd {
padding: 4px 8px;
border-right: 1px solid #eef1f4;
border-bottom: 1px solid #eef1f4;
}
.dtp-minitd--right { text-align: right; }
.dtp-minitable tbody tr:last-child .dtp-minitd { border-bottom: none; }
.dtp-minifoot { font-weight: 700; border-top: 1px solid #c8cdd3 !important; padding: 4px 8px; }
/* ── Số âm / zero cho currency ────────────────────────────────── */
.dtp-currency--neg { color: #ef4444; }
.dtp-currency--zero { color: #b0b7c3; }
</style>