This commit is contained in:
Viet An
2026-06-08 23:13:00 +07:00
parent c1fa84083f
commit 853f969921
26 changed files with 131 additions and 1410 deletions

View File

@@ -5,7 +5,7 @@
>
<div
:id="id"
class="modal is-active has-text-text-20"
class="modal is-active"
>
<div
class="modal-background"
@@ -99,7 +99,6 @@ watchEffect(() => {
});
const id = useId();
let count = 0;
function closeModal() {
const modals = document.getElementById("modals");
@@ -121,18 +120,19 @@ const modalEvent = function (ev) {
}
};
function closeOnEsc(e) {
if (e.key === "Escape") closeModal();
}
onMounted(() => {
window.addEventListener("keydown", closeOnEsc);
document.documentElement.classList.add("is-clipped");
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
const collection = document.getElementsByClassName("modal-background");
count = collection.length;
});
onUnmounted(() => {
count--;
if (count === 0) document.documentElement.classList.remove("is-clipped");
window.removeEventListener("keydown", closeOnEsc);
const remaining = document.getElementsByClassName("modal-background").length;
if (remaining === 0) document.documentElement.classList.remove("is-clipped");
});
</script>
<style scoped>

View File

@@ -1,14 +1,13 @@
<template>
<nav
class="navbar has-shadow sticky px-3"
style="top: 0"
class="navbar has-shadow sticky top-0 px-3"
role="navigation"
>
<div class="navbar-brand mr-5">
<span class="navbar-item is-gap-1">
<div class="navbar-item is-gap-1">
<div class="size-4 has-background-primary rounded-full"></div>
<span class="fs-16 font-semibold has-text-primary">{{ $dayjs().format("DD/MM") }}</span>
</span>
</div>
<a
class="navbar-item p-0 has-text-primary"
@click="changeTab(leftmenu[0])"
@@ -34,7 +33,7 @@
aria-label="menu"
aria-expanded="false"
data-target="navMenu"
@click="handleClick()"
@click="toggleBurger"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
@@ -125,7 +124,7 @@ import Avatarbox from "~/components/common/Avatarbox.vue";
import { watch } from "vue";
const router = useRouter();
const route = useRoute();
const emit = defineEmits(["changetab", "langChanged"]);
const emit = defineEmits(["changeTab"]);
const { $find, $filter, $store, $snackbar } = useNuxtApp();
const lang = ref($store.lang);
const menu = $filter($store.common, { category: "topmenu" });
@@ -145,18 +144,19 @@ menu.forEach((topmenu) => {
});
const leftmenu = $filter(menu, { category: "topmenu", classify: "left" });
var currentTab = ref(leftmenu[0]);
var subTab = ref();
var tabConfig = $find(menu, { code: "configuration" });
var avatar = ref();
var isAdmin = ref();
const handleClick = function () {
const currentTab = ref(leftmenu[0]);
const subTab = ref();
const tabConfig = $find(menu, { code: "configuration" });
const avatar = ref();
const isAdmin = ref();
function toggleBurger() {
const target = document.getElementById("burger");
target.classList.toggle("is-active");
const target1 = document.getElementById("navMenu");
target1.classList.toggle("is-active");
};
const closeMenu = function () {
}
function closeMenu() {
if (!document) return;
const target = document.getElementById("burger");
const target1 = document.getElementById("navMenu");
@@ -165,28 +165,27 @@ const closeMenu = function () {
target.classList.remove("is-active");
target1.classList.remove("is-active");
}
};
}
function changeTab(tab, subtab) {
if (tab.submenu && tab.submenu.length > 0 && !subtab && !tab.detail) {
subtab = tab.submenu[0];
}
currentTab.value = tab;
subTab.value = subtab;
emit("changetab", tab, subtab);
emit("changeTab", tab, subtab);
closeMenu();
let query = subtab ? { tab: tab.code, subtab: subtab.code } : { tab: tab.code };
router.push({ query: query });
}
function openProfile() {
let modal = {
$store.commit("showmodal", {
component: "user/Profile",
width: "1100px",
height: "360px",
title: $store.lang === "vi" ? "Thông tin cá nhân" : "User profile",
};
$store.commit("showmodal", modal);
});
}
let found = route.query.tab ? $find(menu, { code: route.query.tab }) : undefined;
const found = route.query.tab && $find(menu, { code: route.query.tab });
if (found || currentTab.value) changeTab(found || currentTab.value);
onMounted(() => {
if (!$store.login) return;
@@ -200,7 +199,7 @@ onMounted(() => {
});
watch(
() => $store.login,
(newVal, oldVal) => {
(newVal) => {
if (!newVal) return;
avatar.value = {
image: null,

View File

@@ -187,7 +187,7 @@
import { useNuxtApp } from "nuxt/app";
import { ref, computed, onMounted, watch, markRaw } from "vue";
import EmailForm1 from "./forms/EmailForm1.vue";
import Template1 from "~/lib/email/templates/Template1.vue";
import Template1 from "~/components/marketing/email/Template1.vue";
import Modal from "~/components/Modal.vue";
import MappingConfigurator from "~/components/marketing/email/MappingConfigurator.vue";
import JobConfigurator from "~/components/marketing/email/JobConfigurator.vue";

View File

@@ -0,0 +1,453 @@
<template>
<div v-if="previewMode">
<component
:is="'style'"
v-html="styles"
/>
<div
class="container py-6"
:style="{
minHeight: '100%',
backgroundColor: 'hsl(0, 0%, 97%)',
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif',
}"
>
<table
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
:style="{
width: '100%',
maxWidth: '100%',
margin: '0 auto',
}"
>
<tbody>
<tr>
<td>
<table
role="presentation"
class="email-container gmail-fix"
cellspacing="0"
cellpadding="0"
border="0"
:style="{
margin: '0 auto',
width: '100%',
maxWidth: '680px',
backgroundColor: '#ffffff',
borderRadius: '16px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
overflow: 'hidden',
}"
>
<tbody>
<tr class="header-row">
<td :style="{ padding: '0' }">
<div
class="header-image"
:style="{
background: '#f3f4f6',
position: 'relative',
width: '100%',
height: '425px',
overflow: 'hidden',
}"
>
<img
v-if="hasValidImage"
:src="content.imageUrl"
alt="image"
:style="{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}"
/>
<div
v-else
class="is-flex is-justify-content-center is-align-items-center"
:style="{
height: '100%',
background: 'rgba(0, 0, 0, 0.3)',
opacity: 0.15,
}"
>
<SvgIcon
v-bind="{
name: 'image.svg',
type: 'black',
size: 180,
}"
/>
</div>
</div>
</td>
</tr>
<tr v-if="content.message">
<td
class="content-padding"
:style="{
paddingBottom: '20px',
paddingTop: '30px',
paddingRight: '25px',
paddingLeft: '25px',
}"
>
<div
:style="{
border: '1px solid #e5e7eb',
borderRadius: '12px',
backgroundColor: '#ffffff',
overflow: 'hidden',
}"
>
<div :style="{ padding: '15px' }">
<div
class="message-content"
:style="{
fontSize: '16px',
margin: '0',
color: '#374151',
lineHeight: '1.6',
wordBreak: 'break-word',
}"
>
<div
class="content"
v-html="processedMessage"
/>
</div>
</div>
<div
v-if="hasValidLinks"
:style="{
padding: '15px',
paddingLeft: '35px',
borderTop: '1px solid #e5e7eb',
backgroundColor: '#f9fafb',
}"
>
<div>
<div
v-for="(link, index) in validLinks"
:key="index"
:style="{ width: '100%', marginBottom: '8px' }"
>
<ul :style="{ paddingLeft: '20px', margin: 0 }">
<li :style="{ listStyleType: 'disc' }">
<a
:style="{
color: '#2563eb!important',
fontWeight: '600',
textDecoration: 'underline',
display: 'inline-block',
wordBreak: 'break-word',
fontSize: '14px',
}"
target="_blank"
:href="link"
rel="noopener noreferrer"
>
{{ getLinkText(index) }}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</td>
</tr>
<tr>
<td
class="company-padding"
:style="{ paddingBottom: '20px' }"
>
<div
class="company-info"
:style="{
background: 'linear-gradient(to right, #000000, #0f9b0f)',
color: '#ffffff',
borderRadius: '12px',
}"
>
<div
class="logo-container"
:style="{
width: '100%',
height: '100%',
marginBottom: '15px',
backgroundColor: '#ffffff',
borderRadius: '8px',
overflow: 'hidden',
}"
>
<img
:src="imageUrl"
alt="Utopia Footer"
:style="{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}"
/>
</div>
</div>
</td>
</tr>
<!-- <tr>
<td
:style="{
backgroundColor: '#f9fafb',
padding: '20px',
borderTop: '1px solid #e5e7eb',
textAlign: 'center',
}"
>
<p
:style="{
color: '#9ca3af',
fontSize: '11px',
margin: '0',
lineHeight: '1.5',
}"
>
©2025 Utopia. Tất cả các quyền được bảo lưu.
</p>
</td>
</tr> -->
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { imageUrl } from "~/components/marketing/email/Email.utils";
import { computed } from "vue";
interface KeywordItem {
keyword: string;
value: string;
}
interface Template1Content {
subject?: string;
message?: string;
imageUrl?: string | null;
linkUrl?: string[];
textLinkUrl?: string[];
keyword?: KeywordItem[];
}
interface Props {
content: Template1Content;
previewMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
previewMode: false,
});
const hasValidLinks = computed(() => {
return props.content.linkUrl && props.content.linkUrl.some((link) => link && link.trim() !== "");
});
const hasValidImage = computed(() => {
return props.content.imageUrl && props.content.imageUrl.trim() !== "";
});
const replaceKeywords = (content: string): string => {
if (!content) return "";
let replacedContent = content;
if (props.content.keyword && Array.isArray(props.content.keyword)) {
props.content.keyword.forEach((kw) => {
if (typeof kw !== "string" && kw.keyword && kw.value) {
const regex = new RegExp(`\\{\\{${kw.keyword}\\}\\}`, "g");
replacedContent = replacedContent.replace(regex, kw.value);
}
});
}
return replacedContent;
};
const processedMessage = computed(() => replaceKeywords(props.content.message || ""));
const validLinks = computed(() => {
if (!props.content.linkUrl || props.content.linkUrl.length === 0) return [];
return props.content.linkUrl.filter((link) => link && link.trim() !== "");
});
const getLinkText = (index: number): string => {
return props.content.textLinkUrl && props.content.textLinkUrl[index] && props.content.textLinkUrl[index].trim() !== ""
? props.content.textLinkUrl[index]
: `Link ${index + 1}`;
};
const styles = `
ol{
list-style: decimal;
}
li{
list-style: disc;
}
.greeting-padding{
padding: 30px 25px 20px;
}
.content-padding, .company-padding {
padding: 0px 25px 20px;
}
.message-content p {
margin: 0 0 12px 0;
line-height: 1.6;
}
.message-content p:last-child {
margin-bottom: 0;
}
.message-content strong {
font-weight: 700;
}
.message-content em {
font-style: italic;
}
.message-content u {
text-decoration: underline;
}
.message-content s {
text-decoration: line-through;
}
.message-content ol,
.message-content ul {
margin: 12px 0;
padding-left: 25px;
}
.message-content ol li,
.message-content ul li {
margin-bottom: 8px;
line-height: 1.6;
}
.message-content a {
color: #2563eb;
text-decoration: underline;
}
.message-content h1 {
font-size: 28px;
font-weight: 700;
margin: 20px 0 12px 0;
line-height: 1.3;
color: #1f2937;
}
.message-content h2 {
font-size: 24px;
font-weight: 700;
margin: 18px 0 12px 0;
line-height: 1.3;
color: #1f2937;
}
.message-content h3 {
font-size: 20px;
font-weight: 700;
margin: 16px 0 12px 0;
line-height: 1.3;
color: #1f2937;
}
.message-content .ql-align-center {
text-align: center;
}
.message-content .ql-align-right {
text-align: right;
}
.message-content .ql-align-justify {
text-align: justify;
}
.message-content .ql-indent-1 {
padding-left: 3em;
}
.message-content .ql-indent-2 {
padding-left: 6em;
}
@media only screen and (max-width: 768px) {
.email-container {
width: 100% !important;
max-width: 100% !important;
}
.greeting-padding{
padding: 20px 0 0 0 !important;
}
.content-padding {
padding: 20px 0px !important;
}
.company-padding {
padding: 0px 0px 20px 0px !important;
}
.company-info {
padding: 20px 10px !important;
}
.greeting-title{
padding: 10px !important;
margin: 0 !important;
}
.header-image {
width: 100% !important;
height: 250px !important;
}
.greeting-text {
font-size: 12px !important;
}
.message-text {
font-size: 12px !important;
}
.logo-container {
width: 150px !important;
height: 45px !important;
}
.company-name {
font-size: 12px !important;
color: #ffffff !important;
}
.contact-info {
font-size: 11px !important;
color: #ffffff !important;
}
}
`;
</script>

