chore: install prettier

This commit is contained in:
Viet An
2026-05-04 15:22:27 +07:00
parent 93d29ca7d8
commit bd58e2b847
267 changed files with 22950 additions and 13581 deletions

View File

@@ -2,4 +2,4 @@ export const apiUrl = "https://api.utopia.com.vn/data";
export const putApiUrl = "https://api.utopia.com.vn/data-detail";
export const sendEmailUrl = "https://api.utopia.com.vn/send-email";
export const logoUrl = "https://bigdatatech.vn/logo.png";
export const imageUrl = "https://api.bigdatatech.vn/static/files/20251113051227-1.png";
export const imageUrl = "https://api.bigdatatech.vn/static/files/20251113051227-1.png";

View File

@@ -1,12 +1,18 @@
<template>
<div class="container is-fluid px-0" style="overflow: hidden">
<div
class="container is-fluid px-0"
style="overflow: hidden"
>
<!-- 95px is height of nav + breadcrumb -->
<div class="columns m-0" style="height: calc(100vh - 95px)">
<div
class="columns m-0"
style="height: calc(100vh - 95px)"
>
<!-- Form Section -->
<div class="column is-5 pb-5" style="
overflow-y: scroll;
border-right: 1px solid #dbdbdb;
">
<div
class="column is-5 pb-5"
style="overflow-y: scroll; border-right: 1px solid #dbdbdb"
>
<label class="label">
<span class="icon-text">
<span>Chọn mẫu</span>
@@ -15,12 +21,24 @@
<div class="level mb-4 mt-2 pr-4">
<!-- Template Selector -->
<div class="level-left" style="flex: 1">
<div class="level-item" style="flex: 1">
<div class="field" style="width: 100%">
<div
class="level-left"
style="flex: 1"
>
<div
class="level-item"
style="flex: 1"
>
<div
class="field"
style="width: 100%"
>
<div class="control">
<div class="select is-fullwidth">
<select v-model="selectedValue" @change="handleTemplateChange">
<select
v-model="selectedValue"
@change="handleTemplateChange"
>
<option value="defaultTemplate">Mặc định</option>
<option
v-for="template in dataTemplate"
@@ -37,10 +55,16 @@
</div>
<!-- Action Buttons -->
<div v-if="$getEditRights()" class="level-right">
<div
v-if="$getEditRights()"
class="level-right"
>
<div class="level-item">
<div class="buttons">
<button class="button is-light" @click="handleOpenModal">
<button
class="button is-light"
@click="handleOpenModal"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'save.svg', type: 'primary', size: 18 }"></SvgIcon>
</span>
@@ -87,7 +111,10 @@
/>
</div>
<div v-if="activeTab === 'mappings'">
<MappingConfigurator :mappings="formData.mappings" @update:mappings="updateMappings" />
<MappingConfigurator
:mappings="formData.mappings"
@update:mappings="updateMappings"
/>
</div>
<div v-if="activeTab === 'automation'">
<JobConfigurator :template-id="selectedTemplateId" />
@@ -95,25 +122,47 @@
</div>
<!-- Preview Section -->
<div class="column is-7 p-0" style="overflow-y: scroll">
<component :is="currentTemplateComponent" v-bind="templateProps" style="height: 100%" />
<div
class="column is-7 p-0"
style="overflow-y: scroll"
>
<component
:is="currentTemplateComponent"
v-bind="templateProps"
style="height: 100%"
/>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteDialog" class="modal is-active">
<div class="modal-background" @click="showDeleteDialog = false"></div>
<div
v-if="showDeleteDialog"
class="modal is-active"
>
<div
class="modal-background"
@click="showDeleteDialog = false"
></div>
<div class="modal-card">
<header class="modal-card-head p-4">
<p class="modal-card-title">Xác nhận xóa mẫu email</p>
<button class="delete" @click="showDeleteDialog = false"></button>
<button
class="delete"
@click="showDeleteDialog = false"
></button>
</header>
<section class="modal-card-body p-4">
<p class="mb-4">Bạn chắc chắn muốn xóa mẫu email này không? Hành động này không thể hoàn tác.</p>
<p class="has-text-weight-bold has-text-danger">Mẫu: {{ dataTemplateSelected?.name }}</p>
</section>
<footer class="modal-card-foot p-4">
<button class="button mr-2" @click="showDeleteDialog = false" :disabled="loading">Hủy</button>
<button
class="button mr-2"
@click="showDeleteDialog = false"
:disabled="loading"
>
Hủy
</button>
<button
class="button is-danger has-text-white"
@click="handleDeleteTemplate"
@@ -127,7 +176,11 @@
</div>
</div>
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
<Modal
@close="showmodal = undefined"
v-bind="showmodal"
v-if="showmodal"
></Modal>
</template>
<script setup lang="ts">
@@ -146,7 +199,7 @@ const $deleteapi = nuxtApp.$deleteapi as (name: string, id: any) => Promise<Data
const $getEditRights = nuxtApp.$getEditRights as () => boolean;
const showmodal = ref<any>();
const activeTab = ref("content");
const defaultTemplate = 'defaultTemplate';
const defaultTemplate = "defaultTemplate";
// Types
interface FormContent {
@@ -338,7 +391,7 @@ const handleDeleteTemplate = async () => {
try {
loading.value = true;
await $deleteapi('emailtemplate', selectedTemplateId.value);
await $deleteapi("emailtemplate", selectedTemplateId.value);
$snackbar(`Template deleted: ${dataTemplateSelected.value.name}`);
await fetchTemplates();
selectedValue.value = defaultTemplate;
@@ -354,9 +407,9 @@ const handleDeleteTemplate = async () => {
const fetchTemplates = async () => {
try {
dataTemplate.value = await $getdata('emailtemplate');
dataTemplate.value = await $getdata("emailtemplate");
} catch (error) {
$snackbar('Error: Failed to fetch templates');
$snackbar("Error: Failed to fetch templates");
console.error(error);
}
};
@@ -366,7 +419,11 @@ const handleOpenModal = () => {
if (editMode.value && originalLoadedTemplate.value) {
// EDIT MODE: Calculate a patch of changed data
const patchPayload: { id: number; name?: string; content?: Record<string, any> } = {
const patchPayload: {
id: number;
name?: string;
content?: Record<string, any>;
} = {
id: formData.value.id!,
};
@@ -376,10 +433,11 @@ const handleOpenModal = () => {
}
// 2. Reconstruct the content object, preserving all original fields
const originalContentObject = typeof originalLoadedTemplate.value.content === 'object'
? JSON.parse(JSON.stringify(originalLoadedTemplate.value.content))
: {};
const originalContentObject =
typeof originalLoadedTemplate.value.content === "object"
? JSON.parse(JSON.stringify(originalLoadedTemplate.value.content))
: {};
const newContentObject = {
...originalContentObject,
subject: formData.value.content.subject,
@@ -394,11 +452,10 @@ const handleOpenModal = () => {
// 3. Only include the 'content' field in the patch if it has actually changed
if (JSON.stringify(newContentObject) !== JSON.stringify(originalLoadedTemplate.value.content)) {
patchPayload.content = newContentObject;
patchPayload.content = newContentObject;
}
dataForModal = patchPayload;
dataForModal = patchPayload;
} else {
// CREATE MODE: Build the full object
const contentToSave = {

View File

@@ -5,23 +5,36 @@
<h5 class="title is-5">Automation Jobs</h5>
</div>
<div class="level-right">
<button class="button is-primary" @click="openNewJobForm">
<button
class="button is-primary"
@click="openNewJobForm"
>
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 18 }" />
<span class="ml-2">Add New Job</span>
</button>
</div>
</div>
<div v-if="isLoading" class="has-text-centered">
<div
v-if="isLoading"
class="has-text-centered"
>
<p>Loading jobs...</p>
</div>
<div v-else-if="jobs.length === 0" class="has-text-centered">
<div
v-else-if="jobs.length === 0"
class="has-text-centered"
>
<p>No automation jobs found for this template.</p>
</div>
<div v-else>
<div v-for="job in jobs" :key="job.id" class="mb-4 p-3 border">
<div
v-for="job in jobs"
:key="job.id"
class="mb-4 p-3 border"
>
<div class="level">
<div class="level-left">
<div>
@@ -29,25 +42,43 @@
<p class="is-size-7">Model: {{ job.model_name }}</p>
<p class="is-size-7">
Triggers:
<span v-if="job.trigger_on_create" class="tag is-info is-light mr-1">On Create</span>
<span v-if="job.trigger_on_update" class="tag is-warning is-light">On Update</span>
<span
v-if="job.trigger_on_create"
class="tag is-info is-light mr-1"
>On Create</span
>
<span
v-if="job.trigger_on_update"
class="tag is-warning is-light"
>On Update</span
>
</p>
</div>
</div>
<div class="level-right">
<div class="field has-addons">
<div class="control">
<button class="button is-small" :class="{'is-success': job.active, 'is-light': !job.active}" @click="toggleJobStatus(job)">
{{ job.active ? 'Active' : 'Inactive' }}
<button
class="button is-small"
:class="{ 'is-success': job.active, 'is-light': !job.active }"
@click="toggleJobStatus(job)"
>
{{ job.active ? "Active" : "Inactive" }}
</button>
</div>
<div class="control">
<button class="button is-small" @click="openEditJobForm(job)">
<button
class="button is-small"
@click="openEditJobForm(job)"
>
<SvgIcon v-bind="{ name: 'pen1.svg', type: 'primary', size: 18 }" />
</button>
</div>
<div class="control">
<button class="button is-danger is-small" @click="confirmDelete(job)">
<button
class="button is-danger is-small"
@click="confirmDelete(job)"
>
<SvgIcon v-bind="{ name: 'trash.svg', type: 'white', size: 18 }" />
</button>
</div>
@@ -58,54 +89,93 @@
</div>
<!-- Job Form Modal -->
<div v-if="showForm" class="modal is-active">
<div class="modal-background" @click="closeForm"></div>
<div
v-if="showForm"
class="modal is-active"
>
<div
class="modal-background"
@click="closeForm"
></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ isEditing ? 'Edit Job' : 'Create New Job' }}</p>
<button class="delete" @click="closeForm"></button>
<p class="modal-card-title">
{{ isEditing ? "Edit Job" : "Create New Job" }}
</p>
<button
class="delete"
@click="closeForm"
></button>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">Job Name</label>
<div class="control">
<input class="input" type="text" v-model="jobForm.name" placeholder="e.g., Notify on Transaction Update">
<input
class="input"
type="text"
v-model="jobForm.name"
placeholder="e.g., Notify on Transaction Update"
/>
</div>
</div>
<div class="field">
<label class="label">Model Name</label>
<div class="control">
<input class="input" type="text" v-model="jobForm.model_name" placeholder="e.g., app.Transaction_Detail">
<input
class="input"
type="text"
v-model="jobForm.model_name"
placeholder="e.g., app.Transaction_Detail"
/>
</div>
</div>
<div class="field">
<label class="label">Triggers</label>
<div class="control">
<label class="checkbox mr-4">
<input type="checkbox" v-model="jobForm.trigger_on_create">
<input
type="checkbox"
v-model="jobForm.trigger_on_create"
/>
On Create
</label>
<label class="checkbox">
<input type="checkbox" v-model="jobForm.trigger_on_update">
<input
type="checkbox"
v-model="jobForm.trigger_on_update"
/>
On Update
</label>
</div>
</div>
<div class="field">
<div class="field">
<label class="label">Status</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="jobForm.active">
<input
type="checkbox"
v-model="jobForm.active"
/>
Active
</label>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-success" @click="saveJob" :disabled="isSaving">
{{ isSaving ? 'Saving...' : 'Save' }}
<button
class="button is-success"
@click="saveJob"
:disabled="isSaving"
>
{{ isSaving ? "Saving..." : "Save" }}
</button>
<button
class="button"
@click="closeForm"
>
Cancel
</button>
<button class="button" @click="closeForm">Cancel</button>
</footer>
</div>
</div>
@@ -113,10 +183,10 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import axios from 'axios';
import { useNuxtApp } from 'nuxt/app';
import { apiUrl, putApiUrl } from '@/components/marketing/email/Email.utils';
import { ref, watch, onMounted } from "vue";
import axios from "axios";
import { useNuxtApp } from "nuxt/app";
import { apiUrl, putApiUrl } from "@/components/marketing/email/Email.utils";
const props = defineProps({
templateId: {
@@ -136,8 +206,8 @@ const isEditing = ref(false);
const defaultJobForm = () => ({
id: null,
name: '',
model_name: 'app.Transaction_Detail',
name: "",
model_name: "app.Transaction_Detail",
template: props.templateId,
trigger_on_create: false,
trigger_on_update: false,
@@ -211,32 +281,32 @@ const saveJob = async () => {
};
const toggleJobStatus = async (job: any) => {
const updatedJob = { ...job, active: !job.active };
try {
await axios.put(`${putApiUrl}/Email_Job/${job.id}`, updatedJob);
$snackbar(`Job status updated`);
await fetchJobs();
} catch (error) {
console.error("Error updating job status:", error);
$snackbar(`Error updating job status`);
}
const updatedJob = { ...job, active: !job.active };
try {
await axios.put(`${putApiUrl}/Email_Job/${job.id}`, updatedJob);
$snackbar(`Job status updated`);
await fetchJobs();
} catch (error) {
console.error("Error updating job status:", error);
$snackbar(`Error updating job status`);
}
};
const confirmDelete = (job: any) => {
if (confirm(`Are you sure you want to delete the job "${job.name}"?`)) {
deleteJob(job.id);
}
if (confirm(`Are you sure you want to delete the job "${job.name}"?`)) {
deleteJob(job.id);
}
};
const deleteJob = async (jobId: number) => {
try {
await axios.delete(`${putApiUrl}/Email_Job/${jobId}`);
$snackbar(`Job deleted successfully`);
await fetchJobs();
} catch (error) {
console.error("Error deleting job:", error);
$snackbar(`Error deleting job`);
}
try {
await axios.delete(`${putApiUrl}/Email_Job/${jobId}`);
$snackbar(`Job deleted successfully`);
await fetchJobs();
} catch (error) {
console.error("Error deleting job:", error);
$snackbar(`Error deleting job`);
}
};
onMounted(fetchJobs);

View File

@@ -1,12 +1,19 @@
<template>
<div class="box">
<div v-for="(mapping, mapIndex) in localMappings" :key="mapIndex" class="mb-5 p-4 border">
<div
v-for="(mapping, mapIndex) in localMappings"
:key="mapIndex"
class="mb-5 p-4 border"
>
<div class="level">
<div class="level-left">
<h5 class="title is-5">Mapping {{ mapIndex + 1 }}: {{ mapping.alias }}</h5>
</div>
<div class="level-right">
<button class="button is-danger is-small" @click="removeMapping(mapIndex)">
<button
class="button is-danger is-small"
@click="removeMapping(mapIndex)"
>
<span class="icon is-small">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
@@ -19,7 +26,12 @@
<div class="field">
<label class="label">Alias</label>
<div class="control">
<input class="input" type="text" v-model="mapping.alias" @input="update" />
<input
class="input"
type="text"
v-model="mapping.alias"
@input="update"
/>
</div>
</div>
</div>
@@ -27,7 +39,13 @@
<div class="field">
<label class="label">Model</label>
<div class="control">
<input class="input" type="text" v-model="mapping.model" @input="update" placeholder="e.g., app.Transaction" />
<input
class="input"
type="text"
v-model="mapping.model"
@input="update"
placeholder="e.g., app.Transaction"
/>
</div>
</div>
</div>
@@ -35,7 +53,13 @@
<div class="field">
<label class="label">Lookup Field</label>
<div class="control">
<input class="input" type="text" v-model="mapping.lookup_field" @input="update" placeholder="e.g., id" />
<input
class="input"
type="text"
v-model="mapping.lookup_field"
@input="update"
placeholder="e.g., id"
/>
</div>
</div>
</div>
@@ -43,7 +67,13 @@
<div class="field">
<label class="label">Lookup Value From</label>
<div class="control">
<input class="input" type="text" v-model="mapping.lookup_value_from" @input="update" placeholder="e.g., transaction_id or transaction.customer.id" />
<input
class="input"
type="text"
v-model="mapping.lookup_value_from"
@input="update"
placeholder="e.g., transaction_id or transaction.customer.id"
/>
</div>
</div>
</div>
@@ -52,7 +82,10 @@
<label class="label">Type</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="mapping.type" @change="update">
<select
v-model="mapping.type"
@change="update"
>
<option value="object">Object</option>
<option value="list">List</option>
</select>
@@ -60,12 +93,16 @@
</div>
</div>
</div>
<div class="column is-6">
<div class="column is-6">
<div class="field">
<label class="label">Is Trigger Object?</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="mapping.is_trigger_object" @change="update">
<label class="checkbox">
<input
type="checkbox"
v-model="mapping.is_trigger_object"
@change="update"
/>
Yes
</label>
</div>
@@ -73,36 +110,64 @@
</div>
</div>
<hr>
<hr />
<h6 class="title is-6">Fields</h6>
<div v-for="(field, fieldIndex) in mapping.fields" :key="fieldIndex" class="field has-addons">
<div
v-for="(field, fieldIndex) in mapping.fields"
:key="fieldIndex"
class="field has-addons"
>
<div class="control">
<input class="input" type="text" v-model="field.placeholder" @input="update" placeholder="[placeholder]">
<input
class="input"
type="text"
v-model="field.placeholder"
@input="update"
placeholder="[placeholder]"
/>
</div>
<div class="control is-expanded">
<input class="input" type="text" v-model="field.source" @input="update" placeholder="source.field.name">
<input
class="input"
type="text"
v-model="field.source"
@input="update"
placeholder="source.field.name"
/>
</div>
<div class="control">
<button class="button is-danger is-light" @click="removeField(mapIndex, fieldIndex as number)">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'primary'}"></SvgIcon>
<button
class="button is-danger is-light"
@click="removeField(mapIndex, fieldIndex as number)"
>
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'primary' }"></SvgIcon>
</button>
</div>
</div>
<button class="button is-small is-light" @click="addField(mapIndex)">
<button
class="button is-small is-light"
@click="addField(mapIndex)"
>
<span class="icon is-small">+</span>
<span>Add Field</span>
</button>
</div>
<button class="button is-primary" @click="addMapping">
<SvgIcon class="mr-2" v-bind="{ name: 'add4.svg', type: 'white', size: 18 }" />
<button
class="button is-primary"
@click="addMapping"
>
<SvgIcon
class="mr-2"
v-bind="{ name: 'add4.svg', type: 'white', size: 18 }"
/>
Add Mapping
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ref, watch } from "vue";
const props = defineProps({
mappings: {
@@ -111,22 +176,27 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:mappings']);
const emit = defineEmits(["update:mappings"]);
// Use a local ref to manage mappings to handle the object-to-array transformation for fields
const localMappings = ref<any[]>([]);
// Helper to transform fields object to array for v-for
const transformMappings = (sourceMappings: any[]) => {
return sourceMappings.map(m => ({
return sourceMappings.map((m) => ({
...m,
fields: m.fields ? Object.entries(m.fields).map(([placeholder, source]) => ({ placeholder, source: typeof source === 'object' ? JSON.stringify(source) : source })) : []
fields: m.fields
? Object.entries(m.fields).map(([placeholder, source]) => ({
placeholder,
source: typeof source === "object" ? JSON.stringify(source) : source,
}))
: [],
}));
};
// Helper to transform fields array back to object for emission
const reformatMappingsForEmit = () => {
return localMappings.value.map(m => {
return localMappings.value.map((m) => {
const newFields = m.fields.reduce((obj: any, item: any) => {
try {
// Attempt to parse if it's a JSON string for format objects
@@ -140,18 +210,21 @@ const reformatMappingsForEmit = () => {
});
};
watch(() => props.mappings, (newMappings) => {
localMappings.value = transformMappings(newMappings || []);
}, { immediate: true, deep: true });
watch(
() => props.mappings,
(newMappings) => {
localMappings.value = transformMappings(newMappings || []);
},
{ immediate: true, deep: true },
);
const addMapping = () => {
localMappings.value.push({
alias: '',
model: '',
lookup_field: 'id',
lookup_value_from: '',
type: 'object',
alias: "",
model: "",
lookup_field: "id",
lookup_value_from: "",
type: "object",
is_trigger_object: false,
fields: [],
});
@@ -167,7 +240,7 @@ const addField = (mapIndex: number) => {
if (!localMappings.value[mapIndex].fields) {
localMappings.value[mapIndex].fields = [];
}
localMappings.value[mapIndex].fields.push({ placeholder: '', source: '' });
localMappings.value[mapIndex].fields.push({ placeholder: "", source: "" });
update();
};
@@ -177,7 +250,7 @@ const removeField = (mapIndex: number, fieldIndex: number) => {
};
const update = () => {
emit('update:mappings', reformatMappingsForEmit());
emit("update:mappings", reformatMappingsForEmit());
};
</script>

View File

@@ -42,7 +42,10 @@
</thead>
<tbody>
<template v-if="loading">
<tr v-for="index in 5" :key="index">
<tr
v-for="index in 5"
:key="index"
>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
@@ -55,7 +58,10 @@
<tr>
<td colspan="6">
<div class="has-text-centered py-6">
<Mail :size="64" class="has-text-grey-light mb-4" />
<Mail
:size="64"
class="has-text-grey-light mb-4"
/>
<p class="title is-5">No emails sent yet</p>
<p class="subtitle is-6">Sent emails will appear here</p>
</div>
@@ -63,7 +69,12 @@
</tr>
</template>
<template v-else>
<tr v-for="email in emailsSent" :key="email.id" class="is-clickable" @click="handleViewDetail(email)">
<tr
v-for="email in emailsSent"
:key="email.id"
class="is-clickable"
@click="handleViewDetail(email)"
>
<td>
<span>{{ truncateText(email.receiver, 40) }}</span>
</td>
@@ -72,25 +83,37 @@
<div v-html="truncateText(stripHtml(extractMessageContent(email.content), 1000), 40)"></div>
</td>
<td>
<span v-if="email.status === 1" class="tag is-warning">
<span
v-if="email.status === 1"
class="tag is-warning"
>
<span class="icon is-small">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 12 }" />
</span>
<span>Pending</span>
</span>
<span v-else-if="email.status === 2" class="tag is-success">
<span
v-else-if="email.status === 2"
class="tag is-success"
>
<span class="icon is-small">
<CheckCircle2 :size="12" />
</span>
<span>Sent</span>
</span>
<span v-else-if="email.status === 3" class="tag is-danger">
<span
v-else-if="email.status === 3"
class="tag is-danger"
>
<span class="icon is-small">
<XCircle :size="12" />
</span>
<span>Failed</span>
</span>
<span v-else-if="email.status === 4" class="tag is-info">
<span
v-else-if="email.status === 4"
class="tag is-info"
>
<span class="icon is-small">
<Clock :size="12" />
</span>
@@ -105,7 +128,9 @@
</span>
{{ formatDate(email.create_time) }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.create_time) }}</p>
<p class="is-size-7 has-text-grey">
{{ getRelativeTime(email.create_time) }}
</p>
</div>
</td>
<td>
@@ -116,7 +141,9 @@
</span>
{{ formatDate(email.update_time || "") }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.update_time || "") || "---" }}</p>
<p class="is-size-7 has-text-grey">
{{ getRelativeTime(email.update_time || "") || "---" }}
</p>
</div>
</td>
</tr>
@@ -126,12 +153,24 @@
</div>
<!-- Detail Modal -->
<div v-if="showDetailModal && selectedEmail" class="modal is-active">
<div class="modal-background" @click="showDetailModal = false"></div>
<div class="modal-card" style="width: 90%; max-width: 1200px">
<div
v-if="showDetailModal && selectedEmail"
class="modal is-active"
>
<div
class="modal-background"
@click="showDetailModal = false"
></div>
<div
class="modal-card"
style="width: 90%; max-width: 1200px"
>
<header class="modal-card-head">
<p class="modal-card-title">Email Details</p>
<button class="delete" @click="showDetailModal = false"></button>
<button
class="delete"
@click="showDetailModal = false"
></button>
</header>
<section class="modal-card-body">
@@ -139,7 +178,10 @@
<label class="label">
Recipients ({{ selectedEmail.receiver.split(";").filter((e) => e.trim()).length }})
</label>
<div class="box" style="max-height: 150px; overflow-y: auto">
<div
class="box"
style="max-height: 150px; overflow-y: auto"
>
<p style="white-space: pre-wrap">{{ selectedEmail.receiver }}</p>
</div>
</div>
@@ -162,16 +204,34 @@
<div class="column">
<label class="label">Status</label>
<div>
<span v-if="selectedEmail.status === 1" class="tag is-warning">Pending</span>
<span v-else-if="selectedEmail.status === 2" class="tag is-success">Sent</span>
<span v-else-if="selectedEmail.status === 3" class="tag is-danger">Failed</span>
<span v-else-if="selectedEmail.status === 4" class="tag is-info">Scheduled</span>
<span
v-if="selectedEmail.status === 1"
class="tag is-warning"
>Pending</span
>
<span
v-else-if="selectedEmail.status === 2"
class="tag is-success"
>Sent</span
>
<span
v-else-if="selectedEmail.status === 3"
class="tag is-danger"
>Failed</span
>
<span
v-else-if="selectedEmail.status === 4"
class="tag is-info"
>Scheduled</span
>
</div>
</div>
<div class="column">
<label class="label">Sent At</label>
<p>{{ new Date(selectedEmail.create_time).toLocaleString("vi-VN") }}</p>
<p>
{{ new Date(selectedEmail.create_time).toLocaleString("vi-VN") }}
</p>
</div>
<div class="column">
@@ -192,7 +252,7 @@ import { ref, onMounted } from "vue";
import { formatDistanceToNow } from "date-fns";
import axios from "axios";
import { useNuxtApp } from "nuxt/app";
import { apiUrl } from '@/components/marketing/email/Email.utils';
import { apiUrl } from "@/components/marketing/email/Email.utils";
interface EmailSent {
id: number;

View File

@@ -10,18 +10,30 @@
</thead>
<tbody>
<template v-if="loading">
<tr v-for="index in 5" :key="index">
<tr
v-for="index in 5"
:key="index"
>
<td>
<div class="skeleton-loader" style="width: 150px; height: 20px"></div>
<div
class="skeleton-loader"
style="width: 150px; height: 20px"
></div>
</td>
<td>
<div class="skeleton-loader" style="width: 250px; height: 20px"></div>
<div
class="skeleton-loader"
style="width: 250px; height: 20px"
></div>
</td>
</tr>
</template>
<template v-else-if="dataEmail.length === 0">
<tr>
<td colspan="2" class="has-text-centered py-6">
<td
colspan="2"
class="has-text-centered py-6"
>
<div class="has-text-grey">
<p class="has-text-weight-medium">No saved lists found</p>
<p class="is-size-7 mt-2">Create a new list to get started</p>

View File

@@ -1,7 +1,13 @@
<template>
<div class="box" style="min-width: 400px">
<div
class="box"
style="min-width: 400px"
>
<div class="field">
<label class="label is-flex is-align-items-center mb-2" for="name">
<label
class="label is-flex is-align-items-center mb-2"
for="name"
>
Tên danh sách <span class="has-text-danger ml-1">*</span>
</label>
<div class="control">
@@ -19,7 +25,10 @@
</div>
<div class="field">
<label class="label is-flex is-align-items-center mb-2" for="email">
<label
class="label is-flex is-align-items-center mb-2"
for="email"
>
Địa chỉ email <span class="has-text-danger ml-1">*</span>
</label>
<div class="control">
@@ -38,7 +47,12 @@
<div class="is-flex is-justify-content-flex-end pt-2">
<div class="buttons">
<button v-if="isEditMode" class="button" :disabled="loading" @click="handleCreateNew">
<button
v-if="isEditMode"
class="button"
:disabled="loading"
@click="handleCreateNew"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'gray', size: 16 }"></SvgIcon>
</span>
@@ -51,8 +65,17 @@
:disabled="loading || !data.name.trim() || !data.email.trim()"
>
<span v-if="loading"></span>
<span class="icon" v-else>
<SvgIcon v-bind="{ name: isEditMode ? 'save.svg' : 'add4.svg', type: 'white', size: 16 }"></SvgIcon>
<span
class="icon"
v-else
>
<SvgIcon
v-bind="{
name: isEditMode ? 'save.svg' : 'add4.svg',
type: 'white',
size: 16,
}"
></SvgIcon>
</span>
<span>{{
loading
@@ -60,8 +83,8 @@
? "Đang cập nhật..."
: "Đang tạo..."
: isEditMode
? "Cập nhật danh sách"
: "Tạo danh sách"
? "Cập nhật danh sách"
: "Tạo danh sách"
}}</span>
</button>
</div>
@@ -73,7 +96,7 @@
import { ref, computed, watch, nextTick } from "vue";
import axios from "axios";
import { useNuxtApp } from "nuxt/app";
import { apiUrl, putApiUrl } from '@/components/marketing/email/Email.utils';
import { apiUrl, putApiUrl } from "@/components/marketing/email/Email.utils";
interface DataEmail {
id?: number;
@@ -123,7 +146,7 @@ watch(
email: props.emails,
name: props.name || "",
};
}
},
);
const handleSave = async () => {

View File

@@ -1,8 +1,12 @@
<template>
<div class="field"
<div
class="field"
style="min-width: 360px"
>
<label class="label is-flex is-align-items-center fsb-18 mb-2" for="name">
<label
class="label is-flex is-align-items-center fsb-18 mb-2"
for="name"
>
Template name <span class="has-text-danger ml-1">*</span>
</label>
<div class="control">
@@ -25,23 +29,26 @@
@click="handleSave"
:disabled="loading || !templateName.trim()"
>
<span class="icon" v-if="loading">
<span
class="icon"
v-if="loading"
>
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span class="icon" v-else-if="isEditMode">
<span
class="icon"
v-else-if="isEditMode"
>
<SvgIcon v-bind="{ name: 'save.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span class="icon" v-else>
<span
class="icon"
v-else
>
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span>{{
loading
? isEditMode
? "Updating..."
: "Creating..."
: isEditMode
? "Update Template"
: "Create Template"
loading ? (isEditMode ? "Updating..." : "Creating...") : isEditMode ? "Update Template" : "Create Template"
}}</span>
</button>
<button
@@ -50,10 +57,16 @@
:disabled="loadingCreate"
@click="handleCreateNew"
>
<span class="icon" v-if="loadingCreate">
<span
class="icon"
v-if="loadingCreate"
>
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 16 }" />
</span>
<span class="icon" v-else>
<span
class="icon"
v-else
>
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span>{{ loadingCreate ? "Creating..." : "Create" }}</span>
@@ -132,14 +145,14 @@ watch(
() => props.data.name,
(newName) => {
templateName.value = newName || "";
}
},
);
watch(
() => props.editMode,
(newEditMode) => {
isEditMode.value = newEditMode || false;
}
},
);
const handleSave = async () => {

View File

@@ -5,7 +5,10 @@
<div class="field">
<label class="label">
<span class="icon-text">
<span>Người nhận {{ selectedName ? `(Đang mở: ${selectedName})` : "" }}<b class="ml-1 has-text-danger">*</b></span>
<span
>Người nhận {{ selectedName ? `(Đang mở: ${selectedName})` : ""
}}<b class="ml-1 has-text-danger">*</b></span
>
</span>
</label>
<div class="field is-flex">
@@ -19,7 +22,7 @@
v-model="formData.content.receiver"
placeholder="Nhập địa chỉ email người nhận, ngăn cách bằng dấu chấm phẩy (;)"
@input="handleInputChange('receiver', $event)"
></input>
/>
</div>
<div class="control">
<div class="buttons is-flex is-flex-wrap-nowrap">
@@ -74,7 +77,10 @@
</span>
</label>
<div class="control">
<div class="box" style="padding: 0">
<div
class="box"
style="padding: 0"
>
<Editor
:key="quillKey"
:text="formData.content.content"
@@ -119,7 +125,11 @@
</div>
<div class="level-right">
<div class="level-item">
<button type="button" @click="addLinkUrl" class="button is-normal">
<button
type="button"
@click="addLinkUrl"
class="button is-normal"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'black', size: 16 }"></SvgIcon>
</span>
@@ -174,16 +184,27 @@
</div>
<!-- Keywords Section -->
<div v-if="detectedKeywords.length > 0" class="field">
<div
v-if="detectedKeywords.length > 0"
class="field"
>
<label class="label">
<span class="icon-text">
<span>Từ khóa</span>
</span>
</label>
<div class="box">
<div v-for="(keyword, index) in detectedKeywords" :key="index" class="field has-addons mb-2 columns">
<div
v-for="(keyword, index) in detectedKeywords"
:key="index"
class="field has-addons mb-2 columns"
>
<div class="control column is-4 pr-0 pb-0">
<span style="width: 100%" class="button is-static is-small">{{ keyword }}</span>
<span
style="width: 100%"
class="button is-static is-small"
>{{ keyword }}</span
>
</div>
<div class="control is-expanded column is-8 pb-0">
<input
@@ -201,7 +222,11 @@
<!-- Submit Button -->
<div class="field">
<div class="control">
<button class="button is-primary has-text-white" :class="{ 'is-loading': loading }" :disabled="loading">
<button
class="button is-primary has-text-white"
:class="{ 'is-loading': loading }"
:disabled="loading"
>
<span v-if="loading">Đang gửi</span>
<template v-else>
<span class="icon">
@@ -214,7 +239,12 @@
</div>
</form>
</div>
<Modal @dataevent="handleRowClick" @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
<Modal
@dataevent="handleRowClick"
@close="showmodal = undefined"
v-bind="showmodal"
v-if="showmodal"
></Modal>
</template>
<script setup lang="ts">
@@ -343,7 +373,7 @@ const getKeywordValue = (keyword: string): string => {
const handleInputChange = (field: keyof FormContent | "name" | "template", eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
typeof eventOrValue === "string" ? eventOrValue : ((eventOrValue.target as HTMLInputElement)?.value ?? "");
if (field === "receiver" && value.trim() === "") {
selectedName.value = "";
}
@@ -372,7 +402,7 @@ const handleQuillChange = (content: string) => {
const handleLinkUrlChange = (index: number, eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
typeof eventOrValue === "string" ? eventOrValue : ((eventOrValue.target as HTMLInputElement)?.value ?? "");
const newLinkUrl = [...formData.value.content.linkUrl];
newLinkUrl[index] = value;
formData.value = {
@@ -384,7 +414,7 @@ const handleLinkUrlChange = (index: number, eventOrValue: Event | string) => {
const handleTextLinkUrlChange = (index: number, eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
typeof eventOrValue === "string" ? eventOrValue : ((eventOrValue.target as HTMLInputElement)?.value ?? "");
const newTextLinkUrl = [...formData.value.content.textLinkUrl];
newTextLinkUrl[index] = value;
formData.value = {
@@ -396,7 +426,7 @@ const handleTextLinkUrlChange = (index: number, eventOrValue: Event | string) =>
const handleKeywordValueChange = (keyword: string, eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
typeof eventOrValue === "string" ? eventOrValue : ((eventOrValue.target as HTMLInputElement)?.value ?? "");
const keywordIndex = formData.value.content.keyword.findIndex((kw) => kw.keyword === keyword);
if (keywordIndex !== -1) {
const newKeywords = [...formData.value.content.keyword];
@@ -430,7 +460,11 @@ const removeLinkUrl = (index: number) => {
const newTextLinkUrl = formData.value.content.textLinkUrl.filter((_, i) => i !== index);
formData.value = {
...formData.value,
content: { ...formData.value.content, linkUrl: newLinkUrl, textLinkUrl: newTextLinkUrl },
content: {
...formData.value.content,
linkUrl: newLinkUrl,
textLinkUrl: newTextLinkUrl,
},
};
props.onDataChange(formData.value);
}
@@ -499,11 +533,15 @@ const handleSendEmail = async () => {
});
}
const response = await nuxtApp.$insertapi("sendemail", {
const response = await nuxtApp.$insertapi(
"sendemail",
{
to: formData.value.content.receiver,
content: finalEmailHtml,
subject: formData.value.content.subject || "Thông báo từ BigDataTech",
}, undefined, false
},
undefined,
false,
);
if (response !== null) {
@@ -633,7 +671,7 @@ watch(
formData.value = { ...formData.value, ...newValue };
}
},
{ deep: true }
{ deep: true },
);
watch(
@@ -667,7 +705,7 @@ watch(
props.onDataChange(formData.value);
}
},
{ immediate: true }
{ immediate: true },
);
</script>

View File

@@ -1,6 +1,12 @@
<template>
<div v-if="templateEmailContent" class="view-email">
<Template1 v-bind="templateProps" previewMode />
<div
v-if="templateEmailContent"
class="view-email"
>
<Template1
v-bind="templateProps"
previewMode
/>
<div class="action mt-3">
<button
@@ -15,10 +21,10 @@
<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 { ref, computed } from "vue";
import { render } from "@vue-email/render";
import Template1 from '@/lib/email/templates/Template1.vue';
import Template1 from "@/lib/email/templates/Template1.vue";
const {
$insertapi,
@@ -44,19 +50,19 @@ const props = defineProps({
scheduleItemId: Number,
});
const isVietnamese = computed(() => $store.lang.toLowerCase() === 'vi');
const isVietnamese = computed(() => $store.lang.toLowerCase() === "vi");
const paymentScheduleItem = ref(null);
const contentPaymentQR = ref('');
const contentPaymentQR = ref("");
const emailTemplate = await $getdata('emailtemplate', { id: props.idEmailTemplate }, undefined, false);
const emailTemplate = await $getdata("emailtemplate", { id: props.idEmailTemplate }, undefined, false);
templateEmailContent.value = emailTemplate[0] ?? null;
let foundPaymentSchedule = $findapi('payment_schedule');
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',
"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 },
};
@@ -67,8 +73,8 @@ async function paymentSchedule() {
paymentScheduleItem.value = paymentScheduleRes?.data?.rows[0] || null;
contentPaymentQR.value = buildContentPayment(paymentScheduleItem.value);
} catch (error) {
if ($mode === 'dev') {
console.error('Call api product error', error);
if ($mode === "dev") {
console.error("Call api product error", error);
}
}
}
@@ -94,22 +100,22 @@ const buildContentPayment = (data) => {
cycle,
} = data;
if (customerType.toLowerCase() === 'cn') {
if (customerType.toLowerCase() === "cn") {
if (customerName.length < 14) {
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
} else {
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
}
} else {
return `${productCode} ${customerCode} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
return `${productCode} ${customerCode} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
}
};
const templateProps = computed(() => {
let content = templateEmailContent.value.content.content || '';
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, '');
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)) {
@@ -122,14 +128,14 @@ const templateProps = computed(() => {
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.',
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 || [''],
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: '' }],
? templateEmailContent.value.content.keyword.map((k) => (typeof k === "string" ? { keyword: k, value: "" } : k))
: [{ keyword: "", value: "" }],
},
previewMode: true,
};
@@ -151,9 +157,9 @@ const handleSendEmail = async () => {
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');
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
@@ -161,18 +167,18 @@ const handleSendEmail = async () => {
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');
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, "g");
finalEmailHtml = finalEmailHtml.replace(regex, value);
}
});
}
const response = await $insertapi(
'sendemail',
"sendemail",
{
to: paymentScheduleItem.value?.txn_detail__transaction__customer__email,
content: finalEmailHtml,
subject: replaceTemplateVars(templateProps.value.content.subject) || 'Thông báo từ Utopia Villas & Resort',
subject: replaceTemplateVars(templateProps.value.content.subject) || "Thông báo từ Utopia Villas & Resort",
},
undefined,
false,
@@ -189,9 +195,9 @@ const handleSendEmail = async () => {
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(/\[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,
@@ -200,37 +206,37 @@ function replaceTemplateVars(html) {
.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)) : ''}` ||
'',
`${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\.code\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__code || "")
.replace(
/\[customer\.legal_code\]/g,
paymentScheduleItem.value?.txn_detail__transaction__customer__legal_code || '',
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(/\[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) || '',
$numberToVietnameseCurrency(paymentScheduleItem.value?.remain_amount) || "",
)
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(paymentScheduleItem.value?.cycle) || '')
.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()}`}` ||
'',
`${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}`}` || '',
`${paymentScheduleItem.value?.cycle == 0 ? "Dat coc" : `Dot ${paymentScheduleItem.value?.cycle}`}` || "",
);
}
@@ -244,23 +250,23 @@ function quillToEmailHtml(html) {
.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, '')
.replace(/ql-size-small/g, "")
.replace(/ql-size-large/g, "")
.replace(/ql-size-huge/g, "")
// REMOVE EMPTY CLASS
.replace(/class=""/g, '')
.replace(/class=""/g, "")
);
}
function sanitizeContentPayment(text, maxLength = 80) {
if (!text) return '';
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, ' ')
.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);
}