Files
web/app/components/modal/PaymentScheduleConfirmModal.vue
2026-03-02 09:45:33 +07:00

525 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="px-3">
<div v-if="loadingData" class="has-text-centered py-5">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
<p class="mt-2">
{{ isVietnamese ? "Đang tải thông tin công nợ..." : "Loading payment schedule information..." }}
</p>
</div>
<div v-else-if="paymentScheduleData">
<div class="content">
<!-- Thông tin bản (giữ nguyên) -->
<div class="columns is-multiline is-mobile">
<div class="column is-3">
<strong>{{ isVietnamese ? "Mã:" : "Schedule Code:" }}</strong>
<p>{{ paymentScheduleData.code || "-" }}</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Trạng thái:" : "Status:" }}</strong>
<p :class="{
'has-text-success': paymentScheduleData.status__name === 'Đã xác nhận' || paymentScheduleData.status__name === 'Paid',
'has-text-warning': paymentScheduleData.status__name === 'Chưa xác nhận' || paymentScheduleData.status__name === 'Pending',
'has-text-danger': paymentScheduleData.status__name === 'Quá hạn' || paymentScheduleData.status__name === 'Overdue'
}">
{{ paymentScheduleData.status__name || "-" }}
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Loại thanh toán:" : "Payment Type:" }}</strong>
<p>{{ paymentScheduleData.type__name || "-" }}</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Đợt thanh toán:" : "Cycle:" }}</strong>
<p>{{ paymentScheduleData.cycle_days || 0 }} ngày</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Tiền gốc theo kỳ thanh toán:" : "Amount:" }}</strong>
<p class="has-text-weight-bold has-text-primary">{{ $numtoString(paymentScheduleData.amount) }}</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Số tiền gốc đã thanh toán:" : "Paid Amount:" }}</strong>
<p class="has-text-weight-bold has-text-primary">{{ $numtoString(paymentScheduleData.paid_amount) }}</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Số tiền gốc còn lại:" : "Remaining Principal:" }}</strong>
<p class="has-text-weight-bold"
:class="paymentScheduleData.amount_remain > 0 ? 'has-text-danger' : 'has-text-success'">
{{ $numtoString(paymentScheduleData.amount_remain) }}
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Ngày đến hạn:" : "Due Date:" }}</strong>
<p :class="{ 'has-text-danger': isOverdue(paymentScheduleData.to_date && paymentScheduleData.ovd_days > 0) }">
{{ formatDate(paymentScheduleData.to_date) }}
<span v-if="isOverdue(paymentScheduleData.to_date && paymentScheduleData.ovd_days > 0 )" class="has-text-weight-bold">
(Quá hạn {{ paymentScheduleData.ovd_days }} ngày)
</span>
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Tổng lãi phải thu:" : "Total Penalty Amount:" }}</strong>
<p class="has-text-weight-bold has-text-danger">
{{ paymentScheduleData.penalty_amount > 0 ? $numtoString(paymentScheduleData.penalty_amount) : "-" }}
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Lãi phạt đã thanh toán:" : "Penalty Paid:" }}</strong>
<p class="has-text-weight-bold has-text-success">
{{ paymentScheduleData.penalty_paid > 0 ? $numtoString(paymentScheduleData.penalty_paid) : "-" }}
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Lãi phạt còn lại:" : "Penalty Remaining:" }}</strong>
<p class="has-text-weight-bold has-text-danger">
{{ paymentScheduleData.penalty_remain > 0 ? $numtoString(paymentScheduleData.penalty_remain) : "-" }}
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Miễn giảm lãi phạt:" : "Penalty Reduced:" }}</strong>
<p class="has-text-weight-bold has-text-primary">
{{ paymentScheduleData.penalty_reduce > 0 ? $numtoString(paymentScheduleData.penalty_reduce) : "-" }}
</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Tổng tiền phải thanh toán:" : "Total Remaining:" }}</strong>
<p class="has-text-weight-bold has-text-primary">{{ $numtoString(paymentScheduleData.remain_amount) }}</p>
</div>
<div class="column is-3">
<strong>{{ isVietnamese ? "Ghi chú:" : "Note:" }}</strong>
<p class="is-size-6">{{ paymentScheduleData.detail?.note || "-" }}</p>
</div>
</div>
<hr />
<!-- Timeline lịch sử -->
<div v-if="processedEntries.length > 0" class="is-flex is-flex-direction-column is-gap-5" >
<div v-for="(item, index) in processedEntries" :key="index" class="is-flex is-align-items-start is-gap-4">
<div style="min-width: 3rem;">
<p class="is-size-5 has-text-weight-bold has-text-primary">
{{ formatDate(item.entry.date) }}
</p>
<p v-if="item.entry.code" class="is-size-6 has-text-grey">
{{ item.entry.code }}
</p>
</div>
<div class="is-flex-grow-1">
<p class="is-size-5 has-text-weight-bold has-text-dark mb-2">
{{ getEntryTypeLabel(item.entry.type) }}
<span v-if="item.entry.code" class="tag is-link is-light ml-2">
{{ item.entry.code }}
</span>
</p>
<div class="box is-shadowless p-4" style="border-left: 5px solid #204853;">
<div class="columns is-mobile is-multiline is-gap-3">
<div class="column is-6-mobile">
<span class="has-text-grey-light">Gốc trả:</span><br />
<span v-if="item.entry.principal > 0" class="has-text-success has-text-weight-semibold is-size-5">
{{ $numtoString(item.entry.principal) }}
</span>
<span v-else class="has-text-grey-light">-</span>
</div>
<div class="column is-6-mobile">
<span class="has-text-grey-light">Lãi trả:</span><br />
<span v-if="item.entry.penalty > 0" class="has-text-danger has-text-weight-semibold is-size-5">
{{ $numtoString(item.entry.penalty) }}
</span>
<span v-else class="has-text-grey-light">-</span>
</div>
<div class="column is-6-mobile">
<span class="has-text-grey-light">Gốc còn lại :</span><br />
<strong :class="item.principalRemain > 0 ? 'has-text-danger' : 'has-text-success'"
class="is-size-5">
{{ $numtoString(item.principalRemain) }}
</strong>
</div>
</div>
<!-- Chi tiết lãi phát sinh -->
<div class="">
<!-- Diễn giải chi tiết cách tính lãi kỳ này -->
<p v-if="item.penaltyThisPeriod > 0" class="is-size-6 has-text-grey">
nợ gốc còn lại: {{ $numtoString(item.principalBefore) }} ×
{{ item.penaltyDetail.days }} ngày (từ {{ item.penaltyDetail.from }} đến {{ item.penaltyDetail.to
}}) × {{item.rate}}%/ngày = {{ $numtoString(item.penaltyThisPeriod) }}
</p>
<p class="is-size-6 mt-3">
<strong>Tổng lãi tích lũy đến {{ formatDate(item.entry.date) }}: </strong>
<span :class="item.penaltyAccumulated > 0 ? 'has-text-danger' : 'has-text-grey'">
{{ $numtoString(item.penaltyAccumulated) }}
</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Tiền lãi hiện tại (nếu chưa hết nợ) -->
<div v-if="hasUnpaidDebt && latestPenaltyToThisEntry != paymentScheduleData.penalty_amount && processedEntries.length > 0 && latestPenaltyToThisEntry >0" class="mt-5 box has-background-warning-light">
<p class="is-size-5">
<strong>Lãi đến ngày thanh toán gần nhất ({{ latestEntryDate }}) :</strong> {{ $numtoString(latestPenaltyToThisEntry) }}
</p>
<p v-if="latestAdditionalPenalty > 0" class="is-size-5 mt-2">
<strong>Lãi phát sinh từ ngày {{ latestEntryDate }} đến nay:</strong> {{
$numtoString(latestAdditionalPenalty) }}
</p>
<p class="is-size-5 has-text-weight-bold mt-3">
Tổng lãi hiện tại : {{
$numtoString(latestPenaltyToThisEntry) }} + {{ $numtoString(latestAdditionalPenalty) }} = {{ $numtoString(paymentScheduleData.penalty_amount) }}
</p>
</div>
<hr />
<!-- Tóm tắt cuối cùng -->
<div class="columns is-mobile is-multiline">
<div class="column is-3-tablet is-6-mobile">
<p class="heading">Gốc đã trả</p>
<p class="title is-5 has-text-success">{{ $numtoString(paymentScheduleData.paid_amount) }}</p>
</div>
<div class="column is-3-tablet is-6-mobile">
<p class="heading">Gốc còn lại</p>
<p class="title is-5"
:class="paymentScheduleData.amount_remain > 0 ? 'has-text-danger' : 'has-text-success'">
{{ $numtoString(paymentScheduleData.amount_remain) }}
</p>
</div>
<div class="column is-3-tablet is-6-mobile">
<p class="heading">Lãi đã trả</p>
<p class="title is-5 has-text-success">{{ $numtoString(paymentScheduleData.penalty_paid) }}</p>
</div>
<div class="column is-3-tablet is-6-mobile">
<p class="heading">Lãi còn lại</p>
<p class="title is-5"
:class="paymentScheduleData.penalty_remain > 0 ? 'has-text-danger' : 'has-text-success'">
{{ $numtoString(paymentScheduleData.penalty_remain) }}
</p>
</div>
<div class="column is-3-tablet is-6-mobile">
<p class="heading">Tổng tiền phải thanh toán</p>
<p class="title is-5 has-text-primary">{{ $numtoString(paymentScheduleData.remain_amount) }}</p>
</div>
</div>
<div v-if="isConfirmAllowed && $getEditRights()" class="field is-grouped is-grouped-left ">
<p class="control">
<button class="button is-info mx-3 has-text-white" @click="handleViewEmail">
<span>Gửi thông báo</span>
</button>
<button v-if="paymentScheduleData.batch_date == null" class="button is-danger has-text-white"
:class="{ 'is-loading': isLoading }" @click="getPenalty" :disabled="isLoading">
<span>Tính lãi</span>
</button>
</p>
<Modal @close="showModalViewEmail = undefined" v-bind="showModalViewEmail" v-if="showModalViewEmail"></Modal>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useNuxtApp } from "#app";
import { useStore } from "@/stores/index";
import dayjs from "dayjs";
const props = defineProps({
scheduleItemId: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(["close", "confirmed"]);
const store = useStore();
const { $insertapi, $snackbar, $getEditRights, $getdata, $patchapi, $numtoString } = useNuxtApp();
const isLoading = ref(false);
const loadingData = ref(true);
const paymentScheduleData = ref(null);
const rule = ref(null);
const invoiceData = ref({
link: "",
ref_code: "",
});
const isUpdatingInvoice = ref(false);
const showModal = ref(null);
const showModalViewEmail = ref(null);
const isVietnamese = computed(() => store.lang === "vi");
const isOverdue = (dueDate) => {
if (!dueDate) return false;
return dayjs(dueDate).isBefore(dayjs(), "day");
};
const isConfirmAllowed = computed(() => paymentScheduleData.value?.status === 1);
const isInvoiceAllowed = computed(() => paymentScheduleData.value?.status === 2);
const formatDate = (dateString) => {
if (!dateString) return "-";
return dayjs(dateString).format("DD/MM/YYYY");
};
const getEntryTypeLabel = (type) => {
const labels = {
PAYMENT: "Thanh toán",
REDUCTION: "Miễn giảm",
};
return labels[type] || type;
};
// Xử lý timeline
const processedEntries = computed(() => {
if (!paymentScheduleData.value?.entry) return [];
const relevantEntries = paymentScheduleData.value.entry.filter(
(e) => e.type === "PAYMENT" && e.penalty_added_to_entry !== undefined
);
relevantEntries.sort((a, b) => new Date(a.date) - new Date(b.date));
let currentPrincipal = Number(paymentScheduleData.value.amount || 0);
let totalPenaltyAccumulated = 0;
let totalPenaltyPaid = Number(paymentScheduleData.value.penalty_paid || 0);
let lastDate = paymentScheduleData.value.to_date;
let principalBeforeThisPeriod = currentPrincipal;
const result = [];
relevantEntries.forEach((entry, index) => {
const entryDate = dayjs(entry.date);
const lastEventDate = dayjs(lastDate);
const days = entryDate.diff(lastEventDate, "day");
let penaltyThisPeriod = Number(entry.penalty_added_to_entry || 0);
totalPenaltyAccumulated += penaltyThisPeriod;
if (entry.type === "PAYMENT") {
const principalPaid = Number(entry.principal || 0);
const penaltyPaid = Number(entry.penalty || 0);
currentPrincipal -= principalPaid;
if (currentPrincipal < 0) currentPrincipal = 0;
totalPenaltyPaid += penaltyPaid;
principalBeforeThisPeriod = currentPrincipal;
}
lastDate = entry.date;
const penaltyRemain = totalPenaltyAccumulated - totalPenaltyPaid;
result.push({
entry,
principalRemain: Number(entry.amount_remain_after_allocation || 0), // Gốc còn lại sau bút toán này
principalBefore: Number(entry.amount_remain_after_allocation || 0) + Number(entry.principal || 0), // Dư nợ trước khi trừ principal của bút toán này
penaltyAccumulated: totalPenaltyAccumulated,
penaltyThisPeriod,
penaltyRemain,
rate:Number(entry.DAILY_PENALTY_RATE || 0)*100,
totalDebt: Number(entry.amount_remain_after_allocation || 0) + penaltyThisPeriod,
penaltyDetail: {
from: lastEventDate.format("DD/MM/YYYY"),
to: entryDate.format("DD/MM/YYYY"),
days,
},
});
});
return result;
});
// Tiền lãi hiện tại (từ entry gần nhất)
const latestEntry = computed(() => {
if (!paymentScheduleData.value?.entry?.length) return null;
const paymentEntries = paymentScheduleData.value.entry.filter(
e => e.type === "PAYMENT" && e.penalty_to_this_entry !== undefined
);
if (paymentEntries.length === 0) return null;
return paymentEntries[paymentEntries.length - 1];
});
const hasUnpaidDebt = computed(() => {
return (
paymentScheduleData.value?.amount_remain > 0 ||
paymentScheduleData.value?.penalty_remain > 0
);
});
const latestPenaltyToThisEntry = computed(() => {
return latestEntry.value?.penalty_to_this_entry || 0;
});
const latestAdditionalPenalty = computed(() => {
return paymentScheduleData.value?.penalty_amount - latestEntry.value?.penalty_to_this_entry || 0;
});
const latestTotalPenalty = computed(() => {
return latestPenaltyToThisEntry.value + latestAdditionalPenalty.value;
});
const latestEntryCode = computed(() => {
return latestEntry.value?.code || "-";
});
const latestEntryDate = computed(() => {
return latestEntry.value?.date ? formatDate(latestEntry.value.date) : "-";
});
const fetchPaymentScheduleData = async () => {
loadingData.value = true;
try {
const data = await $getdata("payment_schedule", { id: props.scheduleItemId }, undefined, true);
const ruleData = await $getdata("bizsetting", { code: "rule" }, undefined, true);
rule.value =
ruleData?.detail === "fee-principal"
? "Lãi phạt quá hạn trước - Gốc sau"
: "Tiền gốc trước - Lãi phạt quá hạn sau";
paymentScheduleData.value = data;
if (data?.link) invoiceData.value.link = data.link;
if (data?.ref_code) invoiceData.value.ref_code = data.ref_code;
} catch (e) {
console.error("Error fetching payment schedule data:", e);
$snackbar(
isVietnamese.value ? "Không thể tải thông tin công nợ." : "Failed to load payment schedule information.",
"Lỗi",
"Error"
);
} finally {
loadingData.value = false;
}
};
const getPenalty = async () => {
isLoading.value = true;
const target_item = paymentScheduleData.value;
const workflowPayload = {
workflow_code: "CALCULATE_LATE_PAYMENT_PENALTY",
trigger: "create",
target_item,
};
try {
const response = await $insertapi("workflow", workflowPayload, undefined, false);
if (response === "error" || !response?.success) throw new Error("Calculate penalty failed");
$snackbar("Tính lãi thành công!", "Thành công", "Success");
await fetchPaymentScheduleData();
} catch (e) {
$snackbar("Có lỗi xảy ra khi tính lãi.", "Lỗi", "Error");
} finally {
isLoading.value = false;
}
};
const handleUpdateInvoice = async () => {
if (!invoiceData.value.link || !invoiceData.value.ref_code) {
$snackbar(
isVietnamese.value ? "Vui lòng nhập đầy đủ link và mã xác thực" : "Please enter both link and reference code",
"Cảnh báo",
"Warning"
);
return;
}
isUpdatingInvoice.value = true;
try {
const response = await $patchapi(
"payment_schedule",
{
id: props.scheduleItemId,
link: invoiceData.value.link,
ref_code: invoiceData.value.ref_code,
},
undefined,
false
);
if (response === "error" || !response) throw new Error("Update failed");
$snackbar(
isVietnamese.value ? "Cập nhật hóa đơn thành công!" : "Invoice updated successfully!",
"Thành công",
"Success"
);
await fetchPaymentScheduleData();
} catch (error) {
console.error("Error updating invoice:", error);
$snackbar(
isVietnamese.value ? "Có lỗi xảy ra khi cập nhật hóa đơn" : "Error updating invoice",
"Lỗi",
"Error"
);
} finally {
isUpdatingInvoice.value = false;
}
};
const resetInvoiceData = () => {
invoiceData.value = { link: "", ref_code: "" };
};
function openEntryDetailModal(entry) {
if (!entry.code) return;
showModal.value = {
component: "accounting/InternalEntry",
title: `Chi tiết bút toán: ${entry.code}`,
height: "500px",
width: "80%",
vbind: {
row: { code: entry.code },
},
};
}
async function handleViewEmail() {
const emailTemplate = await $getdata(
"emailtemplate",
{ name: "Mail Thông báo đến hạn thanh toán" },
undefined,
false
);
showModalViewEmail.value = {
component: "marketing/email/viewEmail/ViewEmail",
title: "Xem trước nội dung nhắc thanh toán",
width: "60%",
vbind: {
idEmailTemplate: emailTemplate[0]?.id || null,
scheduleItemId: props.scheduleItemId,
},
onConfirm: () => { },
};
}
const handleModalClose = () => {
showModal.value = null;
};
const handleConfirmDelete = () => {
showModal.value = null;
};
onMounted(() => {
fetchPaymentScheduleData();
});
</script>