1140 lines
31 KiB
Vue
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("L");
|
|
}
|
|
|
|
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>
|