Initial commit

This commit is contained in:
Viet An
2026-03-02 09:45:33 +07:00
commit d17a9e2588
415 changed files with 92113 additions and 0 deletions

View 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> &nbsp;
<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>

File diff suppressed because it is too large Load Diff

View 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> &nbsp;
<Datepicker v-bind="{ record: dateReport, attr: 'date-start' }" @date="select('date-start', $event)" />&nbsp;
<span class="label-date">đến ngày:</span> &nbsp;
<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>

View 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 }} </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 }} </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 }} </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>

View File

@@ -0,0 +1,8 @@
<template>
<div>
Hello
</div>
</template>
<script setup>
</script>