changes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 có 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>
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div class="has-text-centered">
|
||||
<p class="">
|
||||
Bạn có 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>
|
||||
@@ -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">
|
||||
<!-- Mã 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>
|
||||
|
||||
<!-- Mã 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>
|
||||
|
||||
<!-- Dư 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 có -->
|
||||
<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>
|
||||
|
||||
<!-- Dư 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 có/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">Mã 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>
|
||||
@@ -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>
|
||||
@@ -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">Mã 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">Mã 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">Sô 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>
|
||||
@@ -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>
|
||||
63
app/error.vue
Normal file
63
app/error.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
error: Object,
|
||||
});
|
||||
console.error(props.error);
|
||||
const router = useRouter();
|
||||
const { $createMeta } = useNuxtApp();
|
||||
const description =
|
||||
"Rất tiếc! Có vẻ như đã xảy ra sự cố. Vui lòng thử lại hoặc liên hệ quản trị viên nếu lỗi tiếp tục.";
|
||||
useHead(
|
||||
$createMeta({
|
||||
title: props.error.message,
|
||||
description,
|
||||
image: "/favicon.svg",
|
||||
type: "website",
|
||||
}),
|
||||
);
|
||||
|
||||
function reload() {
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="container h-dvh is-flex is-gap-2 is-flex-direction-column is-justify-content-center is-align-items-center"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:warning-rounded"
|
||||
:size="50"
|
||||
/>
|
||||
<h1 class="font-semibold is-size-3">Đã xảy ra sự cố</h1>
|
||||
<p class="has-text-grey">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button
|
||||
@click="reload"
|
||||
class="button"
|
||||
>
|
||||
<span class="icon">
|
||||
<Icon
|
||||
name="material-symbols:refresh-rounded"
|
||||
:size="18"
|
||||
/>
|
||||
</span>
|
||||
<span>Thử lại</span>
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/')"
|
||||
class="button"
|
||||
>
|
||||
<span class="icon">
|
||||
<Icon
|
||||
name="material-symbols:home-outline-rounded"
|
||||
:size="18"
|
||||
/>
|
||||
</span>
|
||||
<span>Trang chủ</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,35 +1,30 @@
|
||||
<template>
|
||||
<slot></slot>
|
||||
<Transition>
|
||||
<SnackBar
|
||||
v-if="$store.snackbar"
|
||||
v-bind="$store.snackbar"
|
||||
@close="$store.snackbar = undefined"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
data-theme="light"
|
||||
lang="vi"
|
||||
id="modals"
|
||||
class="absolute top-0"
|
||||
>
|
||||
<slot></slot>
|
||||
<Transition>
|
||||
<SnackBar
|
||||
v-if="$store.snackbar"
|
||||
v-bind="$store.snackbar"
|
||||
@close="$store.snackbar = undefined"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
id="modals"
|
||||
class="absolute top-0"
|
||||
>
|
||||
<Modal
|
||||
v-if="$store.showmodal"
|
||||
v-bind="$store.showmodal"
|
||||
@close="$store.showmodal = undefined"
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
v-if="$store.showmodal"
|
||||
v-bind="$store.showmodal"
|
||||
@close="$store.showmodal = undefined"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { throttle } from "es-toolkit";
|
||||
import SnackBar from "~/components/snackbar/SnackBar.vue";
|
||||
import Modal from "~/components/Modal.vue";
|
||||
import { throttle } from "es-toolkit";
|
||||
|
||||
const route = useRoute();
|
||||
const { $getdata, $requestLogin, $store } = useNuxtApp();
|
||||
@@ -91,7 +86,7 @@ onMounted(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
<style scoped>
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<TopMenu @changetab="changeTab" />
|
||||
<div class="container blockdiv has-text-text-20">
|
||||
<TopMenu @changeTab="changeTab" />
|
||||
<div class="container">
|
||||
<div
|
||||
class="mb-2 is-flex is-justify-content-space-between is-align-items-center is-gap-1"
|
||||
v-if="tab"
|
||||
@@ -57,14 +57,6 @@
|
||||
</ClientOnly>
|
||||
</template>
|
||||
<script setup>
|
||||
useHead({
|
||||
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.svg" }],
|
||||
htmlAttrs: {
|
||||
class: "has-background-blue-100",
|
||||
style: "min-height: 100vh",
|
||||
},
|
||||
});
|
||||
|
||||
const { $createMeta, $store, $copy, $id } = useNuxtApp();
|
||||
const componentMap = {};
|
||||
const componentKey = ref();
|
||||
@@ -87,7 +79,7 @@ function changeTab(_tab, _subtab) {
|
||||
componentMap[vbind.component] = vbind.base || toPascalCase(vbind.component);
|
||||
componentKey.value = vbind.component;
|
||||
$store.commit("tabinfo", { tab, subtab, current: currentTab, vbind });
|
||||
let meta = {
|
||||
const meta = {
|
||||
title: currentTab[$store.lang],
|
||||
image: undefined,
|
||||
description: "Utopia",
|
||||
@@ -103,7 +95,7 @@ function refresh() {
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.blockdiv {
|
||||
.container {
|
||||
max-width: 1900px !important;
|
||||
padding: 1rem 2rem 2rem;
|
||||
@include mobile {
|
||||
|
||||
@@ -97,16 +97,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
summary: "annotate",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "payment_schedule",
|
||||
url: "data/Payment_Schedule/",
|
||||
url_detail: "data-detail/Payment_Schedule/",
|
||||
params: {
|
||||
sort: "-id",
|
||||
values:
|
||||
"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",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "people",
|
||||
url: "data/People/",
|
||||
@@ -122,25 +112,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
summary: "annotate",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "productnote",
|
||||
url: "data/Product_Note/",
|
||||
url_detail: "data-detail/Product_Note/",
|
||||
commit: "productnote",
|
||||
params: {
|
||||
sort: "id",
|
||||
values:
|
||||
"id,detail,user,user__username,user__fullname,create_time,update_time,ref,ref__cart__dealer,ref__trade_code",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "customernote",
|
||||
url: "data/Customer_Note/",
|
||||
url_detail: "data-detail/Customer_Note/",
|
||||
params: {
|
||||
values: "id,ref,detail,user,create_time,update_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "entrytype",
|
||||
url: "data/Entry_Type/",
|
||||
@@ -194,34 +165,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
url_detail: "data-detail/Biz_Rights/",
|
||||
params: { sort: "-id" },
|
||||
},
|
||||
{
|
||||
name: "feetype",
|
||||
url: "data/Fee_Type/",
|
||||
url_detail: "data-detail/Fee_Type/",
|
||||
params: {
|
||||
sort: "id",
|
||||
values: "id,index,code,name,type,value,type__code,type__name,method,method__code,method__name,create_time",
|
||||
distinct_values: {
|
||||
label: {
|
||||
type: "Concat",
|
||||
field: ["name", "method__name", "type__name", "value"],
|
||||
},
|
||||
},
|
||||
summary: "annotate",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dealersetting",
|
||||
url: "data/Biz_Setting/",
|
||||
url_detail: "data-detail/Biz_Setting/",
|
||||
params: { sort: "index" },
|
||||
},
|
||||
{
|
||||
name: "documentaudit",
|
||||
url: "data/Document_Audit/",
|
||||
url_detail: "data-detail/Document_Audit/",
|
||||
params: { sort: "index" },
|
||||
},
|
||||
{
|
||||
name: "documenttype",
|
||||
url: "data/Document_Type/",
|
||||
@@ -1647,6 +1590,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mode,
|
||||
getpath,
|
||||
findapi,
|
||||
readyapi,
|
||||
@@ -1670,7 +1614,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
getEditRights,
|
||||
connectWebSocket,
|
||||
subscribeToData,
|
||||
mode,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -59,7 +59,6 @@ import ChipImage from "~/components/media/ChipImage.vue";
|
||||
import Avatarbox from "~/components/common/Avatarbox.vue";
|
||||
import Email from "~/components/marketing/email/Email.vue";
|
||||
import ViewList from "~/components/common/ViewList.vue";
|
||||
import InternalEntry from "~/components/modal/InternalEntry.vue";
|
||||
import Configuration from "~/components/maintab/Configuration.vue";
|
||||
|
||||
// format
|
||||
@@ -82,8 +81,6 @@ import MenuNote from "~/components/menu/MenuNote.vue";
|
||||
import MenuFile from "~/components/menu/MenuFile.vue";
|
||||
import MenuPayment from "~/components/menu/MenuPayment.vue";
|
||||
import ScrollBox from "~/components/datatable/ScrollBox.vue";
|
||||
import Reservation from "~/components/modal/Reservation.vue";
|
||||
import UserMainTab from "~/components/modal/UserMainTab.vue";
|
||||
import CountWithAdd from "~/components/common/CountWithAdd.vue";
|
||||
import MenuAccount from "~/components/menu/MenuAccount.vue";
|
||||
import ImageLayout from "~/components/media/ImageLayout.vue";
|
||||
@@ -97,11 +94,9 @@ const components = {
|
||||
CustomerQuickAdd,
|
||||
CustomerForm,
|
||||
CountdownTimer,
|
||||
InternalEntry,
|
||||
ViewList,
|
||||
ColorText,
|
||||
CountWithAdd,
|
||||
Reservation,
|
||||
MenuAction,
|
||||
Email,
|
||||
SvgIcon,
|
||||
@@ -129,7 +124,6 @@ const components = {
|
||||
MenuAdd,
|
||||
MenuCollab,
|
||||
MenuNote,
|
||||
UserMainTab,
|
||||
MenuAccount,
|
||||
ImageLayout,
|
||||
MenuFile,
|
||||
|
||||
@@ -21,6 +21,5 @@ search_text(file, text2, newtext2)
|
||||
search_text(file, text3, newtext3)
|
||||
search_text(file, text4, newtext4)
|
||||
file = './app/assets/styles/main.scss'
|
||||
search_text(file, '/logo.png', '/logo_dev.png')
|
||||
file = './app/stores/index.js'
|
||||
search_text(file,'export const useStore = defineStore("main", {','export const useStore = defineStore("maindev", {')
|
||||
@@ -22,6 +22,5 @@ search_text(file, text2, newtext2)
|
||||
search_text(file, text3, newtext3)
|
||||
search_text(file, text4, newtext4)
|
||||
file = './app/assets/styles/main.scss'
|
||||
search_text(file, '/logo.png', '/logo_dev.png')
|
||||
file = './app/stores/index.js'
|
||||
search_text(file,'export const useStore = defineStore("main", {','export const useStore = defineStore("maindev", {')
|
||||
|
||||
@@ -21,6 +21,5 @@ search_text(file, text2, newtext2)
|
||||
search_text(file, text3, newtext3)
|
||||
search_text(file, text4, newtext4)
|
||||
file = './app/assets/styles/main.scss'
|
||||
search_text(file, '/logo_dev.png', '/logo.png')
|
||||
file = './app/stores/index.js'
|
||||
search_text(file,'export const useStore = defineStore("maindev", {','export const useStore = defineStore("main", {')
|
||||
|
||||
@@ -21,6 +21,5 @@ search_text(file, text2, newtext2)
|
||||
search_text(file, text3, newtext3)
|
||||
search_text(file, text4, newtext4)
|
||||
file = './app/assets/styles/main.scss'
|
||||
search_text(file, '/logo_dev.png', '/logo.png')
|
||||
file = './app/stores/index.js'
|
||||
search_text(file,'export const useStore = defineStore("maindev", {','export const useStore = defineStore("main", {')
|
||||
39
error.vue
39
error.vue
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="border-bottom mt-2">
|
||||
<nuxt-link to="/"
|
||||
><img
|
||||
width="100px;"
|
||||
src="/logo.png"
|
||||
/></nuxt-link>
|
||||
</div>
|
||||
<article class="media mt-5">
|
||||
<figure class="media-left">
|
||||
<p class="image is-48x48">
|
||||
<img src="/error.jpg" />
|
||||
</p>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<p class="mt-3 fs-17 has-text-black">
|
||||
{{ text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
const { $createMeta } = useNuxtApp();
|
||||
const text =
|
||||
"Rất tiếc! Có vẻ như đã xảy ra sự cố. Vui lòng thử lại hoặc liên hệ support@y99.vn nếu vấn đề vẫn tiếp diễn. Xin cảm ơn!";
|
||||
useHead(
|
||||
$createMeta({
|
||||
title: "Thông báo mới nhất | Y99",
|
||||
description: text,
|
||||
image: "/logo.png",
|
||||
type: "website",
|
||||
keywords: "Y99",
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
@@ -7,7 +7,12 @@ export default defineNuxtConfig({
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.svg" }],
|
||||
script: [{ src: "/js/html2pdf.bundle.min.js" }, { src: "/js/html2canvas.min.js" }],
|
||||
htmlAttrs: {
|
||||
lang: "vi",
|
||||
class: "theme-light has-background-blue-100 min-h-dvh",
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: ["@pinia/nuxt", "pinia-plugin-persistedstate/nuxt", "@nuxt/image", "nuxt-qrcode", "@nuxt/icon"],
|
||||
|
||||
BIN
public/error.jpg
BIN
public/error.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Reference in New Issue
Block a user