Files
web/app/components/datatable/PivotDataView.vue
2026-05-05 11:06:49 +07:00

1140 lines
31 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>