Initial commit
This commit is contained in:
800
app/components/report/Daily.vue
Normal file
800
app/components/report/Daily.vue
Normal file
@@ -0,0 +1,800 @@
|
||||
<template>
|
||||
<div class="report report-daily-page">
|
||||
<div class="report-header mb-4">
|
||||
<h2 class="header-report-title">Báo cáo tình hình giao dịch & thanh toán theo ngày</h2>
|
||||
<div class="select-date">
|
||||
<span class="label-date">Chọn ngày báo cáo:</span>
|
||||
<Datepicker v-bind="{ record: infoReport, attr: 'date-report' }" @date="select('date-report', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-body">
|
||||
<!-- Report Product -->
|
||||
<div class="report-product">
|
||||
<div class="fixed-grid has-6-cols-desktop has-3-cols-tablet has-2-cols-mobile">
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<p class="cell-label">Tổng số sản phẩm</p>
|
||||
<span class="cell-value">{{ totalProduct }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="cell event"
|
||||
v-for="product in saleStatusProductSummary"
|
||||
:style="{ color: product.color }"
|
||||
@click="handlerStatusProduct($event, product)"
|
||||
>
|
||||
<p class="cell-label">{{ product.name }}</p>
|
||||
<span class="cell-value">{{ product.count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-report-product">
|
||||
<div class="fixed-grid has-1-cols has-3-cols-desktop">
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<div class="chart-status-product">
|
||||
<client-only>
|
||||
<highcharts-vue-chart :highcharts="Highcharts" :options="chartProductStatusOptions" />
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="chart-zone-product">
|
||||
<client-only>
|
||||
<highcharts-vue-chart :highcharts="Highcharts" :options="chartProductZoneOptions" />
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="chart-product-dealer">
|
||||
<client-only>
|
||||
<highcharts-vue-chart :highcharts="Highcharts" :options="chartProductDealerOptions" />
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-cart-product">
|
||||
<client-only>
|
||||
<highcharts-vue-chart :highcharts="Highcharts" :options="chartStatusProductDealerOptions" />
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Report Porduct -->
|
||||
</div>
|
||||
<Modal @close="handlerCloseModal" v-bind="showModal" v-if="showModal.lock"> </Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import Highcharts from "highcharts";
|
||||
import { Chart as HighchartsVueChart } from "highcharts-vue";
|
||||
|
||||
import Datepicker from "~/components/datepicker/Datepicker";
|
||||
|
||||
const { $numtoString, $findapi, $getapi, $store, $getdata, $mode } = useNuxtApp();
|
||||
|
||||
const dealer = $store.dealer;
|
||||
const isVietnamese = computed(() => $store.lang.toLowerCase() === "vi");
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
let selectedChartPie = null;
|
||||
|
||||
const productsResponse = ref(null);
|
||||
const totalProduct = ref(0);
|
||||
|
||||
const infoReport = ref({
|
||||
"date-report": today,
|
||||
});
|
||||
|
||||
const showModal = ref({
|
||||
title: null,
|
||||
lock: false,
|
||||
});
|
||||
|
||||
const dataStatusProduct = ref({
|
||||
totalProduct: 0,
|
||||
totalValue: 0,
|
||||
statusCode: null,
|
||||
statusName: null,
|
||||
totalLandArea: 0,
|
||||
totalBuildingArea: 0,
|
||||
totalFloorArea: 0,
|
||||
listProduct: [],
|
||||
});
|
||||
|
||||
const listSaleStatus = await $getdata("salestatus");
|
||||
// const listZoneType = await $getdata("zonetype");
|
||||
|
||||
const select = (fieldName, value) => {
|
||||
infoReport.value[fieldName] = value;
|
||||
};
|
||||
|
||||
function buildDateRange(date) {
|
||||
if (!date) return null;
|
||||
const dateTemp = new Date(date).toISOString().split("T")[0];
|
||||
const start = new Date(`${dateTemp}T00:00:00.000Z`);
|
||||
const end = new Date(`${dateTemp}T23:59:59.999Z`);
|
||||
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const groupByField = (list, { keyField, nameField, nameTransform, colorField }) => {
|
||||
if (!Array.isArray(list) || list.length === 0) return [];
|
||||
|
||||
const map = new Map();
|
||||
|
||||
for (const item of list) {
|
||||
const key = item[keyField];
|
||||
if (!key) continue;
|
||||
|
||||
let group = map.get(key);
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
name: nameTransform ? nameTransform(item[nameField]) : nameField ? item[nameField] : key,
|
||||
color: colorField ? item[colorField] : null,
|
||||
count: 0,
|
||||
children: [],
|
||||
};
|
||||
map.set(key, group);
|
||||
}
|
||||
|
||||
group.count++;
|
||||
group.children.push(item);
|
||||
}
|
||||
|
||||
return [...map.values()];
|
||||
};
|
||||
|
||||
function buildGroupMap(list = []) {
|
||||
if (!Array.isArray(list)) return new Map();
|
||||
|
||||
return new Map(list.filter((item) => item?.key != null).map((item) => [item.key, item]));
|
||||
}
|
||||
|
||||
function mergeMasterWithGroup(masterList = [], groupMap) {
|
||||
if (!Array.isArray(masterList)) return [];
|
||||
|
||||
return masterList.map((item) => {
|
||||
const found = groupMap.get(item.code);
|
||||
|
||||
return {
|
||||
...item,
|
||||
count: found?.count ?? 0,
|
||||
children: found?.children ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const formatVniDate = (date) => {
|
||||
const dateTemp = date ? new Date(date) : new Date();
|
||||
const d = String(dateTemp.getDate()).padStart(2, "0");
|
||||
const m = String(dateTemp.getMonth() + 1).padStart(2, "0");
|
||||
const y = dateTemp.getFullYear();
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
function formatVND(amount, decimals = 2) {
|
||||
if (!amount || isNaN(amount)) return "0 đ";
|
||||
|
||||
if (amount >= 1_000_000_000) return `${(amount / 1_000_000_000).toFixed(decimals)} tỷ`;
|
||||
|
||||
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(decimals)} triệu`;
|
||||
|
||||
return `${amount.toLocaleString("vi-VN")} đ`;
|
||||
}
|
||||
|
||||
function colorStatus(value) {
|
||||
if (value > 100) {
|
||||
return "#006400";
|
||||
}
|
||||
|
||||
if (value >= 90 && value <= 100) {
|
||||
return "#2ECC71";
|
||||
}
|
||||
|
||||
if (value >= 75 && value < 90) {
|
||||
return "#F1C40F";
|
||||
}
|
||||
|
||||
if (value >= 60 && value < 75) {
|
||||
return "#E67E22";
|
||||
}
|
||||
|
||||
if (value < 60) {
|
||||
return "#E74C3C";
|
||||
}
|
||||
|
||||
return "#BDC3C7";
|
||||
}
|
||||
|
||||
function calcPercentage(part, whole, decimals = 1) {
|
||||
const numerator = Number(part) || 0;
|
||||
const denominator = Number(whole) || 0;
|
||||
|
||||
if (denominator === 0) return 0;
|
||||
|
||||
return Number(((numerator / denominator) * 100).toFixed(decimals));
|
||||
}
|
||||
|
||||
function buildChartSeriesByStatus(statusList = [], parents = [], keyGroup = "status") {
|
||||
const parentStatusMaps = parents.map(
|
||||
(parent) => new Map((parent[keyGroup] ?? []).map((st) => [st.key ?? st.code, st.count ?? 0]))
|
||||
);
|
||||
|
||||
return statusList.map((status) => ({
|
||||
name: status.name?.split(". ")[1] ?? status.name,
|
||||
color: status.color || undefined,
|
||||
data: parentStatusMaps.map((map) => ({
|
||||
y: map.get(status.code) ?? 0,
|
||||
key: status.code,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
let scrollPosition = 0;
|
||||
|
||||
const toggleBodyScroll = (isActive) => {
|
||||
if (isActive) {
|
||||
scrollPosition = window.scrollY || document.documentElement.scrollTop;
|
||||
document.body.classList.add("popup-open");
|
||||
document.body.style.top = `-${scrollPosition}px`;
|
||||
} else {
|
||||
document.body.classList.remove("popup-open");
|
||||
document.body.style.top = "";
|
||||
window.scrollTo(0, scrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// <====== Fetch product list ======>
|
||||
let foundProduct = $findapi("product");
|
||||
foundProduct.params.values =
|
||||
"id,code,trade_code,zone_code,type__code,type__name,zone_type__code,zone_type__code,zone_type__name,direction__code,direction__name,land_lot_size,lot_area,building_area,number_of_floors,cart__code,cart__name,cart__dealer__code,cart__dealer__name,origin_price,txnprd__sale_price,status__code,status__name,status__color,status__sale_status__code,status__sale_status__name,status__sale_status__color";
|
||||
if (dealer?.code) {
|
||||
foundProduct.params.filter = { cart__dealer__code: dealer?.code };
|
||||
foundProduct.params.exclude = { status__sale_status__code: "not-sold" };
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
try {
|
||||
const [productRes] = await $getapi([foundProduct]);
|
||||
productsResponse.value = productRes?.data?.rows ?? [];
|
||||
totalProduct.value = productsResponse.value?.length;
|
||||
} catch (error) {
|
||||
if ($mode === "dev") {
|
||||
console.error("Call api product error", error);
|
||||
}
|
||||
productsResponse.value = [];
|
||||
totalProduct.value = 0;
|
||||
}
|
||||
}
|
||||
fetchProducts();
|
||||
// <====== End fetch product list ======>
|
||||
|
||||
// <====== Group status product ======>
|
||||
const productGroupStatus = computed(() =>
|
||||
groupByField(productsResponse.value, {
|
||||
keyField: "status__sale_status__code",
|
||||
nameField: "status__sale_status__name",
|
||||
nameTransform: (name) => name?.split(". ")[1] ?? name,
|
||||
colorField: "status__sale_status__color",
|
||||
})
|
||||
);
|
||||
|
||||
const productGroupStatusMap = computed(() => buildGroupMap(productGroupStatus.value));
|
||||
|
||||
const saleStatusProductSummary = computed(() => {
|
||||
const merged = mergeMasterWithGroup(listSaleStatus || [], productGroupStatusMap.value);
|
||||
|
||||
return merged.map((item) => {
|
||||
const name = item.name?.split(". ")[1] ?? item.name;
|
||||
return {
|
||||
...item,
|
||||
name,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const handlerStatusProduct = (e, data) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
updateProductStatusSummary(data);
|
||||
showModal.value = {
|
||||
component: "report/TableReportProduct",
|
||||
lock: true,
|
||||
title: `Danh sách sản phẩm - Trạng thái: ${data.name} (${formatVniDate(infoReport["date-date-report"])})`,
|
||||
width: "80%",
|
||||
height: "80%",
|
||||
vbind: { data: dataStatusProduct.value },
|
||||
};
|
||||
handlerOpenModal();
|
||||
};
|
||||
|
||||
function updateProductStatusSummary(statusData = {}) {
|
||||
const listProduct = (statusData.children || []).map((item) => {
|
||||
const buildingArea = Number(item.building_area ?? 0);
|
||||
const floors = Number(item.number_of_floors ?? 0);
|
||||
const totalBuildingArea = buildingArea * floors;
|
||||
const total_built_area = $numtoString(totalBuildingArea);
|
||||
item.txnprd__sale_price = item.txnprd__sale_price ?? item.origin_price;
|
||||
return {
|
||||
...item,
|
||||
total_built_area,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = listProduct.reduce(
|
||||
(acc, product) => {
|
||||
const salePrice = Number(product.txnprd__sale_price ?? 0);
|
||||
const landArea = Number(product.lot_area ?? 0);
|
||||
const buildingArea = Number(product.building_area ?? 0);
|
||||
const floors = Number(product.number_of_floors ?? 0);
|
||||
|
||||
acc.totalValue += salePrice;
|
||||
acc.totalLandArea += landArea;
|
||||
acc.totalBuildingArea += buildingArea;
|
||||
acc.totalFloorArea += buildingArea * floors;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalValue: 0,
|
||||
totalLandArea: 0,
|
||||
totalBuildingArea: 0,
|
||||
totalFloorArea: 0,
|
||||
}
|
||||
);
|
||||
|
||||
dataStatusProduct.value = {
|
||||
statusCode: statusData.code || statusData.key,
|
||||
statusName: statusData.name ?? null,
|
||||
totalProduct: statusData.count ?? 0,
|
||||
totalValue: totals.totalValue,
|
||||
totalLandArea: $numtoString(totals.totalLandArea),
|
||||
totalBuildingArea: $numtoString(totals.totalBuildingArea),
|
||||
totalFloorArea: $numtoString(totals.totalFloorArea),
|
||||
listProduct: listProduct,
|
||||
};
|
||||
}
|
||||
|
||||
// Options chart status product
|
||||
const chartSelectedStatusProductHandler = () => ({
|
||||
select() {
|
||||
selectedChartPie = this;
|
||||
const item = productGroupStatus.value.find((i) => i.key === this.options.key);
|
||||
updateProductStatusSummary(item);
|
||||
|
||||
showModal.value = {
|
||||
component: "report/TableReportProduct",
|
||||
lock: true,
|
||||
title: `Danh sách sản phẩm - Trạng thái: ${this.options.name} (${formatVniDate(infoReport["date-date-report"])})`,
|
||||
width: "80%",
|
||||
height: "80%",
|
||||
vbind: { data: dataStatusProduct.value },
|
||||
};
|
||||
handlerOpenModal();
|
||||
},
|
||||
|
||||
unselect() {
|
||||
if (selectedChartPie !== this) return;
|
||||
selectedChartPie = null;
|
||||
updateProductStatusSummary();
|
||||
},
|
||||
});
|
||||
|
||||
const chartProductStatusOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: "pie",
|
||||
zooming: {
|
||||
type: "xy",
|
||||
},
|
||||
panning: {
|
||||
enabled: true,
|
||||
type: "xy",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: isVietnamese ? "Trạng thái sản phẩm" : "Product status",
|
||||
},
|
||||
subtitle: {
|
||||
text: `Dữ liệu ngày: ${formatVniDate(infoReport.value["date-report"])} - ${totalProduct.value} ${
|
||||
isVietnamese ? "Sản phẩm" : "Products"
|
||||
}`,
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
allowPointSelect: true,
|
||||
cursor: "pointer",
|
||||
dataLabels: [
|
||||
{
|
||||
enabled: true,
|
||||
distance: 20,
|
||||
format: "{point.name}: {point.y:,.0f}", // GIÁ TRỊ
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
distance: -40,
|
||||
format: "({point.percentage:.1f}%)", // GIÁ TRỊ
|
||||
style: {
|
||||
fontSize: "0.75em",
|
||||
textOutline: "none",
|
||||
opacity: 0.7,
|
||||
},
|
||||
filter: {
|
||||
operator: ">",
|
||||
property: "percentage",
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
point: {
|
||||
events: chartSelectedStatusProductHandler(),
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: isVietnamese ? "Số lượng" : "Quantity",
|
||||
colorByPoint: true,
|
||||
data: productGroupStatus.value.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
y: item.count,
|
||||
key: item.key,
|
||||
color: item.color,
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// <====== End group status product ======>
|
||||
|
||||
// <====== Group zone product ======>
|
||||
const productGroupZone = computed(() =>
|
||||
groupByField(productsResponse.value, {
|
||||
keyField: "zone_type__code",
|
||||
nameField: "zone_type__name",
|
||||
})
|
||||
);
|
||||
|
||||
// Options chart zone product
|
||||
const chartProductZoneOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: "pie",
|
||||
zooming: {
|
||||
type: "xy",
|
||||
},
|
||||
panning: {
|
||||
enabled: true,
|
||||
type: "xy",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: isVietnamese ? "Sản phẩm theo phân khu" : "Products by category",
|
||||
},
|
||||
subtitle: {
|
||||
text: `Dữ liệu ngày: ${formatVniDate(infoReport.value["date-report"])} - ${productGroupZone.value.length} ${
|
||||
isVietnamese ? "Phân khu" : "Category"
|
||||
}`,
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
cursor: "default",
|
||||
dataLabels: [
|
||||
{
|
||||
enabled: true,
|
||||
distance: 20,
|
||||
format: "{point.name}: {point.y:,.0f}", // GIÁ TRỊ
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
distance: -40,
|
||||
format: "({point.percentage:.1f}%)", // GIÁ TRỊ
|
||||
style: {
|
||||
fontSize: "0.75em",
|
||||
textOutline: "none",
|
||||
opacity: 0.7,
|
||||
},
|
||||
filter: {
|
||||
operator: ">",
|
||||
property: "percentage",
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: isVietnamese ? "Số lượng" : "Quantity",
|
||||
colorByPoint: true,
|
||||
data: productGroupZone.value.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
y: item.count,
|
||||
key: item.key,
|
||||
color: item.color,
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// <====== End group zone product ======>
|
||||
|
||||
// <====== Group dealer product ======>
|
||||
const productGroupDealer = computed(() =>
|
||||
groupByField(productsResponse.value, {
|
||||
keyField: "cart__dealer__code",
|
||||
nameField: "cart__dealer__name",
|
||||
})
|
||||
);
|
||||
// Options chart product dealer
|
||||
const chartProductDealerOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: "pie",
|
||||
zooming: {
|
||||
type: "xy",
|
||||
},
|
||||
panning: {
|
||||
enabled: true,
|
||||
type: "xy",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: isVietnamese ? "Sản phẩm theo đại lý" : "Products by distributor",
|
||||
},
|
||||
subtitle: {
|
||||
text: `Dữ liệu ngày: ${formatVniDate(infoReport.value["date-report"])} - ${productGroupDealer.value?.length} ${
|
||||
isVietnamese ? "Đại lý" : "Dealer"
|
||||
}`,
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
cursor: "default",
|
||||
dataLabels: [
|
||||
{
|
||||
enabled: true,
|
||||
distance: 20,
|
||||
format: "{point.name}: {point.y:,.0f}", // GIÁ TRỊ
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
distance: -40,
|
||||
format: "({point.percentage:.1f}%)", // GIÁ TRỊ
|
||||
style: {
|
||||
fontSize: "0.75em",
|
||||
textOutline: "none",
|
||||
opacity: 0.7,
|
||||
},
|
||||
filter: {
|
||||
operator: ">",
|
||||
property: "percentage",
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: isVietnamese ? "Số lượng" : "Quantity",
|
||||
colorByPoint: true,
|
||||
data:
|
||||
productGroupDealer.value?.map((item) => {
|
||||
return {
|
||||
name: item.key,
|
||||
y: item.count,
|
||||
key: item.key,
|
||||
color: item.color ?? null,
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
//Options chart status product dealer
|
||||
const dealerProductSaleSummary = computed(() =>
|
||||
(productGroupDealer.value ?? []).map((dealer) => {
|
||||
const productSaleByStatus = groupByField(dealer.children ?? [], {
|
||||
keyField: "status__sale_status__code",
|
||||
nameField: "status__sale_status__name",
|
||||
nameTransform: (name) => name?.split(". ")[1] ?? name,
|
||||
colorField: "status__sale_status__color",
|
||||
});
|
||||
|
||||
return {
|
||||
...dealer,
|
||||
productSaleByStatus,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const dataChartTransactionPolicy = computed(() => {
|
||||
const listSaleStatusDealer = listSaleStatus.filter((status) => status.code !== "not-sold");
|
||||
return buildChartSeriesByStatus(listSaleStatusDealer, dealerProductSaleSummary.value, "productSaleByStatus");
|
||||
});
|
||||
|
||||
const createDealerStatusClickHandler = () => ({
|
||||
click() {
|
||||
const dealerCode = this.category?.split(" - ")?.[0] ?? null;
|
||||
const saleStatusCode = this.options?.key ?? null;
|
||||
|
||||
if (!dealerCode || !saleStatusCode) return;
|
||||
|
||||
const dealerSummary = dealerProductSaleSummary.value?.find((dealer) => dealer.key === dealerCode);
|
||||
|
||||
const productsByStatus = dealerSummary?.productSaleByStatus?.find((status) => status.key === saleStatusCode);
|
||||
|
||||
updateProductStatusSummary(productsByStatus);
|
||||
showModal.value = {
|
||||
component: "report/TableReportProduct",
|
||||
lock: true,
|
||||
title: `Danh sách sản phẩm - Đại lý: ${dealerSummary.key || dealerSummary.code} - Trạng thái: ${
|
||||
productsByStatus.name
|
||||
} (${formatVniDate(infoReport["date-date-report"])})`,
|
||||
width: "80%",
|
||||
height: "80%",
|
||||
vbind: { data: dataStatusProduct.value },
|
||||
};
|
||||
handlerOpenModal();
|
||||
},
|
||||
});
|
||||
|
||||
const chartStatusProductDealerOptions = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: "column",
|
||||
},
|
||||
title: {
|
||||
text: isVietnamese ? "Trạng thái sản phẩm theo từng đại lý" : "Product status by dealer",
|
||||
},
|
||||
subtitle: {
|
||||
text: ` Dữ liệu ngày: ${formatVniDate(infoReport.value["date-report"])} `,
|
||||
},
|
||||
xAxis: {
|
||||
categories: dealerProductSaleSummary.value?.map((dealer) => `${dealer.key} - ${dealer.count} SP`),
|
||||
crosshair: true,
|
||||
accessibility: {
|
||||
description: "Countries",
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: {
|
||||
text: "Số lượng",
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
headerFormat: "<b>{category}</b><br/>",
|
||||
pointFormat: "{series.name}: {point.y} Sản phẩm",
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
cursor: "pointer",
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0,
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: "{point.y}",
|
||||
},
|
||||
point: {
|
||||
events: createDealerStatusClickHandler(),
|
||||
},
|
||||
},
|
||||
},
|
||||
series: dataChartTransactionPolicy.value,
|
||||
};
|
||||
});
|
||||
|
||||
// <====== End group dealer product ======>
|
||||
|
||||
const handleUnSelectChart = () => {
|
||||
const chart = Highcharts.charts.find((c) => c);
|
||||
if (chart) {
|
||||
chart.series[0]?.points.forEach((p) => {
|
||||
if (p.selected) p.select(false, false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlerOpenModal = () => {
|
||||
toggleBodyScroll(true);
|
||||
};
|
||||
|
||||
const handlerCloseModal = () => {
|
||||
toggleBodyScroll(false);
|
||||
showModal.value = {
|
||||
lock: false,
|
||||
};
|
||||
handleUnSelectChart();
|
||||
};
|
||||
|
||||
// Reload data change
|
||||
watch(
|
||||
infoReport,
|
||||
(newVal) => {
|
||||
// foundCustomer.params.filter.create_time__lte = buildDateRange(infoReport.value["date-report"])?.end;
|
||||
// foundCustomerTransaction.params.filter.txncust__create_time__lte = buildDateRange(
|
||||
// infoReport.value["date-report"]
|
||||
// )?.end;
|
||||
// fetchCustomer();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body.popup-open {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-daily-page .report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.report-daily-page .report-header .header-report-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.report-daily-page .report-header .select-date {
|
||||
background-color: #204853;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.report-daily-page .report-header .select-date .label-date {
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
.report-daily-page .cell.event {
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.report-daily-page .cell.event:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.report-daily-page .cell .cell-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.report-daily-page .cell .cell-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.report-daily-page .modal-card {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 767px) {
|
||||
.report-daily-page .report-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1197
app/components/report/FromTo.vue
Normal file
1197
app/components/report/FromTo.vue
Normal file
File diff suppressed because it is too large
Load Diff
23
app/components/report/Monthly.vue
Normal file
23
app/components/report/Monthly.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="report report-monthly-page">
|
||||
<div class="header-report mb-2">
|
||||
<h2 class="header-report-title">Báo cáo tình hình giao dịch & thanh toán theo tháng</h2>
|
||||
<div class="select-date">
|
||||
<span class="label-date">Từ ngày:</span>
|
||||
<Datepicker v-bind="{ record: dateReport, attr: 'date-start' }" @date="select('date-start', $event)" />
|
||||
<span class="label-date">đến ngày:</span>
|
||||
<Datepicker v-bind="{ record: dateReport, attr: 'date-end' }" @date="select('date-end', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
</script>
|
||||
<style>
|
||||
.report-monthly-page .header-report .header-report-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bolder;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
105
app/components/report/TableReportProduct.vue
Normal file
105
app/components/report/TableReportProduct.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="table-report-product">
|
||||
<div class="fixed-grid has-2-cols has-5-cols-desktop">
|
||||
<div class="grid summary-metrics">
|
||||
<div class="cell metric-item">
|
||||
<p class="metric-label">Tổng sản phẩm</p>
|
||||
<span class="metric-value">{{ props.data.totalProduct }}</span>
|
||||
</div>
|
||||
<div class="cell metric-item">
|
||||
<p class="metric-label">Tổng giá trị</p>
|
||||
<span class="metric-value">{{ formatVND(props.data.totalValue) }}</span>
|
||||
</div>
|
||||
<div class="cell metric-item">
|
||||
<p class="metric-label">Tổng diện tích đất</p>
|
||||
<span class="metric-value">{{ props.data.totalLandArea }} m²</span>
|
||||
</div>
|
||||
<div class="cell metric-item">
|
||||
<p class="metric-label">Tổng diện tích xây dựng</p>
|
||||
<span class="metric-value"> {{ props.data.totalBuildingArea }} m² </span>
|
||||
</div>
|
||||
<div class="cell metric-item">
|
||||
<p class="metric-label">Tổng diện tích sàn</p>
|
||||
<span class="metric-value">{{ props.data.totalFloorArea }} m²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataView v-bind="configurationDataView" v-if="configurationDataView && props.data.listProduct.length > 0" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
data: Object,
|
||||
});
|
||||
|
||||
function formatVND(amount, maxDecimals = 2) {
|
||||
if (!Number.isFinite(amount)) return "0 đ";
|
||||
|
||||
const format = (value) => {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(maxDecimals).replace(/\.?0+$/, "");
|
||||
};
|
||||
|
||||
if (amount >= 1_000_000_000) return `${format(amount / 1_000_000_000)} tỷ`;
|
||||
|
||||
if (amount >= 1_000_000) return `${format(amount / 1_000_000)} triệu`;
|
||||
|
||||
return `${amount.toLocaleString("vi-VN")} đ`;
|
||||
}
|
||||
|
||||
const configurationDataView = ref({
|
||||
data: props.data.listProduct,
|
||||
setting: "product-list-report",
|
||||
pagename: "productListReport",
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.table-report-product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-report-product .summary-metrics {
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.table-report-product .summary-metrics .metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.table-report-product .summary-metrics .metric-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-report-product .summary-metrics .metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.table-report-product .table-container {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-report-product .table-container .table {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-report-product .table-container .table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
8
app/components/report/Test.vue
Normal file
8
app/components/report/Test.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
Hello
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user