This commit is contained in:
Viet An
2026-06-20 14:48:35 +07:00
parent 7402e61906
commit b596ea0a0f
31 changed files with 5911 additions and 13522 deletions

105
app/components/LogIn.vue Normal file
View File

@@ -0,0 +1,105 @@
<script setup>
const emit = defineEmits(["close"]);
const store = useStore();
const userCreds = {
email: "user@gmail.com",
password: "99171123",
};
const adminCreds = {
email: "admin@gmail.com",
password: "1234567890",
};
const formValues = ref(adminCreds);
function login() {
if (formValues.value.email.includes("admin")) store.login = adminRec;
else store.login = userRec;
window.location.assign("/");
}
const adminRec = {
id: 1,
avatar: null,
username: "admin",
fullname: "Admin",
type: 1,
type__code: "admin",
type__name: "Quản trị hệ thống",
is_admin: true,
token: "3j3ki2rvy",
};
const userRec = {
id: 2,
avatar: null,
username: "user",
fullname: "User",
type: 2,
type__code: "user",
type__name: "Nhân viên",
is_admin: false,
token: "b2z0as13x",
};
</script>
<template>
<div>
<div class="buttons is-centered is-flex-wrap-nowrap pb-3">
<button
@click="formValues = userCreds"
class="button fs-14 is-fullwidth"
:class="formValues.email === userCreds.email && 'is-primary is-light has-text-weight-bold'"
>
Nhân viên
</button>
<button
@click="formValues = adminCreds"
class="button fs-14 is-fullwidth"
:class="formValues.email === adminCreds.email && 'is-primary is-light has-text-weight-bold'"
>
Admin
</button>
</div>
<form>
<div class="field">
<p class="control has-icons-left has-icons-right">
<input
class="input"
type="email"
placeholder="Email"
v-model="formValues.email"
disabled
/>
<span class="icon is-small is-left">
<SvgIcon v-bind="{ name: 'email.svg', type: 'grey', size: 18 }" />
</span>
</p>
</div>
<div class="field">
<p class="control has-icons-left">
<input
class="input"
type="password"
placeholder="Mật khẩu"
v-model="formValues.password"
disabled
/>
<span class="icon is-small is-left">
<SvgIcon v-bind="{ name: 'password.svg', type: 'grey', size: 18 }" />
</span>
</p>
</div>
<div class="field is-grouped is-grouped-centered">
<p class="control">
<button
@click="login"
type="button"
class="button is-success"
>
Đăng nhập
</button>
</p>
</div>
</form>
</div>
</template>

View File