View File

@@ -248,26 +248,19 @@
</template>
<script setup lang="ts">
import { useNuxtApp } from "nuxt/app";
import { nextTick } from "vue";
import { render } from "@vue-email/render";
const nuxtApp = useNuxtApp();
import Modal from "~/components/Modal.vue";
import Template1 from "~/components/marketing/email/Template1.vue";
import SvgIcon from "~/components/SvgIcon.vue";
import Editor from "~/components/common/Editor.vue";
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const $getpath = nuxtApp.$getpath as (message?: string) => void;
const $getEditRights = nuxtApp.$getEditRights as () => boolean;
// const { $getpath, $snackbar} = useNuxtApp()
import { ref, watch, nextTick } from "vue";
import axios from "axios";
import Modal from "~/components/Modal.vue";
import Template1 from "~/lib/email/templates/Template1.vue";
import SvgIcon from "~/components/SvgIcon.vue";
import Editor from "~/components/common/Editor.vue";
const apiUrl = `${$getpath()}data`;
const sendEmailUrl = `${$getpath()}send-email`;
const showmodal = ref();
// Types
@@ -566,11 +559,7 @@ const handleSendEmail = async () => {
}
} catch (error) {
console.error("Error sending email:", error);
const errorMsg = axios.isAxiosError(error)
? error.response?.data?.error || error.message
: "Unknown error occurred";
$snackbar(`Failed to send email: ${errorMsg}`);
$snackbar(`Failed to send email: ${error.message}`);
} finally {
loading.value = false;
}
@@ -618,15 +607,10 @@ const getData = async () => {
const emailListUrl = `${apiUrl}/Email_List/`;
try {
const response = await axios.get(emailListUrl);
if (response.status === 200) {
console.log(response);
dataEmails.value = response.data.rows;
console.log(dataEmails.value);
} else {
throw new Error("Failed to fetch email list");
}
const response = await $fetch(emailListUrl);
dataEmails.value = response.rows;
console.log(response);
console.log(dataEmails.value);
} catch (error) {
console.error("Error fetching email list:", error);
throw error;

View File

@@ -1,287 +0,0 @@
<template>
<div
v-if="templateEmailContent"
class="view-email"
>
<Template1
v-bind="templateProps"
previewMode
/>
<div class="action mt-3">
<button
class="button is-info mx-3 has-text-white"
:class="{ 'is-loading': isLoading || workflowIsLoading }"
@click="handleSendEmail()"
>
<span>Gửi mail</span>
</button>
</div>
</div>
<div v-else>Chưa nội dung email</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { render } from "@vue-email/render";
import Template1 from "~/lib/email/templates/Template1.vue";
const {
$insertapi,
$snackbar,
$getdata,
$mode,
$findapi,
$getapi,
$numtoString,
$numberToVietnamese,
$numberToVietnameseCurrency,
$formatDateVN,
$getFirstAndLastName,
$store,
$paymentQR,
} = useNuxtApp();
const isLoading = ref(false);
const templateEmailContent = ref(null);
const props = defineProps({
data: Object,
idEmailTemplate: Number,
scheduleItemId: Number,
});
const isVietnamese = computed(() => $store.lang.toLowerCase() === "vi");
const paymentScheduleItem = ref(null);
const contentPaymentQR = ref("");
const emailTemplate = await $getdata("emailtemplate", { filter: { id: props.idEmailTemplate }, first: true });
templateEmailContent.value = emailTemplate[0] ?? null;
let foundPaymentSchedule = $findapi("payment_schedule");
foundPaymentSchedule.params = {
values:
"id,code,from_date,to_date,amount,cycle,paid_amount,remain_amount,txn_detail,txn_detail__transaction,txn_detail__transaction__customer,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__phone,txn_detail__transaction__customer__email,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__legal_code,txn_detail__transaction__product__trade_code",
filter: { id: props.scheduleItemId },
};
async function paymentSchedule() {
try {
const [paymentScheduleRes] = await $getapi([foundPaymentSchedule]);
paymentScheduleItem.value = paymentScheduleRes?.data?.rows[0] || null;
contentPaymentQR.value = buildContentPayment(paymentScheduleItem.value);
} catch (error) {
if ($mode === "dev") {
console.error("Call api product error", error);
}
}
}
paymentSchedule();
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}`}`;
}
};
const templateProps = computed(() => {
let content = templateEmailContent.value.content.content || "";
// 1⃣ XÓA TOÀN BỘ QR CŨ
content = content.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, "");
// 2⃣ CHỈ APPEND 1 QR CUỐI CÙNG (NẾU CÓ URL)
if ($paymentQR(contentPaymentQR.value)) {
content = `
${content.trim()}
${buildQrHtml($paymentQR(contentPaymentQR.value))}
`;
}
return {
content: {
subject: templateEmailContent.value.content.subject || "Thông báo mới",
message: replaceTemplateVars(content) || "Bạn có một thông báo mới.",
imageUrl: templateEmailContent.value.content.imageUrl || null,
linkUrl: templateEmailContent.value.content.linkUrl || [""],
textLinkUrl: templateEmailContent.value.content.textLinkUrl || [""],
keyword: Array.isArray(templateEmailContent.value.content.keyword)
? templateEmailContent.value.content.keyword.map((k) => (typeof k === "string" ? { keyword: k, value: "" } : k))
: [{ keyword: "", value: "" }],
},
previewMode: true,
};
});
const handleSendEmail = async () => {
isLoading.value = true;
const tempEm = {
value: {
...templateProps.value,
content: {
...templateProps.value.content,
},
},
};
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
tempEm.value.content.message = quillToEmailHtml(templateProps.value.content.message);
let emailHtml = await render(Template1, tempEm.value);
// If no image URL provided, remove image section from HTML
if ((templateProps.value.content.imageUrl ?? "").trim() === "") {
emailHtml = emailHtml.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, "");
emailHtml = emailHtml.replace(/\n\s*\n\s*\n/g, "\n\n");
}
// Replace keywords in HTML
let finalEmailHtml = emailHtml;
if (templateProps.value.content.keyword && templateProps.value.content.keyword.length > 0) {
templateProps.value.content.keyword.forEach(({ keyword, value }) => {
if (keyword && value) {
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, "g");
finalEmailHtml = finalEmailHtml.replace(regex, value);
}
});
}
const response = await $insertapi("sendemail", {
data: {
to: paymentScheduleItem.value?.txn_detail__transaction__customer__email,
content: finalEmailHtml,
subject: replaceTemplateVars(templateProps.value.content.subject) || "Thông báo từ Utopia Villas & Resort",
},
notify: false,
});
if (response !== null) {
isLoading.value = false;
$snackbar(
isVietnamese
? `Thông báo đã được gửi thành công đến khách hàng.`
: `The notification has been successfully sent to the customer.`,
);
}
};
function replaceTemplateVars(html) {
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, paymentScheduleItem.value?.txn_detail__transaction__product__trade_code)
.replace(
/\[product\.trade_code_payment\]/g,
sanitizeContentPayment(paymentScheduleItem.value?.txn_detail__transaction__product__trade_code),
)
.replace(/\[customer\.fullname\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__fullname)
.replace(
/\[customer\.name\]/g,
`${paymentScheduleItem.value?.txn_detail__transaction__customer__type__code.toLowerCase() == "cn" ? (paymentScheduleItem.value?.txn_detail__transaction__customer__fullname.length < 14 ? paymentScheduleItem.value?.txn_detail__transaction__customer__fullname : $getFirstAndLastName(paymentScheduleItem.value.txn_detail__transaction__customer__fullname)) : ""}` ||
"",
)
.replace(/\[customer\.code\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__code || "")
.replace(
/\[customer\.legal_code\]/g,
paymentScheduleItem.value?.txn_detail__transaction__customer__legal_code || "",
)
.replace(
/\[customer\.contact_address\]/g,
paymentScheduleItem.value?.txn_detail__transaction__customer__contact_address ||
paymentScheduleItem.value?.txn_detail__transaction__customer__address ||
"",
)
.replace(/\[customer\.phone\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__phone || "")
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(paymentScheduleItem.value?.from_date) || "")
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(paymentScheduleItem.value?.to_date) || "")
.replace(/\[payment_schedule\.amount\]/g, $numtoString(paymentScheduleItem.value?.remain_amount) || "")
.replace(
/\[payment_schedule\.amount_in_word\]/g,
$numberToVietnameseCurrency(paymentScheduleItem.value?.remain_amount) || "",
)
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(paymentScheduleItem.value?.cycle) || "")
.replace(
/\[payment_schedule\.cycle-in-words\]/g,
`${paymentScheduleItem.value?.cycle == 0 ? "đặt cọc" : `đợt thứ ${$numberToVietnamese(paymentScheduleItem.value?.cycle).toLowerCase()}`}` ||
"",
)
.replace(
/\[payment_schedule\.note\]/g,
`${paymentScheduleItem.value?.cycle == 0 ? "Dat coc" : `Dot ${paymentScheduleItem.value?.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, "")
);
}
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);
}
</script>
<style>
.view-email .view-email-wrapper {
border: 1px solid #ccc;
padding: 16px;
border-radius: 8px;
background-color: #f9f9f9;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 80%;
overflow-y: auto;
margin-bottom: 15px;
margin: 0 auto;
}
.action {
text-align: center;
}
</style>

