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

644 lines
19 KiB
Vue

<template>
<div class="p-3">
<!-- TimeOption Filter -->
<TimeOption
v-bind="{
pagename: 'debt_report',
api: 'transaction',
timeopt: { time: 36000, disable: ['add'] },
filter: { phase: 3 },
}"
@option="handleTimeOption"
@excel="exportExcel"
@refresh-data="loadData"
class="mb-3"
/>
<!-- Loading -->
<div
v-if="loading"
class="has-text-centered py-6"
>
<p class="has-text-grey mb-3">Đang tải dữ liệu...</p>
<progress
class="progress is-small is-primary"
max="100"
></progress>
</div>
<div
class=""
v-else
>
<!-- Table -->
<div
v-if="filteredRows.length > 0"
class="table-container"
style="overflow-x: auto; max-width: 100%"
>
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth debt-table">
<thead>
<tr>
<!-- Fixed columns (sticky left) -->
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white has-text-centered"
>
STT
</th>
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white"
>
Mã KH
</th>
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white"
>
Mã Căn
</th>
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white"
>
Ngày ký HĐ
</th>
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white"
>
Giá trị HĐMB
</th>
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white"
>
Tiền nộp theo HĐV/TTTHNV
</th>
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white"
>
Tỷ lệ
</th>
<!-- Scrollable columns -->
<th
v-for="(sch, si) in scheduleHeaders"
:key="si"
:colspan="6"
class="has-text-centered has-background-primary has-text-white"
>
{{ sch.label }}
</th>
<th
rowspan="2"
class="has-background-primary has-text-white"
>
Số tiền quá hạn
</th>
</tr>
<tr>
<template
v-for="(sch, si) in scheduleHeaders"
:key="si"
>
<th class="has-background-primary has-text-white sub-header">Ngày</th>
<th class="has-background-primary has-text-white sub-header">Số tiền</th>
<th class="has-background-primary has-text-white sub-header">Lũy kế sang đợt</th>
<th class="has-background-primary has-text-white sub-header">Số tiền đã thực thanh toán</th>
<th class="has-background-primary has-text-white sub-header">Tỷ lệ</th>
<th class="has-background-primary has-text-white sub-header-remain">Dư nợ</th>
</template>
</tr>
</thead>
<tbody>
<tr
v-for="(row, ri) in filteredRows"
:key="ri"
>
<!-- Fixed columns -->
<td class="fixed-col has-text-centered">{{ ri + 1 }}</td>
<td class="fixed-col">{{ row.customer_code }}</td>
<td class="fixed-col has-text-weight-semibold has-text-primary">
{{ row.trade_code }}
</td>
<td class="fixed-col">{{ row.contract_date }}</td>
<td class="fixed-col has-text-right">
{{ fmt(row.sale_price) }}
</td>
<td class="fixed-col has-text-right has-text-weight-semibold has-background-warning-light">
{{ fmt(row.ttthnv_paid) }}
</td>
<td class="fixed-col has-text-right has-background-success-light">
{{ pct(row.ttthnv_paid, row.sale_price) }}
</td>
<!-- Scrollable columns -->
<template
v-for="(sch, si) in scheduleHeaders"
:key="si"
>
<template v-if="row.schedules[si]">
<td>{{ row.schedules[si].to_date }}</td>
<td class="has-text-right">
{{ fmt(row.schedules[si].amount) }}
</td>
<td class="has-text-right has-text-info">
{{ fmt(row.schedules[si].luy_ke_sang_dot) }}
</td>
<td class="has-text-right has-text-weight-semibold has-text-success">
{{ fmt(row.schedules[si].thuc_thanh_toan) }}
</td>
<td
class="has-text-right"
:class="pctClass(row.schedules[si].thuc_thanh_toan, row.schedules[si].amount)"
>
{{ pct(row.schedules[si].thuc_thanh_toan, row.schedules[si].amount) }}
</td>
<td
class="has-text-right has-text-weight-semibold"
:class="Number(row.schedules[si].amount_remain) > 0 ? 'has-text-danger' : 'has-text-success'"
>
{{ fmt(row.schedules[si].amount_remain) }}
</td>
</template>
<template v-else>
<td
colspan="6"
class="has-text-centered has-text-grey-light"
style="font-style: italic"
>
</td>
</template>
</template>
<td
class="has-text-right has-text-weight-bold"
:class="Number(row.overdue) > 0 ? 'has-background-danger-light' : ''"
>
{{ Number(row.overdue) > 0 ? fmt(row.overdue) : "—" }}
</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<td
:colspan="7"
class="fixed-col has-text-right has-text-weight-bold"
>
TỔNG CỘNG:
</td>
<template
v-for="(sch, si) in scheduleHeaders"
:key="si"
>
<td></td>
<td class="has-text-right has-text-weight-semibold">
{{ fmt(colSum(si, "amount")) }}
</td>
<td></td>
<td class="has-text-right has-text-weight-semibold has-text-success">
{{ fmt(colSum(si, "thuc_thanh_toan")) }}
</td>
<td></td>
<td class="has-text-right has-text-weight-semibold has-text-danger">
{{ fmt(colSum(si, "amount_remain")) }}
</td>
</template>
<td class="has-text-right has-text-weight-bold has-text-danger">
{{ fmt(totalOverdue) }}
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Empty -->
<div
v-else
class="has-text-centered py-6"
>
<p class="has-text-grey">Không có dữ liệu</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import TimeOption from "~/components/datatable/TimeOption";
const { $findapi, $getapi, $dayjs, $copy } = useNuxtApp();
const loading = ref(false);
const rows = ref([]);
const filteredRows = ref([]);
const scheduleHeaders = ref([]);
const currentFilter = ref(null);
const currentSearch = ref(null);
function handleTimeOption(option) {
if (!option) {
currentFilter.value = null;
currentSearch.value = null;
applyFilters();
return;
}
if (option.filter) {
currentFilter.value = option.filter;
currentSearch.value = null;
applyFilters();
} else if (option.filter_or) {
currentFilter.value = null;
currentSearch.value = option.filter_or;
applyFilters();
}
}
function applyFilters() {
let filtered = [...rows.value];
if (currentFilter.value && currentFilter.value.create_time__date__gte) {
const filterDate = new Date(currentFilter.value.create_time__date__gte);
filtered = filtered.filter((row) => {
const contractDate = row.contract_date_raw ? new Date(row.contract_date_raw) : null;
return contractDate && contractDate >= filterDate;
});
}
if (currentSearch.value) {
const searchTerms = Object.values(currentSearch.value).map((v) =>
String(v).toLowerCase().replace("__icontains", ""),
);
filtered = filtered.filter((row) => {
const searchableText = [
row.customer_code,
row.customer_name,
row.trade_code,
row.contract_date,
String(row.sale_price),
]
.join(" ")
.toLowerCase();
return searchTerms.some((term) => searchableText.includes(term));
});
}
filteredRows.value = filtered;
}
async function loadData() {
loading.value = true;
rows.value = [];
filteredRows.value = [];
scheduleHeaders.value = [];
try {
const txnConn = $copy($findapi("transaction"));
txnConn.params = {
filter: { phase: 3 },
values:
"id,code,date,customer,customer__code,customer__fullname,sale_price,amount_received,amount_remain,product,product__trade_code,phase",
sort: "id",
};
const detailConn = $copy($findapi("reservation"));
detailConn.params = {
filter: { transaction__phase: 3, phase: 3 },
values: "id,transaction,phase,amount,amount_received,amount_remaining,status",
sort: "transaction",
};
const schConn = $copy($findapi("payment_schedule"));
schConn.params = {
filter: { txn_detail__phase: 3 },
values:
"id,code,cycle,to_date,from_date,amount,paid_amount,amount_remain,remain_amount,status,status__name,txn_detail,txn_detail__transaction,txn_detail__phase,txn_detail__amount_received",
sort: "txn_detail__transaction,cycle",
};
const ttthnvConn = $copy($findapi("reservation"));
ttthnvConn.params = {
filter: { transaction__phase: 3, phase: 4 },
values: "id,transaction,phase,amount,amount_received,amount_remaining,status",
sort: "transaction",
};
const [txnRs, detailRs, schRs, ttthnvRs] = await $getapi([txnConn, detailConn, schConn, ttthnvConn]);
const transactions = txnRs?.data?.rows || [];
const details = detailRs?.data?.rows || [];
const schedules = schRs?.data?.rows || [];
const ttthnvList = ttthnvRs?.data?.rows || [];
if (!transactions.length) {
loading.value = false;
return;
}
// TTTHNV map
const ttthnvMap = {};
ttthnvList.forEach((t) => {
const tid = t.transaction;
ttthnvMap[tid] = (ttthnvMap[tid] || 0) + Number(t.amount_received || 0);
});
// Group schedules by transaction
const schByTxn = {};
schedules.forEach((s) => {
const tid = s.txn_detail__transaction;
if (!schByTxn[tid]) schByTxn[tid] = [];
schByTxn[tid].push(s);
});
// Tìm số đợt tối đa
let maxCycles = 0;
Object.values(schByTxn).forEach((list) => {
const paymentList = list.filter((s) => Number(s.cycle) > 0);
if (paymentList.length > maxCycles) maxCycles = paymentList.length;
});
scheduleHeaders.value = Array.from({ length: maxCycles }, (_, i) => ({
label: `L0${i + 1}`,
index: i,
}));
rows.value = transactions.map((txn) => {
const txnSchedules = (schByTxn[txn.id] || [])
.filter((s) => Number(s.cycle) > 0)
.sort((a, b) => Number(a.cycle) - Number(b.cycle));
const ttthnvPaid = ttthnvMap[txn.id] || 0;
const salePriceNum = Number(txn.sale_price || 0);
// ───────────────────────────────────────────────
// QUAN TRỌNG: Theo yêu cầu mới nhất của bạn
// Lũy kế HĐCN = TTTHNV
const luyKe = ttthnvPaid;
// ───────────────────────────────────────────────
// Phân bổ TTTHNV dần vào từng đợt → tính lũy kế sang đợt
let remainingTTTHNV = ttthnvPaid;
const schedulesWithCalc = txnSchedules.map((sch) => {
const scheduleAmount = Number(sch.amount || 0);
// Lũy kế sang đợt = min(remaining TTTHNV, số tiền đợt)
const luyKeSangDot = Math.min(remainingTTTHNV, scheduleAmount);
// Số tiền đã thực thanh toán = paid_amount - lũy kế sang đợt
const paidAmountFromSchedule = Number(sch.paid_amount || 0);
const thucThanhToan = Math.max(0, paidAmountFromSchedule - luyKeSangDot);
// Dư nợ = số tiền đợt - lũy kế sang đợt - thực thanh toán
const amountRemain = Math.max(0, scheduleAmount - luyKeSangDot - thucThanhToan);
remainingTTTHNV -= luyKeSangDot;
remainingTTTHNV = Math.max(0, remainingTTTHNV);
return {
to_date: sch.to_date ? $dayjs(sch.to_date).format("DD/MM/YYYY") : "—",
amount: scheduleAmount,
luy_ke_sang_dot: luyKeSangDot,
thuc_thanh_toan: thucThanhToan,
amount_remain: amountRemain,
status: sch.status,
};
});
// Tính quá hạn
const todayDate = new Date();
const overdue = txnSchedules.reduce((sum, sch, idx) => {
const toDate = sch.to_date ? new Date(sch.to_date) : null;
const remain = schedulesWithCalc[idx]?.amount_remain || 0;
if (toDate && toDate < todayDate && remain > 0) {
return sum + remain;
}
return sum;
}, 0);
const paddedSchedules = Array.from({ length: maxCycles }, (_, i) => schedulesWithCalc[i] || null);
return {
customer_code: txn.customer__code || "",
customer_name: txn.customer__fullname || "",
trade_code: txn.product__trade_code || txn.code || "",
contract_date: txn.date ? $dayjs(txn.date).format("DD/MM/YYYY") : "—",
contract_date_raw: txn.date,
sale_price: salePriceNum,
ttthnv_paid: ttthnvPaid,
luy_ke: luyKe, // ← chính là TTTHNV
schedules: paddedSchedules,
overdue: overdue,
};
});
filteredRows.value = rows.value;
} catch (e) {
console.error("BaoCaoCongNo error:", e);
} finally {
loading.value = false;
}
}
function fmt(val) {
const n = Number(val);
if (isNaN(n) || (!n && n !== 0)) return "—";
return n.toLocaleString("vi-VN");
}
function pct(num, denom) {
const n = Number(num);
const d = Number(denom);
if (!d || isNaN(n)) return "—";
return ((n / d) * 100).toFixed(1) + "%";
}
function pctClass(paid, amount) {
const p = Number(paid);
const a = Number(amount);
if (isNaN(p) || isNaN(a) || !a) return "";
const ratio = p / a;
if (ratio >= 1) return "has-text-success";
if (ratio >= 0.5) return "has-text-info";
return "has-text-danger";
}
function colSum(scheduleIndex, field) {
return filteredRows.value.reduce((sum, row) => {
const sch = row.schedules[scheduleIndex];
return sum + (sch ? Number(sch[field] || 0) : 0);
}, 0);
}
const totalOverdue = computed(() => filteredRows.value.reduce((s, r) => s + Number(r.overdue || 0), 0));
function exportExcel() {
const headers = [
"STT",
"Mã KH",
"Mã Căn",
"Ngày ký HĐ",
"Giá trị HĐMB",
"Tiền nộp TTTHNV",
"Lũy kế tiền về HĐCN",
"Tỷ lệ HĐCN",
];
scheduleHeaders.value.forEach((h) => {
headers.push(
`${h.label} - Ngày`,
`${h.label} - Số tiền`,
`${h.label} - Lũy kế sang`,
`${h.label} - Số tiền đã thực thanh toán`,
`${h.label} - Tỷ lệ`,
`${h.label} - Dư nợ`,
);
});
headers.push("Số tiền quá hạn");
const data = filteredRows.value.map((row, i) => {
const base = [
i + 1,
row.customer_code,
row.trade_code,
row.contract_date,
fmt(row.sale_price),
fmt(row.ttthnv_paid),
fmt(row.luy_ke),
pct(row.luy_ke, row.sale_price),
];
scheduleHeaders.value.forEach((_, si) => {
const sch = row.schedules[si];
if (sch) {
base.push(
sch.to_date,
fmt(sch.amount),
fmt(sch.luy_ke_sang_dot),
fmt(sch.thuc_thanh_toan),
pct(sch.thuc_thanh_toan, sch.amount),
fmt(sch.amount_remain),
);
} else {
base.push("", "", "", "", "", "");
}
});
base.push(fmt(row.overdue));
return base;
});
const csvRows = [headers, ...data];
const csv = csvRows.map((r) => r.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(",")).join("\n");
const BOM = "\uFEFF";
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `bao-cao-cong-no-${$dayjs().format("YYYYMMDD")}.csv`;
a.click();
URL.revokeObjectURL(url);
}
onMounted(() => loadData());
</script>
<style scoped>
/* Fixed columns style */
.fixed-col {
position: sticky;
left: 0;
z-index: 3;
background-color: white; /* nền trắng cho body */
min-width: 80px; /* điều chỉnh theo nhu cầu */
}
/* Cột STT thường hẹp hơn */
.fixed-col:nth-child(1) {
left: 0;
min-width: 50px;
z-index: 4; /* cao hơn để đè lên */
}
/* Cột Mã KH */
.fixed-col:nth-child(2) {
left: 50px;
min-width: 100px;
}
/* Cột Mã Căn */
.fixed-col:nth-child(3) {
left: 150px;
min-width: 120px;
}
/* Cột Ngày ký HĐ */
.fixed-col:nth-child(4) {
left: 270px;
min-width: 110px;
}
/* Cột Giá trị HĐMB */
.fixed-col:nth-child(5) {
left: 380px;
min-width: 140px;
}
/* Cột Tiền nộp TTTHNV */
.fixed-col:nth-child(6) {
left: 520px;
min-width: 160px;
}
/* Cột Lũy kế HĐCN */
.fixed-col:nth-child(7) {
left: 680px;
min-width: 160px;
}
/* Cột Tỷ lệ */
.fixed-col:nth-child(8) {
left: 840px;
min-width: 90px;
border-right: 2px solid #dee2e6 !important; /* tạo đường phân cách rõ ràng */
}
/* Header fixed */
.debt-table thead .fixed-col {
position: sticky;
top: 0;
z-index: 5;
background-color: #204853 !important; /* primary color */
color: white !important;
}
/* Footer fixed columns */
.debt-table tfoot .fixed-col {
background-color: #f8f9fa !important;
font-weight: bold;
}
/* Đảm bảo tfoot cũng có z-index */
.debt-table tfoot tr {
z-index: 2;
}
/* Optional: bóng nhẹ khi scroll để dễ nhận biết fixed */
.fixed-col {
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.08);
}
</style>