chore: install prettier
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 có 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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 có 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user