View File

@@ -1,106 +0,0 @@
<template>
<div class="has-text-centered">
<p class="">
Bạn chắc chắn muốn chuyển sản phẩm này sang trạng thái<br />
<strong :class="newStatus === 2 ? 'has-text-success' : 'has-text-danger'">
{{ newStatus === 2 ? "ĐANG BÁN" : "KHÓA" }}
</strong>
không?
</p>
<hr class="my-3" />
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button
class="button"
:class="newStatus === 2 ? 'is-success' : 'is-danger'"
:disabled="isSaving"
@click="confirmChange"
>
<span v-if="isSaving">Đang xử lý...</span>
<span v-else>Đồng ý</span>
</button>
</div>
<div class="control">
<button
class="button is-dark"
:disabled="isSaving"
@click="close"
>
Hủy bỏ
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useNuxtApp } from "#app";
const emit = defineEmits(["close", "modalevent"]);
const props = defineProps({
product: {
type: Object,
required: true,
},
});
const { $patchapi, $snackbar } = useNuxtApp();
const isSaving = ref(false);
const currentStatus = computed(() => Number(props.product?.status) || null);
const newStatus = computed(() => (currentStatus.value === 15 ? 2 : 15));
async function confirmChange() {
if (!props.product?.id || newStatus.value === currentStatus.value) {
close();
return;
}
isSaving.value = true;
try {
const result = await $patchapi(
"product",
{
id: props.product.id,
status: newStatus.value,
},
{},
false,
);
if (result === "error" || !result) {
$snackbar("Cập nhật thất bại", "Error");
return;
}
$snackbar("Cập nhật trạng thái thành công", "Success");
// Phát sự kiện để component cha (hoặc bảng) cập nhật lại dữ liệu
emit("modalevent", {
name: "update",
data: {
id: props.product.id,
status: newStatus.value,
},
});
close();
} catch (error) {
console.error("Lỗi đổi trạng thái:", error);
$snackbar("Có lỗi xảy ra", "Error");
} finally {
isSaving.value = false;
}
}
function close() {
emit("close");
}
</script>

