Files
hrm/app/components/modal/PaymentScheduleConfirmModal.vue
2026-04-07 10:08:00 +07:00

801 lines
26 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 -->
<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"
>
<!-- ===================== REDUCTION ===================== -->
<template v-if="item.isReduction">
<div style="min-width: 3rem">
<p class="is-size-5 has-text-weight-bold has-text-info">
{{ 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-info mb-2">
Miễn giảm lãi phạt
<span
v-if="item.entry.code"
class="tag is-info is-light ml-2"
>
{{ item.entry.code }}
</span>
</p>
<div
class="box is-shadowless p-4"
style="border-left: 5px solid #3273dc"
>
<div class="columns is-mobile is-multiline">
<div class="column is-6-mobile">
<span class="has-text-grey-light">Số tiền miễn giảm:</span
><br />
<span
class="has-text-info has-text-weight-semibold is-size-5"
>
{{ $numtoString(item.reduceAmount) }}
</span>
</div>
<div class="column is-6-mobile">
<span class="has-text-grey-light"
>Lãi còn lại sau miễn giảm:</span
><br />
<span
class="has-text-weight-semibold is-size-5"
:class="
item.penaltyRemain > 0
? 'has-text-danger'
: 'has-text-success'
"
>
{{ $numtoString(item.penaltyRemain) }}
</span>
</div>
</div>
</div>
</div>
</template>
<!-- ===================== PAYMENT ===================== -->
<template v-else>
<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="">
<p
v-if="item.penaltyThisPeriod > 0"
class="is-size-6 has-text-grey"
>
Dư 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>
</template>
</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
v-if="paymentScheduleData.penalty_reduce > 0"
class="column is-3-tablet is-6-mobile"
>
<p class="heading">Đã miễn giảm lãi</p>
<p class="title is-5 has-text-info">
{{ $numtoString(paymentScheduleData.penalty_reduce) }}
</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 mr-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 — gộp PAYMENT + REDUCTION, sort theo date
// ============================================================
const processedEntries = computed(() => {
if (!paymentScheduleData.value?.entry) return [];
const relevantEntries = paymentScheduleData.value.entry.filter(
(e) =>
(e.type === 'PAYMENT' && e.penalty_added_to_entry !== undefined) ||
e.type === 'REDUCTION',
);
relevantEntries.sort((a, b) => new Date(a.date) - new Date(b.date));
let currentPrincipal = Number(paymentScheduleData.value.amount || 0);
let totalPenaltyAccumulated = 0;
let totalPenaltyPaid = 0;
let totalPenaltyReduce = 0;
let lastDate = null; // null = chưa có lần trả nào
const toDate = paymentScheduleData.value.to_date;
const result = [];
relevantEntries.forEach((entry) => {
const entryDate = dayjs(entry.date);
if (entry.type === 'PAYMENT') {
const penaltyThisPeriod = Number(entry.penalty_added_to_entry || 0);
totalPenaltyAccumulated += penaltyThisPeriod;
const principalPaid = Number(entry.principal || 0);
const penaltyPaid = Number(entry.penalty || 0);
currentPrincipal -= principalPaid;
if (currentPrincipal < 0) currentPrincipal = 0;
totalPenaltyPaid += penaltyPaid;
// last_event = max(to_date, lastDate) — khớp với logic backend
// Nếu chưa có lần trả nào: dùng to_date
// Nếu lần trước trả trước hạn: max trả về to_date → from = to_date
// Nếu lần trước trả sau hạn: max trả về lastDate → from = lastDate
const lastEventDate = lastDate
? dayjs(Math.max(dayjs(toDate).valueOf(), dayjs(lastDate).valueOf()))
: dayjs(toDate);
const days = Math.max(0, entryDate.diff(lastEventDate, 'day'));
lastDate = entry.date; // cập nhật sau khi đã tính
result.push({
entry,
isReduction: false,
principalRemain: Number(entry.amount_remain_after_allocation || 0),
principalBefore:
Number(entry.amount_remain_after_allocation || 0) + principalPaid,
penaltyAccumulated: totalPenaltyAccumulated,
penaltyThisPeriod,
penaltyRemain: Math.max(
0,
totalPenaltyAccumulated - totalPenaltyPaid - totalPenaltyReduce,
),
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.subtract(1, 'day').format('DD/MM/YYYY'),
days,
},
});
} else if (entry.type === 'REDUCTION') {
const reduceAmount = Number(entry.amount || 0);
totalPenaltyReduce += reduceAmount;
// REDUCTION không dịch chuyển lastDate
result.push({
entry,
isReduction: true,
reduceAmount,
penaltyRemain: Math.max(
0,
totalPenaltyAccumulated - totalPenaltyPaid - totalPenaltyReduce,
),
});
}
});
return result;
});
// Tiền lãi hiện tại (từ entry PAYMENT 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(
() => latestEntry.value?.penalty_to_this_entry || 0,
);
const latestAdditionalPenalty = computed(() => {
return (
paymentScheduleData.value?.penalty_amount -
(latestEntry.value?.penalty_to_this_entry || 0) || 0
);
});
const latestTotalPenalty = computed(
() => latestPenaltyToThisEntry.value + latestAdditionalPenalty.value,
);
const latestEntryCode = computed(() => 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(
'payment_schedule',
{ 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>