@@ -1,9 +1,12 @@
<template>
<nav class="navbar is-fixed-top has-shadow px-3" role="navigation">
<nav
class="navbar is-fixed-top has-shadow px-3"
role="navigation"
>
<div class="navbar-brand mr-5">
<span class="navbar-item">
<SvgIcon v-bind="{ name: 'dot.svg', size: 18, type: 'primary' }" />
<span class="fsb-20 has-text-primary">HRM</span>
<span class="fsb-18 has-text-primary">HRM</span>
</span>
<a
role="button"
@@ -20,19 +23,39 @@
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" id="navMenu">
<div class="navbar-start" style="min-width: 650px">
<template v-for="(v, i) in leftmenu" :key="i" :id="v.code">
<a class="navbar-item px-2" v-if="!v.submenu" @click="changeTab(v)">
<div
class="navbar-menu"
id="navMenu"
>
<div
class="navbar-start"
style="min-width: 650px"
>
<template
v-for="(v, i) in leftmenu"
:key="i"
:id="v.code"
>
<a
class="navbar-item px-2"
v-if="!v.submenu"
@click="changeTab(v)"
>
<span
:class="`fs-15 ${currentTab.code === v.code ? 'activetab' : ''}`"
style="font-weight: 600; padding: 3px 4px"
style="font-weight: 500; padding: 3px 5px"
>
{{ v[lang] }}
</span>
</a>
<div class="navbar-item has-dropdown is-hoverable" v-else>
<a class="navbar-item px-2" @click="changeTab(v)">
<div
class="navbar-item has-dropdown is-hoverable"
v-else
>
<a
class="navbar-item px-2"
@click="changeTab(v)"
>
<span
:class="`icon-text ${currentTab.code === v.code ? 'activetab' : ''}`"
style="padding: 3px 4px"
@@ -62,144 +85,88 @@
</template>
</div>
<div class="navbar-end">
<a class="navbar-item" @click="changeTab(tabConfig)" v-if="tabConfig">
<SvgIcon
v-bind="{ name: 'configuration.svg', type: 'findata', size: 24 }"
></SvgIcon>
<a
v-if="!$store.login"
@click="openLogInModal"
class="navbar-item fs-14 px-2"
>
<span
class="px-2 py-1"
style="border-radius: 6px; border: 1px solid #204853"
>Đăng nhập</span
>
</a>
<a class="navbar-item" @click="openProfile()" v-if="avatar">
<a
class="navbar-item"
@click="changeTab(tabConfig)"
v-if="tabConfig"
>
<SvgIcon v-bind="{ name: 'configuration.svg', type: 'findata', size: 24 }"></SvgIcon>
</a>
<a
class="navbar-item"
@click="openProfile()"
v-if="avatar && $store.login"
>
<Avatarbox v-bind="avatar"></Avatarbox>
</a>
</div>
</div>
<Modal
v-if="showModal"
v-bind="showModal"
@close="showModal = undefined"
/>
</nav>
</template>
<script setup>
import { watch } from 'vue';
import { watch } from "vue";
const router = useRouter();
const route = useRoute();
const emit = defineEmits(['changetab', 'langChanged']);
const emit = defineEmits(["changetab", "langChanged"]);
const { $find, $filter, $findIndex, $store } = useNuxtApp();
const lang = ref($store.lang);
// let menu = $filter($store.common, { category: 'topmenu' });
// console.log('menu', menu);
const menu = [
{
id: 1,
category: 'topmenu',
classify: 'left',
code: 'dashboard',
vi: 'Dashboard',
link: null,
detail: {
base: 'Dashboard',
component: 'DashboardMaster',
},
index: 0,
},
{
id: 2,
category: 'topmenu',
classify: 'left',
code: 'hrm',
vi: 'Quản lý nhân sự',
link: null,
detail: {
base: 'HRM',
component: 'HRMMaster',
},
index: 0,
},
{
id: 3,
category: 'topmenu',
classify: 'left',
code: 'rollcall',
vi: 'Chấm công',
link: null,
detail: {
base: 'Rollcall',
component: 'RollcallMaster',
},
index: 0,
},
{
id: 4,
category: 'topmenu',
classify: 'left',
code: 'salary',
vi: 'Tính lương',
link: null,
detail: {
base: 'Salary',
component: 'SalaryMaster',
},
index: 0,
},
{
id: 5,
category: 'topmenu',
classify: 'left',
code: 'kpi_efficiency',
vi: 'KPI & Hiệu suất',
link: null,
detail: {
base: 'Kpi',
component: 'KpiMaster',
},
index: 0,
},
{
id: 6,
category: 'topmenu',
classify: 'left',
code: 'employee',
vi: 'Cổng nhân viên',
link: null,
detail: {
base: 'Employee',
component: 'EmployeeMaster',
},
index: 0,
},
];
if ($store.rights.length > 0) {
menu = menu.filter((v) => $findIndex($store.rights, { setting: v.id }) >= 0);
}
// if (menu.length === 0) {
// $snackbar(
// $store.lang === 'vi'
// ? 'Bạn không có quyền truy cập'
// : 'You do not have permission to access.',
// );
// if ($store.rights.length > 0) {
// menu = menu.filter((v) => $findIndex($store.rights, { setting: v.id }) >= 0);
// }
// menu.map((v) => {
// let arr = $filter($store.common, { category: 'submenu', classify: v.code });
// if ($store.rights.length > 0) {
// arr = arr.filter((x) => $findIndex($store.rights, { setting: x.id }) >= 0);
// }
// v.submenu = arr.length > 0 ? arr : null;
// });
const leftmenu = $filter(menu, { category: 'topmenu', classify: 'left' });
let currentTab = ref(leftmenu.length > 0 ? leftmenu[0] : undefined);
const showModal = ref();
function openLogInModal() {
showModal.value = {
component: "LogIn",
width: "400px",
height: "176px",
title: "Đăng nhập",
};
}
let leftmenu = ref(
!$store.login
? $store.menu.slice(0, -1)
: $store.login.fullname === "User"
? $store.menu.filter((m) => m.view)
: $store.menu,
);
let currentTab = ref(leftmenu.value.length > 0 ? leftmenu.value[0] : undefined);
const subTab = ref();
const tabConfig = $find(menu, { code: 'configuration' });
const tabConfig = $find($store.menu, { code: "configuration" });
const avatar = ref();
const isAdmin = ref();
const handleClick = function () {
const target = document.getElementById('burger');
target.classList.toggle('is-active');
const target1 = document.getElementById('navMenu');
target1.classList.toggle('is-active');
const target = document.getElementById("burger");
target.classList.toggle("is-active");
const target1 = document.getElementById("navMenu");
target1.classList.toggle("is-active");
};
const closeMenu = function () {
if (!document) return;
const target = document.getElementById('burger');
const target1 = document.getElementById('navMenu');
const target = document.getElementById("burger");
const target1 = document.getElementById("navMenu");
if (!target) return;
if (target.classList.contains('is-active')) {
target.classList.remove('is-active');
target1.classList.remove('is-active');
if (target.classList.contains("is-active")) {
target.classList.remove("is-active");
target1.classList.remove("is-active");
}
};
function changeTab(tab, subtab) {
@@ -208,35 +175,31 @@ function changeTab(tab, subtab) {
}
currentTab.value = tab;
subTab.value = subtab;
emit('changetab', tab, subtab);
emit("changetab", tab, subtab);
closeMenu();
let query = subtab
? { tab: tab.code, subtab: subtab.code }
: { tab: tab.code };
let query = subtab ? { tab: tab.code, subtab: subtab.code } : { tab: tab.code };
router.push({ query: query });
}
function openProfile() {
let modal = {
component: 'user/Profile',
width: '1100px',
height: '360px',
title: $store.lang === 'vi' ? 'Thông tin cá nhân' : '"User profile"',
component: "user/Profile",
width: "1100px",
height: "360px",
title: $store.lang === "vi" ? "Thông tin cá nhân" : '"User profile"',
};
$store.commit('showmodal', modal);
$store.commit("showmodal", modal);
}
let found = route.query.tab
? $find(menu, { code: route.query.tab })
: undefined;
let found = route.query.tab ? $find($store.menu, { code: route.query.tab }) : undefined;
if (found || currentTab.value) changeTab(found || currentTab.value);
onMounted(() => {
if (!$store.login) return;
avatar.value = {
image: null,
text: $store.login.fullname.substring(0, 1).toUpperCase(),
size: 'two',
type: 'findata',
size: "two",
type: "findata",
};
isAdmin.value = $store.login.type__code === 'admin';
isAdmin.value = $store.login.type__code === "admin";
});
watch(
() => $store.login,
@@ -245,10 +208,10 @@ watch(
avatar.value = {
image: null,
text: $store.login.fullname.substring(0, 1).toUpperCase(),
size: 'two',
type: 'findata',
size: "two",
type: "findata",
};
isAdmin.value = $store.login.type__code === 'admin';
isAdmin.value = $store.login.type__code === "admin";
lang.value = $store.lang;
},
);

View File

@@ -4,8 +4,14 @@
@click="$emit('justclick')"
:style="image ? 'border: none' : ''"
>
<figure class="image" v-if="image">
<img class="is-rounded" :src="`${$path()}download?name=${image}`" />
<figure
class="image"
v-if="image"
>
<img
class="is-rounded"
:src="`${$path()}download?name=${image}`"
/>
</figure>
<div v-else>
<span>{{ text }}</span>
@@ -14,19 +20,18 @@
</template>
<script>
export default {
props: ['text', 'image', 'type', 'size'],
props: ["text", "image", "type", "size"],
};
</script>
<style>
.cbox-findata-two {
font-size: 16px;
font-weight: bold;
font-weight: 500;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e4e4e4;
border: 1px solid #d3d3d3;
border-radius: 50%;
}
</style>

View File

@@ -1,393 +1,3 @@
<template>
<ClientOnly>
<div ref="el"></div>
</ClientOnly>
</template>
<template>hey</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import 'ckeditor5/ckeditor5.css';
import 'ckeditor5-premium-features/ckeditor5-premium-features.css';
const el = ref(null);
let editor = null;
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const content = ref(props.modelValue || '');
onMounted(async () => {
if (!process.client) return;
const {
ClassicEditor,
AccessibilityHelp,
Autoformat,
AutoImage,
AutoLink,
Autosave,
BalloonToolbar,
BlockQuote,
BlockToolbar,
Bold,
CKBox,
CKBoxImageEdit,
CloudServices,
Essentials,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
Heading,
Highlight,
ImageBlock,
ImageCaption,
ImageInline,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageTextAlternative,
ImageToolbar,
ImageUpload,
Indent,
IndentBlock,
Italic,
Link,
LinkImage,
List,
ListProperties,
MediaEmbed,
Mention,
PageBreak,
Paragraph,
PasteFromOffice,
PictureEditing,
RemoveFormat,
SelectAll,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Subscript,
Superscript,
Table,
TableCaption,
TableCellProperties,
GeneralHtmlSupport,
TableColumnResize,
TableProperties,
TableToolbar,
TextTransformation,
TodoList,
Underline,
Alignment,
Undo,
} = await import('ckeditor5');
const { ExportPdf, ExportWord, ImportWord } = await import('ckeditor5-premium-features');
editor = await ClassicEditor.create(el.value, {
// licenseKey: 'GPL',
plugins: [
AccessibilityHelp,
Autoformat,
AutoImage,
AutoLink,
Autosave,
BalloonToolbar,
BlockQuote,
BlockToolbar,
Bold,
CKBox,
CKBoxImageEdit,
CloudServices,
Essentials,
ExportPdf,
ExportWord,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
Heading,
Highlight,
ImageBlock,
ImageCaption,
ImageInline,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageTextAlternative,
ImageToolbar,
ImageUpload,
ImportWord,
Indent,
IndentBlock,
Italic,
Link,
LinkImage,
List,
ListProperties,
MediaEmbed,
Mention,
PageBreak,
Paragraph,
PasteFromOffice,
PictureEditing,
RemoveFormat,
SelectAll,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Subscript,
Superscript,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
TextTransformation,
TodoList,
Underline,
Alignment,
GeneralHtmlSupport,
Undo,
],
toolbar: [
'undo',
'redo',
'|',
'importWord',
'exportWord',
'exportPdf',
'|',
'heading',
'|',
'fontSize',
'fontFamily',
'fontColor',
'fontBackgroundColor',
'|',
'bold',
'italic',
'underline',
'subscript',
'superscript',
'removeFormat',
'|',
'alignment', // 👈 THÊM
'|',
'specialCharacters',
'pageBreak',
'link',
'insertImage',
'insertImageViaUrl',
'ckbox',
'mediaEmbed',
'insertTable',
'highlight',
'blockQuote',
'|',
'bulletedList',
'numberedList',
'todoList',
'outdent',
'indent',
],
exportPdf: {
stylesheets: [
'https://cdn.ckeditor.com/ckeditor5/43.2.0/ckeditor5.css',
'https://cdn.ckeditor.com/ckeditor5-premium-features/43.2.0/ckeditor5-premium-features.css',
],
fileName: 'export-pdf-demo.pdf',
converterOptions: {
format: 'Tabloid',
margin_top: '20mm',
margin_bottom: '20mm',
margin_right: '24mm',
margin_left: '24mm',
page_orientation: 'portrait',
},
},
exportWord: {
stylesheets: [
'https://cdn.ckeditor.com/ckeditor5/43.2.0/ckeditor5.css',
'https://cdn.ckeditor.com/ckeditor5-premium-features/43.2.0/ckeditor5-premium-features.css',
],
fileName: 'export-word-demo.docx',
converterOptions: {
document: {
orientation: 'portrait',
size: 'Tabloid',
margins: {
top: '20mm',
bottom: '20mm',
right: '24mm',
left: '24mm',
},
},
},
},
balloonToolbar: ['bold', 'italic','fontFamily','fontSize', '|', 'alignment', 'link', 'insertImage', '|', 'bulletedList', 'numberedList'],
blockToolbar: [
'fontSize',
'fontColor',
'fontBackgroundColor',
'|',
'bold',
'italic',
'|',
'link',
'insertImage',
'insertTable',
'|',
'bulletedList',
'numberedList',
'outdent',
'indent',
],
fontFamily: {
supportAllValues: true,
},
fontSize: {
options: [10, 12,13, 14, 'default', 18, 20, 22],
supportAllValues: true,
},
heading: {
options: [
{
model: 'paragraph',
title: 'Paragraph',
class: 'ck-heading_paragraph',
},
{
model: 'heading1',
view: 'h1',
title: 'Heading 1',
class: 'ck-heading_heading1',
},
{
model: 'heading2',
view: 'h2',
title: 'Heading 2',
class: 'ck-heading_heading2',
},
{
model: 'heading3',
view: 'h3',
title: 'Heading 3',
class: 'ck-heading_heading3',
},
{
model: 'heading4',
view: 'h4',
title: 'Heading 4',
class: 'ck-heading_heading4',
},
{
model: 'heading5',
view: 'h5',
title: 'Heading 5',
class: 'ck-heading_heading5',
},
{
model: 'heading6',
view: 'h6',
title: 'Heading 6',
class: 'ck-heading_heading6',
},
],
},
image: {
toolbar: [
'toggleImageCaption',
'imageTextAlternative',
'|',
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:breakText',
'|',
'resizeImage',
'|',
'ckboxImageEdit',
],
},
link: {
addTargetToExternalLinks: true,
defaultProtocol: 'https://',
decorators: {
toggleDownloadable: {
mode: 'manual',
label: 'Downloadable',
attributes: {
download: 'file',
},
},
},
},
list: {
properties: {
styles: true,
startIndex: true,
reversed: true,
},
},
mention: {
feeds: [
{
marker: '@',
feed: [],
},
],
},
menuBar: {
isVisible: true,
},
table: {
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties'],
},
alignment: {
options: ['left', 'center', 'right', 'justify'],
},
});
// set data ban đầu
editor.setData(props.modelValue || '');
// khi thay đổi → emit ra ngoài
editor.model.document.on('change:data', () => {
const data = editor.getData();
emit('update:modelValue', data);
});
});
watch(
() => props.modelValue,
(val) => {
if (editor && val !== editor.getData()) {
editor.setData(val || '');
}
},
);
onBeforeUnmount(async () => {
if (editor) {
await editor.destroy();
editor = null;
}
});
</script>
<script setup></script>

View File

@@ -1,347 +1,5 @@
<template>
<div class="container">
<div class="level mb-4">
<div class="level-left">
<div class="level-item">
<div>
<h2 class="title is-4">Sent Emails</h2>
<p class="subtitle is-6">
{{ loading ? "Loading..." : `${emailsSent.length} email(s) sent` }}
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button
class="button is-light"
@click="getDataEmailsSent"
:disabled="loading"
:class="{ 'is-loading': loading }"
>
<span class="icon">
<RefreshCw :size="16" />
</span>
<span>Refresh</span>
</button>
</div>
</div>
</div>
<div class="box">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>Emails</th>
<th>Subject</th>
<th>Content</th>
<th>Status</th>
<th>Sent At</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
<template v-if="loading">
<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>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
</tr>
</template>
<template v-else-if="emailsSent.length === 0">
<tr>
<td colspan="6">
<div class="has-text-centered py-6">
<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>
</td>
</tr>
</template>
<template v-else>
<tr v-for="email in emailsSent" :key="email.id" class="is-clickable" @click="handleViewDetail(email)">
<td>
<span>{{ truncateText(email.receiver, 40) }}</span>
</td>
<td>{{ truncateText(email.subject, 20) }}</td>
<td>
<div v-html="truncateText(stripHtml(extractMessageContent(email.content), 1000), 40)"></div>
</td>
<td>
<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 class="icon is-small">
<CheckCircle2 :size="12" />
</span>
<span>Sent</span>
</span>
<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 class="icon is-small">
<Clock :size="12" />
</span>
<span>Scheduled</span>
</span>
</td>
<td>
<div>
<p class="is-size-7">
<span class="icon is-small">
<Calendar :size="12" />
</span>
{{ formatDate(email.create_time) }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.create_time) }}</p>
</div>
</td>
<td>
<div>
<p class="is-size-7">
<span class="icon is-small">
<Clock :size="12" />
</span>
{{ formatDate(email.update_time || "") }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.update_time || "") || "---" }}</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</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">
<header class="modal-card-head">
<p class="modal-card-title">Email Details</p>
<button class="delete" @click="showDetailModal = false"></button>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">
Recipients ({{ selectedEmail.receiver.split(";").filter((e) => e.trim()).length }})
</label>
<div class="box" style="max-height: 150px; overflow-y: auto">
<p style="white-space: pre-wrap">{{ selectedEmail.receiver }}</p>
</div>
</div>
<div class="field">
<label class="label">Subject</label>
<p>{{ selectedEmail.subject }}</p>
</div>
<div class="field">
<label class="label">Content</label>
<div
class="box content"
style="max-height: 300px; overflow-y: auto"
v-html="selectedEmail.content || 'No content'"
></div>
</div>
<div class="columns">
<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>
</div>
</div>
<div class="column">
<label class="label">Sent At</label>
<p>{{ new Date(selectedEmail.create_time).toLocaleString("vi-VN") }}</p>
</div>
<div class="column">
<label class="label">Updated At</label>
<p>
{{ selectedEmail.update_time ? new Date(selectedEmail.update_time).toLocaleString("vi-VN") : "---" }}
</p>
</div>
</div>
</section>
</div>
</div>
</div>
<div class="container"></div>
</template>
<script setup lang="ts">
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';
interface EmailSent {
id: number;
receiver: string;
subject: string;
content: string;
status: number;
create_time: string;
update_time?: string;
}
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const emailsSent = ref<EmailSent[]>([]);
const loading = ref(true);
const selectedEmail = ref<EmailSent | null>(null);
const showDetailModal = ref(false);
const extractMessageContent = (html: string | null | undefined): string => {
if (!html || html === "") return "No content";
try {
if (typeof window !== "undefined") {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const messageContentDiv = doc.querySelector("div.message-content");
if (messageContentDiv) {
const content = messageContentDiv.innerHTML.trim();
if (content) {
return content;
}
return messageContentDiv.innerHTML;
}
} else {
const regex = /<div[^>]*class=["'][^"']*message-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i;
const match = html.match(regex);
if (match && match[1]) {
return match[1].trim();
}
}
} catch (error) {
console.error("Error extracting message content:", error);
}
return html;
};
const stripHtml = (html: string, maxLength: number = 1000): string => {
if (!html) return "";
const text = html.replace(/<[^>]*>/g, "");
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
};
const getDataEmailsSent = async () => {
loading.value = true;
try {
const response = await axios.get(`${apiUrl}/Email_Sent/?sort=-id`);
if (response.status === 200) {
emailsSent.value = response.data.rows || response.data || [];
}
} catch (error) {
console.error("Error fetching sent emails:", error);
$snackbar("Failed to load sent emails");
} finally {
loading.value = false;
}
};
const formatDate = (dateString: string) => {
try {
if (dateString === "") return "";
const date = new Date(dateString);
return date.toLocaleString("vi-VN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "Invalid date";
}
};
const getRelativeTime = (dateString: string) => {
try {
if (dateString === "") return "";
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
} catch {
return "";
}
};
const handleViewDetail = (email: EmailSent) => {
selectedEmail.value = email;
showDetailModal.value = true;
};
const truncateText = (text: string | undefined | null, maxLength: number = 50): string => {
if (!text) return "";
const textStr = String(text);
if (textStr.length <= maxLength) return textStr;
return textStr.substring(0, maxLength) + "...";
};
onMounted(() => {
getDataEmailsSent();
});
</script>
<style scoped>
.is-clickable {
cursor: pointer;
}
.is-clickable:hover {
background-color: #f5f5f5;
}
.skeleton-line {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.rotating {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<script setup lang="ts"></script>

View File

@@ -1,15 +1,29 @@
<template>
<div>
<article class="message is-dark">
<div class="message-body py-2 mt-5 has-text-dark fs-16">
{{$store.lang==='en'? 'Click the button below to log out of the system.' : 'Nhấn vào nút bên dưới để thoát khỏi hệ thống.'}}
<article class="message is-dark">
<div class="message-body py-2 mt-5 has-text-dark fs-16">
{{
$store.lang === "en"
? "Click the button below to log out of the system."
: "Nhấn vào nút bên dưới để thoát khỏi hệ thống."
}}
</div>
</article>
<div class="mt-5 pt-3">
<button
class="button is-primary has-text-white"
@click="logout"
>
{{ $store.lang === "en" ? "Sign out" : "Đăng xuất" }}
</button>
</div>
</article>
<div class="mt-5 pt-3">
<button class="button is-primary has-text-white" @click="$requestLogin()">{{$store.lang==='en'? 'Sign out' : 'Đăng xuất'}}</button>
</div>
</div>
</template>
<script setup>
const { $store, $requestLogin } = useNuxtApp()
</script>
const { $store, $requestLogin } = useNuxtApp();
function logout() {
$store.login = undefined;
window.location.assign("/");
}
</script>

View File

@@ -0,0 +1,65 @@
<script setup>
import { isEqual } from "es-toolkit";
const store = useStore();
const { $copy, $snackbar } = useNuxtApp();
const localMenu = ref($copy(store.menu));
function save() {
store.menu = $copy(localMenu.value);
localMenu.value = $copy(store.menu);
$snackbar("Đã cập nhật phân quyền");
}
</script>
<template>
<div style="max-width: 800px; margin-inline: auto">
<table class="table is-fullwidth is-hoverable is-bordered fs-14 mb-4">
<thead>
<tr style="background-color: #e4fff0">
<th>
<SvgIcon v-bind="{ name: 'visibility.svg', size: 20 }" />
</th>
<th>Menu</th>
</tr>
</thead>
<tbody>
<tr
v-for="m in localMenu"
:key="m.id"
@click="m.view = !m.view"
class="is-clickable"
>
<td>
<label class="checkbox">
<input
type="checkbox"
:disabled="localMenu.filter((menu) => menu.view).length === 1 && m.view"
v-model="m.view"
/>
</label>
</td>
<td>
<div class="is-flex is-justify-content-space-between is-align-items-center">
<p>{{ m.vi }}</p>
<p class="fs-13 has-text-grey">{{ m.code }}</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="block">
<button
:disabled="isEqual(store.menu, localMenu)"
@click="save"
class="button is-primary"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'save.svg', type: 'white', size: 19 }" />
</span>
<span>Lưu thay đổi</span>
</button>
</div>
</div>
</template>