View File

@@ -1,354 +0,0 @@
<template>
<div class="container-fluid">
<div
class="mt-0"
v-if="loading"
>
<p>{{ isVietnamese ? "Đang tải dữ liệu..." : "Loading..." }}</p>
</div>
<div
class="mt-0"
v-else-if="entries.length === 0"
>
<p>{{ isVietnamese ? "Không có dữ liệu" : "No data" }}</p>
</div>
<div v-else>
<div
v-for="(entry, index) in entries"
:key="entry.code || index"
class="entry-item mb-5"
>
<h3 class="title is-5 mb-3">{{ isVietnamese ? "Bút toán" : "Entry" }} #{{ index + 1 }}</h3>
<div class="columns is-multiline mx-0">
<!-- bút toán -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Mã bút toán" : "Code" }}</label>
<div class="control">
<span
class="hyperlink"
@click="$copyToClipboard(entry.code)"
>{{ entry.code }}</span
>
</div>
</div>
</div>
<!-- Ngày bút toán -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Ngày bút toán" : "Date" }}</label>
<div class="control">
<span>{{ entry.date ? $dayjs(entry.date).format("L") : "/" }}</span>
</div>
</div>
</div>
<!-- Amount -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Số tiền" : "Amount" }}</label>
<div class="control">
<span>
{{ $numtoString(entry.amount) }}
</span>
</div>
</div>
</div>
<!-- quỹ -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Mã quỹ" : "Account Code" }}</label>
<div class="control">
<span
class="hyperlink"
@click="$copyToClipboard(entry.account__code)"
>
{{ entry.account__code || "/" }}
</span>
</div>
</div>
</div>
<!-- Tên quỹ -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Tên quỹ" : "Account Type" }}</label>
<div class="control">
<span>{{ entry.account__type__name || "/" }}</span>
</div>
</div>
</div>
<!-- Loại bút toán -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Loại bút toán" : "Category" }}</label>
<div class="control">
<span>{{ entry.category__name || "/" }}</span>
</div>
</div>
</div>
<!-- Loại tiền -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Loại tiền" : "Currency" }}</label>
<div class="control">
<span>{{ entry.account__currency__code || "/" }}</span>
</div>
</div>
</div>
<!-- trước -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Dư trước" : "Balance Before" }}</label>
<div class="control">
<span>
{{ $numtoString(entry.balance_before) }}
</span>
</div>
</div>
</div>
<!-- Ghi -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Ghi có" : "Credit" }}</label>
<div class="control">
<span v-if="entry.type === 1">
{{ $numtoString(entry.amount) }}
</span>
</div>
</div>
</div>
<!-- Trích nợ (Ghi nợ) -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Ghi nợ" : "Debit" }}</label>
<div class="control">
<span v-if="entry.type === 2">
{{ $numtoString(entry.amount) }}
</span>
</div>
</div>
</div>
<!-- sau -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Dư sau" : "Balance After" }}</label>
<div class="control">
<span>
{{ $numtoString(entry.balance_after) }}
</span>
</div>
</div>
</div>
<!-- Loại (Ghi /ghi nợ) -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Loại" : "Type" }}</label>
<div class="control">
<span>{{ entry.type__name || "/" }}</span>
</div>
</div>
</div>
<!-- Người nhập -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Người nhập" : "Inputer" }}</label>
<div class="control">
<span
class="hyperlink"
@click="openUser(entry.inputer)"
>
{{ entry.inputer__fullname || "/" }}
</span>
</div>
</div>
</div>
<!-- Người duyệt -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Người duyệt" : "Approver" }}</label>
<div class="control">
<span
class="hyperlink"
@click="openUser(entry.approver)"
>
{{ entry.approver__fullname || "/" }}
</span>
</div>
</div>
</div>
<!-- Ref -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label"> tham chiếu</label>
<div class="control">
<span>{{ entry.ref || "/" }}</span>
</div>
</div>
</div>
<!-- Thời gian -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Thời gian" : "Create Time" }}</label>
<div class="control">
<span>{{ entry.create_time ? $dayjs(entry.create_time).format("L HH:mm") : "/" }}</span>
</div>
</div>
</div>
<!-- Nội dung -->
<div class="column is-full pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Nội dung" : "Content" }}</label>
<div class="control">
<span>{{ entry.content || "/" }}</span>
</div>
</div>
</div>
</div>
<hr
v-if="index < entries.length - 1"
class="my-4"
/>
</div>
</div>
<Modal
@close="showmodal = undefined"
v-bind="showmodal"
v-if="showmodal"
></Modal>
</div>
</template>
<script>
import { useStore } from "~/stores/index";
export default {
setup() {
const store = useStore();
return { store };
},
props: {
entryList: {
type: Array,
default: () => [],
},
},
data() {
return {
entries: [],
loading: false,
showmodal: undefined,
};
},
computed: {
lang() {
return this.store.lang;
},
isVietnamese() {
return this.store.lang === "vi";
},
},
async created() {
await this.fetchEntries();
},
watch: {
entryList: {
handler() {
this.fetchEntries();
},
deep: true,
},
},
methods: {
parseEntryItem(item) {
// Nếu item đã là object thì return luôn
if (typeof item === "object" && item !== null) {
return item;
}
// Nếu item là string thì parse
if (typeof item === "string") {
try {
// Thay thế single quotes bằng double quotes để parse JSON
const jsonString = item.replace(/'/g, '"');
return JSON.parse(jsonString);
} catch (error) {
console.error("Lỗi khi parse string thành JSON:", item, error);
return null;
}
}
return null;
},
async fetchEntries() {
const { $getdata } = useNuxtApp();
if (!this.entryList || this.entryList.length === 0) {
this.entries = [];
return;
}
this.loading = true;
this.entries = [];
try {
// Duyệt qua từng item trong entryList để fetch dữ liệu
for (const item of this.entryList) {
// Parse item từ string sang object nếu cần
const parsedItem = this.parseEntryItem(item);
if (parsedItem && parsedItem.code) {
try {
const entryData = await $getdata("internalentry", {
first: true,
filter: { code: parsedItem.code },
});
if (entryData) {
this.entries.push(entryData);
}
} catch (error) {
console.error(`Lỗi khi load bút toán với code ${parsedItem.code}:`, error);
}
}
}
console.log(`Đã load ${this.entries.length} bút toán thành công`);
} catch (error) {
console.error("Lỗi khi load danh sách bút toán:", error);
} finally {
this.loading = false;
}
},
openUser(userId) {
if (!userId) return;
this.showmodal = {
component: "user/UserInfo",
width: "50%",
height: "200px",
title: "User",
vbind: { userId: userId },
};
},
},
};
</script>

View File

@@ -1,21 +0,0 @@
<template>
<ViewList
:vbind="{
api: 'payment_schedule',
params: {
filter: { txn_detail: scheduleDetailId },
values:
'link,ref_code,batch_date,amount_remain,penalty_remain,penalty_paid,penalty_amount,penalty_reduce,ovd_days,remain_amount,paid_amount,txn_detail__transaction__product__trade_code,txn_detail__status,txn_detail__transaction__product__code,txn_detail__phase__name,txn_detail,id,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__code,txn_detail__transaction__customer__legal_code,status__name,type__name,code,from_date,txn_detail__transaction__policy__code,to_date,amount,cycle,cycle_days,txn_detail__transaction,type,status,updater,entry,detail,txn_detail__transaction__code,txn_detail__code',
},
setting: 'payment_schedule_list_timeline',
}"
/>
</template>
<script>
export default {
props: {
scheduleDetailId: [Number, String],
},
};
</script>

