Initial commit
This commit is contained in:
363
app/components/debt/Overdue.vue
Normal file
363
app/components/debt/Overdue.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup>
|
||||
import Template1 from '@/lib/email/templates/Template1.vue';
|
||||
import { render } from '@vue-email/render';
|
||||
import { forEachAsync, isEqual } from 'es-toolkit';
|
||||
|
||||
const {
|
||||
$dayjs,
|
||||
$getdata,
|
||||
$insertapi,
|
||||
$numtoString,
|
||||
$numberToVietnamese,
|
||||
$numberToVietnameseCurrency,
|
||||
$formatDateVN,
|
||||
$getFirstAndLastName,
|
||||
$snackbar,
|
||||
$store,
|
||||
} = useNuxtApp();
|
||||
|
||||
const payables = ref(null);
|
||||
const defaultFilter = {
|
||||
status: 1,
|
||||
to_date__lt: $dayjs().format('YYYY-MM-DD'),
|
||||
}
|
||||
const filter = ref(defaultFilter);
|
||||
const activeDateFilter = ref(null);
|
||||
const key = ref(0);
|
||||
|
||||
function setDateFilter(detail) {
|
||||
activeDateFilter.value = isEqual(activeDateFilter.value, detail) ? null : detail;
|
||||
}
|
||||
|
||||
function resetDateFilter() {
|
||||
activeDateFilter.value = null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const payablesData = await $getdata('bizsetting', undefined, { filter: { classify: 'overduepayables' }, sort: 'index' });
|
||||
payables.value = payablesData;
|
||||
});
|
||||
|
||||
watch(activeDateFilter, (val) => {
|
||||
if (!val) {
|
||||
filter.value = defaultFilter;
|
||||
contents.value = null;
|
||||
} else {
|
||||
const cutoffDate = $dayjs().subtract(val.time, 'day').format('YYYY-MM-DD');
|
||||
const filterField = `to_date__${val.lookup === 'lte' ? 'gt' :
|
||||
'lte'}`;
|
||||
filter.value = {
|
||||
...defaultFilter,
|
||||
[filterField]: cutoffDate,
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
const contents = ref(null);
|
||||
const isSending = ref(false);
|
||||
|
||||
function sanitizeContentPayment(text, maxLength = 80) {
|
||||
if (!text) return '';
|
||||
|
||||
return text
|
||||
.normalize('NFD') // bỏ dấu tiếng Việt
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, '') // bỏ ký tự lạ
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const buildQrHtml = (url) => `
|
||||
<div style="text-align: center; margin-top: 16px">
|
||||
<img
|
||||
src="${url}"
|
||||
alt="VietQR"
|
||||
width="500"
|
||||
style="display: inline-block"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const buildContentPayment = (data) => {
|
||||
const {
|
||||
txn_detail__transaction__customer__type__code: customerType,
|
||||
txn_detail__transaction__customer__code: customerCode,
|
||||
txn_detail__transaction__customer__fullname: customerName,
|
||||
txn_detail__transaction__product__trade_code: productCode,
|
||||
cycle,
|
||||
} = data;
|
||||
|
||||
if (customerType.toLowerCase() === 'cn') {
|
||||
if (customerName.length < 14) {
|
||||
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
|
||||
} else {
|
||||
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
|
||||
}
|
||||
} else {
|
||||
return `${productCode} ${customerCode} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
|
||||
}
|
||||
};
|
||||
|
||||
function replaceTemplateVars(html, paymentScheduleItem) {
|
||||
const {
|
||||
txn_detail__transaction__product__trade_code,
|
||||
txn_detail__transaction__customer__code,
|
||||
txn_detail__transaction__customer__fullname,
|
||||
txn_detail__transaction__customer__legal_code,
|
||||
txn_detail__transaction__customer__type__code,
|
||||
txn_detail__transaction__customer__contact_address,
|
||||
txn_detail__transaction__customer__address,
|
||||
txn_detail__transaction__customer__phone,
|
||||
from_date,
|
||||
to_date,
|
||||
remain_amount,
|
||||
cycle
|
||||
} = paymentScheduleItem;
|
||||
return html
|
||||
.replace(/\[day]/g, String(new Date().getDate()).padStart(2, '0') || '')
|
||||
.replace(/\[month]/g, String(new Date().getMonth() + 1).padStart(2, '0') || '')
|
||||
.replace(/\[year]/g, new Date().getFullYear() || '')
|
||||
.replace(/\[product\.trade_code\]/g, txn_detail__transaction__product__trade_code)
|
||||
.replace(/\[product\.trade_code_payment\]/g, sanitizeContentPayment(txn_detail__transaction__product__trade_code))
|
||||
.replace(/\[customer\.fullname\]/g, txn_detail__transaction__customer__fullname)
|
||||
.replace(
|
||||
/\[customer\.name\]/g,
|
||||
`${txn_detail__transaction__customer__type__code.toLowerCase() == 'cn' ? (txn_detail__transaction__customer__fullname.length < 14 ? txn_detail__transaction__customer__fullname : $getFirstAndLastName(txn_detail__transaction__customer__fullname)) : ''}` ||
|
||||
'',
|
||||
)
|
||||
.replace(/\[customer\.code\]/g, txn_detail__transaction__customer__code || '')
|
||||
.replace(
|
||||
/\[customer\.legal_code\]/g,
|
||||
txn_detail__transaction__customer__legal_code || '',
|
||||
)
|
||||
.replace(
|
||||
/\[customer\.contact_address\]/g,
|
||||
txn_detail__transaction__customer__contact_address ||
|
||||
txn_detail__transaction__customer__address ||
|
||||
'',
|
||||
)
|
||||
.replace(/\[customer\.phone\]/g, txn_detail__transaction__customer__phone || '')
|
||||
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(from_date) || '')
|
||||
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(to_date) || '')
|
||||
.replace(/\[payment_schedule\.amount\]/g, $numtoString(remain_amount) || '')
|
||||
.replace(
|
||||
/\[payment_schedule\.amount_in_word\]/g,
|
||||
$numberToVietnameseCurrency(remain_amount) || '',
|
||||
)
|
||||
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(cycle) || '')
|
||||
.replace(
|
||||
/\[payment_schedule\.cycle-in-words\]/g,
|
||||
`${cycle == 0 ? 'đặt cọc' : `đợt thứ ${$numberToVietnamese(cycle).toLowerCase()}`}` ||
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/\[payment_schedule\.note\]/g,
|
||||
`${cycle == 0 ? 'Dat coc' : `Dot ${cycle}`}` || '',
|
||||
);
|
||||
}
|
||||
|
||||
function quillToEmailHtml(html) {
|
||||
return html
|
||||
// ALIGN
|
||||
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
|
||||
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
|
||||
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
|
||||
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
|
||||
|
||||
// FONT SIZE
|
||||
.replace(/ql-size-small/g, '')
|
||||
.replace(/ql-size-large/g, '')
|
||||
.replace(/ql-size-huge/g, '')
|
||||
|
||||
// REMOVE EMPTY CLASS
|
||||
.replace(/class=""/g, '')
|
||||
;
|
||||
}
|
||||
|
||||
const showmodal = ref(null);
|
||||
|
||||
function openConfirmModal() {
|
||||
showmodal.value = {
|
||||
component: 'dialog/Confirm',
|
||||
title: 'Xác nhận',
|
||||
width: '500px',
|
||||
height: '100px',
|
||||
vbind: {
|
||||
content: 'Bạn có đồng ý gửi thông báo hàng loạt không?',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEmails() {
|
||||
isSending.value = true;
|
||||
$snackbar('Hệ thống đang xử lý ngầm yêu cầu gửi email hàng loạt...');
|
||||
|
||||
const paymentScheduleData = await $getdata(
|
||||
'payment_schedule',
|
||||
undefined,
|
||||
{
|
||||
filter: filter.value,
|
||||
sort: 'to_date',
|
||||
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__legal_code,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__phone,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
}
|
||||
);
|
||||
|
||||
const emailTemplate = await $getdata(
|
||||
'emailtemplate',
|
||||
{ id: activeDateFilter.value.emailTemplate },
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
let message = emailTemplate.content.content;
|
||||
|
||||
contents.value = paymentScheduleData.map(paymentSchedule => {
|
||||
message = message.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, '');
|
||||
const transfer = {
|
||||
bank: {
|
||||
code: 'MB',
|
||||
name: 'MB Bank',
|
||||
},
|
||||
account: {
|
||||
number: '146768686868',
|
||||
name: 'CONG TY CO PHAN BAT DONG SAN UTOPIA',
|
||||
},
|
||||
content: 'Thanh toán đơn #xyz',
|
||||
};
|
||||
|
||||
transfer.content = buildContentPayment(paymentSchedule);
|
||||
const params = new URLSearchParams({
|
||||
addInfo: transfer.content,
|
||||
accountName: transfer.account.name,
|
||||
});
|
||||
const qrImageUrl = `https://img.vietqr.io/image/${transfer.bank.code}-${transfer.account.number}-print.png?${params.toString()}`;
|
||||
|
||||
message = `
|
||||
${message.trim()}
|
||||
|
||||
${buildQrHtml(qrImageUrl)}
|
||||
`;
|
||||
return {
|
||||
...emailTemplate.content,
|
||||
content: undefined,
|
||||
message: replaceTemplateVars(message, paymentSchedule),
|
||||
}
|
||||
});
|
||||
|
||||
await forEachAsync(contents.value, async (bigContent, i) => {
|
||||
const { imageUrl, keyword, message, subject } = bigContent;
|
||||
const tempEm = {
|
||||
content: toRaw(bigContent),
|
||||
previewMode: true,
|
||||
};
|
||||
|
||||
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
|
||||
tempEm.content.message = quillToEmailHtml(message);
|
||||
let emailHtml = await render(Template1, tempEm);
|
||||
|
||||
// If no image URL provided, remove image section from HTML
|
||||
if ((imageUrl ?? '').trim() === '') {
|
||||
emailHtml = emailHtml
|
||||
.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, '')
|
||||
.replace(/\n\s*\n\s*\n/g, '\n\n');
|
||||
}
|
||||
|
||||
// Replace keywords in HTML
|
||||
let finalEmailHtml = emailHtml;
|
||||
if (keyword && keyword.length > 0) {
|
||||
keyword.forEach(({ keyword, value }) => {
|
||||
if (keyword && value) {
|
||||
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, 'g');
|
||||
finalEmailHtml = finalEmailHtml.replace(regex, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await $insertapi(
|
||||
'sendemail',
|
||||
{
|
||||
to: paymentScheduleData[i].txn_detail__transaction__customer__email,
|
||||
content: finalEmailHtml,
|
||||
subject: replaceTemplateVars(subject, paymentScheduleData[i]) || 'Thông báo từ Utopia Villas & Resort',
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
if (response !== null) {
|
||||
await $insertapi('productnote', {
|
||||
ref: paymentScheduleData[i].txn_detail__transaction__product,
|
||||
user: $store.login.id,
|
||||
detail: `Đã gửi email thông báo quá hạn cho sản phẩm ${paymentScheduleData[i].txn_detail__transaction__product__trade_code} vào lúc ${$dayjs().format('HH:mm ngày DD/MM/YYYY')}.`
|
||||
}, undefined, false);
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
$snackbar('Thông báo đã được gửi thành công đến các khách hàng.');
|
||||
isSending.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
watch(filter, () => {
|
||||
key.value += 1;
|
||||
}, { deep: true })
|
||||
</script>
|
||||
<template>
|
||||
<div class="is-flex is-justify-content-space-between is-align-content-center mb-4">
|
||||
<div class="buttons m-0">
|
||||
<p>Quá hạn:</p>
|
||||
<button
|
||||
v-for="payable in payables"
|
||||
:key="payable.id"
|
||||
@click="setDateFilter(payable.detail)"
|
||||
:class="['button', { 'is-primary': isEqual(activeDateFilter, payable.detail) }]"
|
||||
>
|
||||
{{ payable.detail.lookup === 'lte' ? '≤' : '>' }} {{ payable.detail.time }} ngày
|
||||
</button>
|
||||
<button
|
||||
v-if="activeDateFilter"
|
||||
@click="resetDateFilter()"
|
||||
class="button is-white"
|
||||
>
|
||||
Xoá lọc
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="activeDateFilter"
|
||||
@click="openConfirmModal()"
|
||||
:class="['button', 'is-light', { 'is-loading': isSending }]"
|
||||
>
|
||||
Gửi thông báo
|
||||
</button>
|
||||
</div>
|
||||
<DataView
|
||||
:key="key"
|
||||
v-bind="{
|
||||
pagename: 'payment-schedule-overdue',
|
||||
api: 'payment_schedule',
|
||||
setting: 'payment-schedule-debt-overdue',
|
||||
realtime: { time: '5', update: 'true' },
|
||||
params: {
|
||||
filter,
|
||||
sort: 'to_date',
|
||||
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,ovd_days,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
}
|
||||
}" />
|
||||
<Modal
|
||||
v-if="showmodal"
|
||||
v-bind="showmodal"
|
||||
@confirm="sendEmails()"
|
||||
@close="showmodal = undefined"
|
||||
/>
|
||||
<!-- <div class="is-flex is-gap-1">
|
||||
// debug
|
||||
<Template1
|
||||
v-if="contents"
|
||||
v-for="content in contents"
|
||||
:content="content"
|
||||
previewMode
|
||||
/>
|
||||
</div> -->
|
||||
</template>
|
||||
Reference in New Issue
Block a user