View File

@@ -1,245 +0,0 @@
<template>
<div class="columns is-multiline is-mobile mx-0">
<div class="column is-3">
<div class="field">
<label class="label has-text-left">ID<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input
class="input"
type="text"
v-model="record.id"
disabled
/>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label has-text-left"> giữ chỗ<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input
class="input"
type="text"
v-model="record.code"
/>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label has-text-left"> giao dịch<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input
class="input"
type="text"
v-model="record.transaction__code"
/>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label has-text-left">Người tạo<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input
class="input"
type="text"
v-model="record.creator__fullname"
disabled
/>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left">Ngày bắt đầu</label>
<div class="control">
<input
class="input"
type="date"
v-model="record.date"
/>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left">Hạn thanh toán</label>
<div class="control">
<input
class="input"
type="date"
v-model="record.due_date"
/>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left"> tiền đặt cọc</label>
<div class="control">
<input
class="input"
type="number"
v-model="record.amount"
/>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left">Trạng thái</label>
<div class="control">
<SearchBox
v-bind="{
api: 'status',
field: 'name',
column: ['name'],
optionid: record.approve_status,
first: true,
}"
@option="selectedStatus"
/>
</div>
</div>
</div>
<div class="column is-12 pt-5">
<a
class="button is-primary has-text-white"
@click="updateData()"
>Lưu lại</a
>
<a
class="button is-dark has-text-white ml-5"
@click="updateData(true)"
v-if="record.id"
>Tạo mới</a
>
<a
v-if="contractData"
class="button is-info has-text-white ml-5"
@click="openContractModal"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'contract.svg', type: 'primary', size: 18 }" />
</span>
<span>Xem hợp đồng</span>
</a>
</div>
</div>
<Modal
@close="showmodal = undefined"
v-bind="showmodal"
@texteditor="updateText"
@update="updateAttr"
@dataevent="handleModalEvent"
v-if="showmodal"
/>
</template>
<script setup>
const emit = defineEmits([]);
var props = defineProps({
pagename: String,
row: Object,
api: String,
});
const { $copy, $resetNull, $insertrow, $updaterow, $getdata } = useNuxtApp();
var record = ref(props.row ? $copy(props.row) : {});
var showmodal = ref();
var vapi = props.api || "reservation";
var contractData = ref(null);
const loadContract = async () => {
if (record.value.transaction__code) {
try {
contractData.value = await $getdata("contract", {
first: true,
filter: { transaction__code: record.value.transaction__code },
});
} catch (error) {
console.error("Error loading contract:", error);
contractData.value = null;
}
}
};
const contractModalConfig = computed(() => ({
component: "application/Contract",
title: "Hợp đồng",
width: "90%",
height: "90vh",
vbind: {
row: contractData.value,
api: "transaction",
},
}));
const openContractModal = () => {
showmodal.value = contractModalConfig.value;
};
const handleModalEvent = (eventData) => {
if (eventData?.data?.transaction) {
contractData.value = { ...contractData.value, ...eventData.data };
}
};
var selectedStatus = function (option) {
record.value.approve_status = option.id;
};
var updateText = function (content) {
record.value.vi = content;
};
var openEditor = function () {
showmodal.value = {
component: "common/TextEditor",
vbind: { content: record.value.vi },
title: "Text editor",
width: "40%",
height: "150px",
};
};
var editDetail = function () {
let detail = record.value.detail ? record.value.detail : {};
showmodal.value = {
component: "datatable/FieldAttribute",
vbind: { field: detail, close: true },
title: "Sửa thuộc tính",
width: "40%",
height: "150px",
};
};
var updateAttr = function (detail) {
record.value.detail = detail;
};
var updateData = async function (isNew) {
let ele = record.value;
if (ele.create_time === null) ele.create_time = new Date();
ele = $resetNull(ele);
if (isNew) delete ele.id;
let result = ele.id
? await $updaterow(vapi, ele, undefined, props.pagename)
: await $insertrow(vapi, ele, undefined, props.pagename);
if (isNew) emit("close");
await loadContract();
};
onMounted(() => {
loadContract();
});
watch(
() => record.value.transaction,
() => {
loadContract();
},
);
</script>

View File

@@ -1,198 +0,0 @@
<template>
<div class="columns mx-0 px-0 py-2">
<!-- Tab Navigation -->
<div
:class="`column is-narrow p-0 pr-4 ${viewport < 2 ? 'px-0' : ''}`"
:style="`${viewport < 2 ? '' : 'border-right: 1px solid #B0B0B0;'}`"
>
<div
:class="['is-clickable p-3', i !== 0 && 'mt-2', getStyle(v)]"
style="width: 120px; border-radius: 4px"
v-for="(v, i) in tabsArray"
:key="i"
@click="changeTab(v)"
>
{{ isVietnamese ? v.name : v.en }}
</div>
</div>
<!-- Tab Content -->
<div
:class="`column pl-4 ${viewport < 2 ? 'px-0' : 'pr-0 py-0'}`"
style="min-width: 0"
>
<div
v-if="isLoading"
class="has-text-centered"
>
<span class="icon is-large">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
</span>
</div>
<component
:is="getComponent(activeComponent.component)"
v-else-if="activeComponent && activeComponent.component"
v-bind="componentProps"
@update="handleUpdate"
@close="emit('close')"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, defineAsyncComponent, watch } from "vue";
import { useStore } from "~/stores/index";
import { useNuxtApp } from "#app";
const props = defineProps({
tabs: [Array, Object],
row: Object,
pagename: String,
application: Object,
});
const emit = defineEmits(["modalevent", "close"]);
const store = useStore();
const nuxtApp = useNuxtApp();
const { $dialog, $calculate, $findapi, $getapi, $copy } = nuxtApp;
const tabsArray = computed(() => {
if (Array.isArray(props.tabs)) {
return props.tabs;
}
if (typeof props.tabs === "object" && props.tabs !== null) {
return Object.keys(props.tabs)
.sort()
.map((key) => props.tabs[key]);
}
return [];
});
const modules = import.meta.glob("~/components/**/*.vue");
function getComponent(path) {
if (!path || typeof path !== "string" || !path.includes("/")) return null;
const moduleKey = Object.keys(modules).find((key) => key.endsWith(`${path}.vue`));
if (moduleKey) return defineAsyncComponent(modules[moduleKey]);
console.warn(`Component not found: ${path}`);
return null;
}
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const viewport = computed(() => store.viewport);
const record = ref(props.row || {});
const activeTab = ref(
tabsArray.value.find((t) => t.active === true || t.active === "true")?.code || tabsArray.value[0]?.code || "",
);
const tabData = ref(null);
const isLoading = ref(false);
const activeComponent = computed(() => tabsArray.value.find((t) => t.code === activeTab.value));
const componentProps = computed(() => {
const baseProps = {
row: record.value,
data: tabData.value,
pagename: props.pagename,
application: props.application,
};
const tabSpecificProps = activeComponent.value?.vbind || {};
const processedProps = {};
if (record.value) {
for (const key in tabSpecificProps) {
const value = tabSpecificProps[key];
if (typeof value === "string" && value.startsWith("row.")) {
const recordKey = value.substring(4);
processedProps[key] = record.value[recordKey];
} else {
processedProps[key] = value;
}
}
}
return { ...baseProps, ...processedProps };
});
watch(
activeTab,
async (newTabCode) => {
const tabConfig = tabsArray.value.find((t) => t.code === newTabCode);
if (tabConfig && tabConfig.api) {
await fetchTabData(tabConfig);
} else {
tabData.value = null;
}
},
{ immediate: true },
);
async function fetchTabData(tabConfig) {
isLoading.value = true;
tabData.value = null;
try {
let conn = $findapi(tabConfig.api.name);
if (!conn) {
console.error(`API connection '${tabConfig.api.name}' not found.`);
return;
}
conn = $copy(conn);
if (tabConfig.api.params) {
let params = $copy(tabConfig.api.params);
for (const key in params.filter) {
const value = params.filter[key];
if (typeof value === "string" && value.startsWith("record.")) {
const recordKey = value.substring(7);
params.filter[key] = record.value[recordKey];
}
}
conn.params = params;
}
const result = await $getapi([conn]);
const apiResult = result.find((r) => r.name === tabConfig.api.name);
if (apiResult && apiResult.data) {
tabData.value = apiResult.data.rows;
}
} catch (error) {
console.error("Error fetching tab data:", error);
} finally {
isLoading.value = false;
}
}
function getStyle(tab) {
const canEnter = checkCondition(tab, false);
return tab.code === activeTab.value
? "has-background-primary has-text-white"
: `has-background-light ${canEnter ? "" : "has-text-grey-light"}`;
}
function checkCondition(tab, showAlert = true) {
if (!tab.condition) return true;
const context = { record: record.value };
const result = $calculate(context, [], tab.condition);
if (!result.success || !result.value) {
if (showAlert) {
$dialog("Vui lòng <b>lưu dữ liệu</b> hoặc hoàn tất các bước trước khi chuyển sang mục này.", "Thông báo");
}
return false;
}
return true;
}
function changeTab(tab) {
if (activeTab.value === tab.code) return;
if (!checkCondition(tab)) return;
activeTab.value = tab.code;
}
function handleUpdate(updatedRecord) {
record.value = { ...record.value, ...updatedRecord };
emit("modalevent", { name: "dataevent", data: record.value });
}
</script>