1111 lines
36 KiB
JavaScript
1111 lines
36 KiB
JavaScript
(function () {
|
|
const body = document.body;
|
|
const menuButton = document.getElementById('mobileMenuBtn');
|
|
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
|
const FLUENT_ICON_FILES = Object.freeze({
|
|
add: 'add_20_regular.svg',
|
|
add_box: 'box_24_regular.svg',
|
|
apps: 'apps_24_regular.svg',
|
|
archive: 'archive_24_regular.svg',
|
|
attach_file: 'attach_20_regular.svg',
|
|
campaign: 'megaphone_20_regular.svg',
|
|
check_circle: 'checkmark_circle_20_filled.svg',
|
|
close: 'dismiss_20_regular.svg',
|
|
dashboard: 'grid_24_regular.svg',
|
|
database_off: 'database_warning_20_regular.svg',
|
|
delete: 'delete_20_regular.svg',
|
|
deployed_code: 'cube_24_regular.svg',
|
|
download: 'arrow_download_20_regular.svg',
|
|
draft: 'document_24_regular.svg',
|
|
edit: 'edit_20_regular.svg',
|
|
error: 'error_circle_20_filled.svg',
|
|
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
|
|
group: 'people_24_regular.svg',
|
|
inventory_2: 'box_24_regular.svg',
|
|
link_off: 'link_dismiss_20_regular.svg',
|
|
login: 'arrow_enter_20_regular.svg',
|
|
logout: 'sign_out_20_regular.svg',
|
|
mark_email_unread: 'mail_unread_24_regular.svg',
|
|
memory: 'memory_16_regular.svg',
|
|
menu: 'navigation_20_regular.svg',
|
|
notifications: 'alert_20_regular.svg',
|
|
notifications_none: 'alert_off_20_regular.svg',
|
|
person_add: 'person_add_20_regular.svg',
|
|
precision_manufacturing: 'bot_24_filled.svg',
|
|
save: 'save_20_regular.svg',
|
|
search_off: 'search_24_regular.svg',
|
|
send: 'send_20_regular.svg',
|
|
stars: 'star_20_filled.svg',
|
|
swap_horiz: 'arrow_swap_20_regular.svg',
|
|
sync: 'arrow_sync_20_regular.svg',
|
|
upgrade: 'arrow_up_20_regular.svg',
|
|
upload: 'arrow_upload_20_regular.svg',
|
|
upload_file: 'document_arrow_up_24_regular.svg',
|
|
visibility: 'eye_20_regular.svg',
|
|
warning: 'warning_20_filled.svg'
|
|
});
|
|
|
|
function applyFluentIcon(element, iconName) {
|
|
if (!element) return;
|
|
const fileName = FLUENT_ICON_FILES[iconName] || 'question_20_regular.svg';
|
|
element.classList.remove('material-symbols-outlined');
|
|
element.classList.add('fluent-icon');
|
|
element.style.setProperty('--fluent-icon-url', `url("/vendor/fluent-icons/${fileName}")`);
|
|
element.setAttribute('aria-hidden', 'true');
|
|
element.textContent = '';
|
|
}
|
|
|
|
function hydrateFluentIcons(root) {
|
|
root.querySelectorAll('.material-symbols-outlined').forEach((element) => {
|
|
applyFluentIcon(element, element.textContent.trim());
|
|
});
|
|
}
|
|
|
|
function initNotiflix() {
|
|
if (!window.Notiflix) return;
|
|
|
|
window.Notiflix.Notify.init({
|
|
width: '320px',
|
|
position: 'right-top',
|
|
distance: '16px',
|
|
timeout: 2600,
|
|
borderRadius: '4px',
|
|
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
|
|
fontSize: '13px',
|
|
messageMaxLength: 180,
|
|
clickToClose: true,
|
|
pauseOnHover: true,
|
|
cssAnimationStyle: 'from-right',
|
|
useIcon: true,
|
|
zindex: 5000,
|
|
success: {
|
|
background: '#2e7d32',
|
|
textColor: '#ffffff'
|
|
},
|
|
failure: {
|
|
background: '#c62828',
|
|
textColor: '#ffffff'
|
|
},
|
|
warning: {
|
|
background: '#9a5b00',
|
|
textColor: '#ffffff'
|
|
},
|
|
info: {
|
|
background: '#223771',
|
|
textColor: '#ffffff'
|
|
}
|
|
});
|
|
|
|
window.Notiflix.Confirm.init({
|
|
width: '360px',
|
|
borderRadius: '4px',
|
|
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
|
|
titleColor: '#161824',
|
|
titleFontSize: '16px',
|
|
messageColor: '#565b70',
|
|
messageFontSize: '13px',
|
|
okButtonBackground: '#223771',
|
|
okButtonColor: '#ffffff',
|
|
cancelButtonBackground: '#eeedf6',
|
|
cancelButtonColor: '#3c4054',
|
|
backOverlayColor: 'rgba(15, 23, 42, 0.42)',
|
|
zindex: 5001,
|
|
cssAnimationStyle: 'zoom'
|
|
});
|
|
}
|
|
|
|
function notify(type, message) {
|
|
if (!message) return;
|
|
|
|
if (!window.Notiflix) {
|
|
console.info(message);
|
|
return;
|
|
}
|
|
|
|
const Notify = window.Notiflix.Notify;
|
|
|
|
if (type === 'success') {
|
|
Notify.success(message);
|
|
return;
|
|
}
|
|
|
|
if (type === 'failure') {
|
|
Notify.failure(message);
|
|
return;
|
|
}
|
|
|
|
if (type === 'warning') {
|
|
Notify.warning(message);
|
|
return;
|
|
}
|
|
|
|
Notify.info(message);
|
|
}
|
|
|
|
function confirmAction(message, onConfirm) {
|
|
if (!window.Notiflix) {
|
|
if (window.confirm(message || 'Xác nhận thao tác?') && typeof onConfirm === 'function') {
|
|
onConfirm();
|
|
}
|
|
return;
|
|
}
|
|
|
|
window.Notiflix.Confirm.show(
|
|
'Xác nhận thao tác',
|
|
message,
|
|
'Xác nhận',
|
|
'Hủy',
|
|
() => {
|
|
if (typeof onConfirm === 'function') {
|
|
onConfirm();
|
|
return;
|
|
}
|
|
notify('success', 'Đã xác nhận thao tác');
|
|
},
|
|
() => {
|
|
notify('info', 'Đã hủy thao tác');
|
|
}
|
|
);
|
|
}
|
|
|
|
function setMobileNav(open) {
|
|
body.classList.toggle('mobile-nav-open', open);
|
|
if (menuButton) {
|
|
menuButton.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
}
|
|
}
|
|
|
|
function openModal(id) {
|
|
const modal = document.getElementById(id);
|
|
if (!modal) return;
|
|
|
|
modal.classList.add('open');
|
|
const focusTarget = modal.querySelector('input, select, textarea, button');
|
|
if (focusTarget) {
|
|
window.setTimeout(() => focusTarget.focus(), 60);
|
|
}
|
|
}
|
|
|
|
function closeModal(modal) {
|
|
if (!modal) return;
|
|
modal.classList.remove('open');
|
|
}
|
|
|
|
function applyTableFilters(tableId) {
|
|
const table = document.getElementById(tableId);
|
|
if (!table) return;
|
|
|
|
const searchInput = document.querySelector(`[data-table-search="${tableId}"]`);
|
|
const query = searchInput ? searchInput.value.trim().toLowerCase() : '';
|
|
const selects = document.querySelectorAll(`[data-filter-select][data-filter-table="${tableId}"]`);
|
|
|
|
table.querySelectorAll('tbody tr').forEach((row) => {
|
|
const matchesSearch = !query || (row.dataset.search || '').includes(query);
|
|
let matchesSelects = true;
|
|
|
|
selects.forEach((select) => {
|
|
const column = select.dataset.filterColumn;
|
|
const value = select.value;
|
|
if (value && row.dataset[column] !== value) {
|
|
matchesSelects = false;
|
|
}
|
|
});
|
|
|
|
row.hidden = !(matchesSearch && matchesSelects);
|
|
});
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
const value = bytes / Math.pow(1024, index);
|
|
|
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
|
}
|
|
|
|
function isAllowedPackageFile(file) {
|
|
if (!file) return false;
|
|
|
|
const name = file.name.toLowerCase();
|
|
const allowedExtensions = ['.deb', '.tar', '.tar.gz', '.tgz', '.zip', '.gz'];
|
|
|
|
return allowedExtensions.some((extension) => name.endsWith(extension));
|
|
}
|
|
|
|
function renderSelectedFile(zone, file) {
|
|
const preview = zone.querySelector('[data-file-preview]');
|
|
const fileName = zone.querySelector('[data-file-name]');
|
|
const fileMeta = zone.querySelector('[data-file-meta]');
|
|
|
|
if (!preview || !fileName || !fileMeta) return;
|
|
|
|
if (!file) {
|
|
preview.hidden = true;
|
|
fileName.textContent = 'Chưa chọn file';
|
|
fileMeta.textContent = '';
|
|
zone.classList.remove('has-file');
|
|
return;
|
|
}
|
|
|
|
fileName.textContent = file.name;
|
|
fileMeta.textContent = `${formatFileSize(file.size)} • ${file.type || 'package file'}`;
|
|
preview.hidden = false;
|
|
zone.classList.add('has-file');
|
|
notify('success', `Đã chọn file: ${file.name}`);
|
|
}
|
|
|
|
function setInputFiles(input, files) {
|
|
try {
|
|
input.files = files;
|
|
} catch (error) {
|
|
console.info('Browser does not allow assigning dropped files to input.files.', error);
|
|
}
|
|
}
|
|
|
|
function handlePackageFiles(zone, files) {
|
|
const input = zone.querySelector('[data-file-input]');
|
|
const file = files && files[0];
|
|
|
|
if (!input || !file) return;
|
|
|
|
if (!isAllowedPackageFile(file)) {
|
|
notify('warning', 'File chưa đúng định dạng. Vui lòng chọn .deb, .tar, .tgz, .zip hoặc .gz.');
|
|
return;
|
|
}
|
|
|
|
setInputFiles(input, files);
|
|
renderSelectedFile(zone, file);
|
|
}
|
|
|
|
function initFileDropzones() {
|
|
document.querySelectorAll('[data-file-dropzone]').forEach((zone) => {
|
|
const input = zone.querySelector('[data-file-input]');
|
|
const browseButton = zone.querySelector('[data-file-browse]');
|
|
const clearButton = zone.querySelector('[data-file-clear]');
|
|
|
|
if (!input) return;
|
|
|
|
if (browseButton) {
|
|
browseButton.addEventListener('click', () => input.click());
|
|
}
|
|
|
|
if (clearButton) {
|
|
clearButton.addEventListener('click', () => {
|
|
input.value = '';
|
|
renderSelectedFile(zone, null);
|
|
notify('info', 'Đã bỏ file đã chọn');
|
|
});
|
|
}
|
|
|
|
input.addEventListener('change', () => {
|
|
handlePackageFiles(zone, input.files);
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach((eventName) => {
|
|
zone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
zone.classList.add('dragover');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach((eventName) => {
|
|
zone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
zone.classList.remove('dragover');
|
|
});
|
|
});
|
|
|
|
zone.addEventListener('drop', (event) => {
|
|
handlePackageFiles(zone, event.dataTransfer.files);
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateRegisterSubmit(form) {
|
|
const submitButton = form.querySelector('[data-register-submit]');
|
|
if (!submitButton) return;
|
|
|
|
const uniqueInputs = form.querySelectorAll('[data-unique-check]');
|
|
const isBlocked = Array.from(uniqueInputs).some((input) => (
|
|
input.dataset.uniqueStatus === 'error' || input.dataset.uniqueStatus === 'checking'
|
|
));
|
|
|
|
submitButton.disabled = isBlocked;
|
|
}
|
|
|
|
function setUniqueState(input, state, message) {
|
|
const field = input.closest('.form-field');
|
|
const form = input.closest('[data-register-form]');
|
|
const feedback = form
|
|
? form.querySelector(`[data-unique-feedback="${input.dataset.uniqueCheck}"]`)
|
|
: null;
|
|
|
|
input.dataset.uniqueStatus = state;
|
|
|
|
if (field) {
|
|
field.classList.toggle('has-error', state === 'error');
|
|
field.classList.toggle('has-success', state === 'success');
|
|
}
|
|
|
|
if (feedback) {
|
|
feedback.textContent = message || '';
|
|
feedback.style.display = message ? 'block' : '';
|
|
}
|
|
|
|
input.setCustomValidity(state === 'error' ? message : '');
|
|
|
|
if (form) {
|
|
updateRegisterSubmit(form);
|
|
}
|
|
}
|
|
|
|
function checkUniqueInput(input) {
|
|
const field = input.dataset.uniqueCheck;
|
|
const value = input.value.trim();
|
|
|
|
if (!field || !value) {
|
|
setUniqueState(input, 'idle', '');
|
|
return;
|
|
}
|
|
|
|
if (field === 'email' && !input.validity.valid) {
|
|
setUniqueState(input, 'idle', '');
|
|
return;
|
|
}
|
|
|
|
const requestId = String(Date.now());
|
|
input.dataset.uniqueRequestId = requestId;
|
|
setUniqueState(input, 'checking', 'Đang kiểm tra...');
|
|
|
|
fetch(`/register/check?field=${encodeURIComponent(field)}&value=${encodeURIComponent(value)}`, {
|
|
headers: {
|
|
Accept: 'application/json'
|
|
}
|
|
})
|
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Cannot check field')))
|
|
.then((data) => {
|
|
if (input.dataset.uniqueRequestId !== requestId) return;
|
|
|
|
setUniqueState(
|
|
input,
|
|
data.available ? 'success' : 'error',
|
|
data.message || (data.available ? 'Có thể sử dụng.' : 'Đã tồn tại.')
|
|
);
|
|
})
|
|
.catch((error) => {
|
|
console.info('Cannot check registration field:', error);
|
|
if (input.dataset.uniqueRequestId === requestId) {
|
|
setUniqueState(input, 'idle', '');
|
|
}
|
|
});
|
|
}
|
|
|
|
function initRegistrationUniqueChecks() {
|
|
document.querySelectorAll('[data-register-form]').forEach((form) => {
|
|
const timers = new Map();
|
|
|
|
form.querySelectorAll('[data-unique-check]').forEach((input) => {
|
|
input.addEventListener('input', () => {
|
|
window.clearTimeout(timers.get(input));
|
|
setUniqueState(input, 'idle', '');
|
|
timers.set(input, window.setTimeout(() => checkUniqueInput(input), 450));
|
|
});
|
|
|
|
input.addEventListener('blur', () => {
|
|
window.clearTimeout(timers.get(input));
|
|
checkUniqueInput(input);
|
|
});
|
|
|
|
if (input.value.trim()) {
|
|
checkUniqueInput(input);
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', (event) => {
|
|
const blockedInput = form.querySelector('[data-unique-status="error"], [data-unique-status="checking"]');
|
|
if (!blockedInput) return;
|
|
|
|
event.preventDefault();
|
|
notify('warning', blockedInput.dataset.uniqueStatus === 'checking'
|
|
? 'Đang kiểm tra username/email, vui lòng chờ một chút.'
|
|
: 'Vui lòng đổi username/email đang bị trùng.');
|
|
blockedInput.focus();
|
|
});
|
|
});
|
|
}
|
|
|
|
function getUserDataFromRow(row) {
|
|
return {
|
|
id: row.dataset.userId || '',
|
|
name: row.dataset.userName || '',
|
|
username: row.dataset.userUsername || '',
|
|
email: row.dataset.userEmail || '',
|
|
fullName: row.dataset.userFullName || '',
|
|
role: row.dataset.userRole || 'User',
|
|
status: row.dataset.userStatus || '',
|
|
isActive: row.dataset.userActive === 'true',
|
|
createdAt: row.dataset.userCreatedAt || '',
|
|
updatedAt: row.dataset.userUpdatedAt || '',
|
|
packageCount: row.dataset.userPackageCount || '0',
|
|
applicationCount: row.dataset.userApplicationCount || '0'
|
|
};
|
|
}
|
|
|
|
function parseJsonAttribute(value, fallback) {
|
|
try {
|
|
return JSON.parse(value || '');
|
|
} catch (error) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function getAppDataFromTrigger(trigger) {
|
|
return {
|
|
id: trigger.dataset.appId || '',
|
|
code: trigger.dataset.appCode || '',
|
|
name: trigger.dataset.appName || '',
|
|
version: trigger.dataset.appVersion || '',
|
|
status: trigger.dataset.appStatus || 'Draft',
|
|
openUrl: trigger.dataset.appOpenUrl || '',
|
|
notes: trigger.dataset.appNotes || '',
|
|
packages: parseJsonAttribute(trigger.dataset.appPackages, [])
|
|
};
|
|
}
|
|
|
|
function findEditAppVersionSelect(form, packageId) {
|
|
return Array.from(form.querySelectorAll('[data-edit-app-version]'))
|
|
.find((select) => select.dataset.editAppVersion === packageId);
|
|
}
|
|
|
|
function setEditAppPackageEnabled(form, checkbox) {
|
|
const select = findEditAppVersionSelect(form, checkbox.dataset.editAppPackage);
|
|
if (select) {
|
|
select.disabled = !checkbox.checked;
|
|
}
|
|
}
|
|
|
|
function setEditAppField(form, field, value) {
|
|
const input = form.querySelector(`[data-edit-app-field="${field}"]`);
|
|
if (input) {
|
|
input.value = value || '';
|
|
}
|
|
}
|
|
|
|
function openAppEdit(trigger) {
|
|
const app = getAppDataFromTrigger(trigger);
|
|
const form = document.getElementById('editAppForm');
|
|
if (!form || !app.id) return;
|
|
|
|
form.action = `/applications/${encodeURIComponent(app.id)}/edit`;
|
|
setEditAppField(form, 'appCode', app.code);
|
|
setEditAppField(form, 'appName', app.name);
|
|
setEditAppField(form, 'appVersion', app.version);
|
|
setEditAppField(form, 'status', app.status);
|
|
setEditAppField(form, 'openUrl', app.openUrl);
|
|
setEditAppField(form, 'notes', app.notes);
|
|
|
|
form.querySelectorAll('[data-edit-app-package]').forEach((checkbox) => {
|
|
checkbox.checked = false;
|
|
setEditAppPackageEnabled(form, checkbox);
|
|
});
|
|
|
|
app.packages.forEach((packageItem) => {
|
|
const packageId = packageItem.packageId || '';
|
|
const checkbox = Array.from(form.querySelectorAll('[data-edit-app-package]'))
|
|
.find((input) => input.dataset.editAppPackage === packageId);
|
|
const select = findEditAppVersionSelect(form, packageId);
|
|
|
|
if (checkbox) {
|
|
checkbox.checked = true;
|
|
}
|
|
|
|
if (select) {
|
|
select.disabled = false;
|
|
select.value = packageItem.selectedVersionId || '';
|
|
}
|
|
});
|
|
|
|
openModal('editAppModal');
|
|
}
|
|
|
|
function openPackageUpdate(packageId) {
|
|
const modalId = 'updatePackageModal';
|
|
const modal = document.getElementById(modalId);
|
|
const packageSelect = modal ? modal.querySelector('select[name="packageId"]') : null;
|
|
|
|
if (packageSelect && packageId) {
|
|
packageSelect.value = packageId;
|
|
}
|
|
|
|
openModal(modalId);
|
|
}
|
|
|
|
function setText(selector, value) {
|
|
const element = document.querySelector(selector);
|
|
if (element) {
|
|
element.textContent = value || '';
|
|
}
|
|
}
|
|
|
|
function openUserDetail(row) {
|
|
const user = getUserDataFromRow(row);
|
|
|
|
setText('[data-user-detail="name"]', user.name);
|
|
setText('[data-user-detail="username"]', user.username);
|
|
setText('[data-user-detail="email"]', user.email);
|
|
setText('[data-user-detail="role"]', user.role);
|
|
setText('[data-user-detail="status"]', user.status);
|
|
setText('[data-user-detail="createdAt"]', user.createdAt);
|
|
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
|
|
setText('[data-user-detail="ownedData"]', `${user.packageCount} packages, ${user.applicationCount} apps`);
|
|
|
|
openModal('userDetailModal');
|
|
}
|
|
|
|
function openUserEdit(row) {
|
|
const user = getUserDataFromRow(row);
|
|
const form = document.getElementById('editUserForm');
|
|
if (!form) return;
|
|
|
|
form.action = `/users/${encodeURIComponent(user.id)}/edit`;
|
|
form.querySelector('[data-edit-user-field="username"]').value = user.username;
|
|
form.querySelector('[data-edit-user-field="fullName"]').value = user.fullName;
|
|
form.querySelector('[data-edit-user-field="email"]').value = user.email;
|
|
form.querySelector('[data-edit-user-field="role"]').value = user.role;
|
|
form.querySelector('[data-edit-user-field="isActive"]').checked = user.isActive;
|
|
form.querySelector('[data-edit-user-field="newPassword"]').value = '';
|
|
form.querySelector('[data-edit-user-field="confirmPassword"]').value = '';
|
|
|
|
openModal('editUserModal');
|
|
}
|
|
|
|
function validateProfileForm(form, shouldNotify) {
|
|
const email = form.querySelector('[data-profile-email]');
|
|
const confirmEmail = form.querySelector('[data-profile-confirm-email]');
|
|
const newPassword = form.querySelector('[data-profile-new-password]');
|
|
const confirmPassword = form.querySelector('[data-profile-confirm-password]');
|
|
const emailFeedback = form.querySelector('[data-profile-feedback="email"]');
|
|
const passwordFeedback = form.querySelector('[data-profile-feedback="password"]');
|
|
|
|
const emailMismatch = Boolean(email && confirmEmail
|
|
&& email.value.trim().toLowerCase() !== confirmEmail.value.trim().toLowerCase());
|
|
const passwordMismatch = Boolean(newPassword && confirmPassword
|
|
&& (newPassword.value || confirmPassword.value)
|
|
&& newPassword.value !== confirmPassword.value);
|
|
|
|
if (confirmEmail) {
|
|
confirmEmail.setCustomValidity(emailMismatch ? 'Confirm email mới chưa khớp.' : '');
|
|
const field = confirmEmail.closest('.form-field');
|
|
if (field) {
|
|
field.classList.toggle('has-error', emailMismatch);
|
|
}
|
|
}
|
|
|
|
if (emailFeedback) {
|
|
emailFeedback.textContent = emailMismatch ? 'Confirm email mới chưa khớp.' : '';
|
|
emailFeedback.style.display = emailMismatch ? 'block' : '';
|
|
}
|
|
|
|
if (confirmPassword) {
|
|
confirmPassword.setCustomValidity(passwordMismatch ? 'Xác nhận mật khẩu mới chưa khớp.' : '');
|
|
const field = confirmPassword.closest('.form-field');
|
|
if (field) {
|
|
field.classList.toggle('has-error', passwordMismatch);
|
|
}
|
|
}
|
|
|
|
if (passwordFeedback) {
|
|
passwordFeedback.textContent = passwordMismatch ? 'Xác nhận mật khẩu mới chưa khớp.' : '';
|
|
passwordFeedback.style.display = passwordMismatch ? 'block' : '';
|
|
}
|
|
|
|
if (shouldNotify && emailMismatch) {
|
|
notify('warning', 'Confirm email mới chưa khớp.');
|
|
} else if (shouldNotify && passwordMismatch) {
|
|
notify('warning', 'Xác nhận mật khẩu mới chưa khớp.');
|
|
}
|
|
|
|
return !emailMismatch && !passwordMismatch;
|
|
}
|
|
|
|
function initProfileForms() {
|
|
document.querySelectorAll('[data-profile-form]').forEach((form) => {
|
|
form.querySelectorAll('input').forEach((input) => {
|
|
input.addEventListener('input', () => validateProfileForm(form, false));
|
|
});
|
|
|
|
form.addEventListener('submit', (event) => {
|
|
if (!validateProfileForm(form, true)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function initEditAppForms() {
|
|
document.querySelectorAll('#editAppForm [data-edit-app-package]').forEach((checkbox) => {
|
|
const form = checkbox.closest('form');
|
|
setEditAppPackageEnabled(form, checkbox);
|
|
checkbox.addEventListener('change', () => setEditAppPackageEnabled(form, checkbox));
|
|
});
|
|
}
|
|
|
|
function formatNotificationTime(value) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
|
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
|
|
if (elapsedSeconds < 60) return 'Vừa xong';
|
|
if (elapsedSeconds < 3600) return `${Math.floor(elapsedSeconds / 60)} phút trước`;
|
|
if (elapsedSeconds < 86400) return `${Math.floor(elapsedSeconds / 3600)} giờ trước`;
|
|
if (elapsedSeconds < 604800) return `${Math.floor(elapsedSeconds / 86400)} ngày trước`;
|
|
|
|
return new Intl.DateTimeFormat('vi-VN', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).format(date);
|
|
}
|
|
|
|
function getNotificationIcon(notification) {
|
|
if (notification.isSystem) return 'campaign';
|
|
if (notification.severity === 'success') return 'check_circle';
|
|
if (notification.severity === 'warning') return 'warning';
|
|
if (notification.severity === 'error') return 'error';
|
|
if (notification.entityType === 'package') return 'inventory_2';
|
|
if (notification.entityType === 'application') return 'apps';
|
|
return 'notifications';
|
|
}
|
|
|
|
async function notificationRequest(url, options = {}) {
|
|
const response = await fetch(url, {
|
|
cache: 'no-store',
|
|
credentials: 'same-origin',
|
|
...options,
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
...(options.headers || {})
|
|
}
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Không thể xử lý thông báo.');
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function initNotificationCenter() {
|
|
const center = document.querySelector('[data-notification-center]');
|
|
if (!center) return;
|
|
|
|
const toggle = center.querySelector('[data-notification-toggle]');
|
|
const panel = center.querySelector('[data-notification-panel]');
|
|
const badge = center.querySelector('[data-notification-badge]');
|
|
const summary = center.querySelector('[data-notification-summary]');
|
|
const list = center.querySelector('[data-notification-list]');
|
|
const empty = center.querySelector('[data-notification-empty]');
|
|
const readAllButton = center.querySelector('[data-notification-read-all]');
|
|
const filterButtons = center.querySelectorAll('[data-notification-filter]');
|
|
const knownNotificationIds = new Set();
|
|
let currentFilter = 'all';
|
|
let initialized = false;
|
|
let loading = false;
|
|
let reloadPending = false;
|
|
|
|
function setPanelOpen(open) {
|
|
panel.hidden = !open;
|
|
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
}
|
|
|
|
function setEmptyState(title, message, visible) {
|
|
const titleElement = empty.querySelector('strong');
|
|
const messageElement = empty.querySelector('span:last-child');
|
|
if (titleElement) titleElement.textContent = title;
|
|
if (messageElement) messageElement.textContent = message;
|
|
empty.hidden = !visible;
|
|
}
|
|
|
|
function updateUnreadCount(count) {
|
|
const unreadCount = Math.max(0, Number(count) || 0);
|
|
badge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
|
badge.hidden = unreadCount === 0;
|
|
summary.textContent = unreadCount > 0 ? `${unreadCount} thông báo chưa đọc` : 'Không có thông báo chưa đọc';
|
|
readAllButton.disabled = unreadCount === 0;
|
|
}
|
|
|
|
function renderNotifications(notifications) {
|
|
list.replaceChildren();
|
|
setEmptyState(
|
|
currentFilter === 'unread' ? 'Không có thông báo chưa đọc' : 'Chưa có thông báo',
|
|
'Các cập nhật quan trọng sẽ xuất hiện tại đây.',
|
|
notifications.length === 0
|
|
);
|
|
|
|
notifications.forEach((notification) => {
|
|
const item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = `notification-item tone-${notification.severity || 'info'}${notification.isRead ? '' : ' unread'}`;
|
|
item.dataset.notificationId = notification.id;
|
|
|
|
const icon = document.createElement('span');
|
|
icon.className = 'notification-item-icon';
|
|
const iconGlyph = document.createElement('span');
|
|
applyFluentIcon(iconGlyph, getNotificationIcon(notification));
|
|
icon.appendChild(iconGlyph);
|
|
|
|
const copy = document.createElement('span');
|
|
copy.className = 'notification-item-copy';
|
|
const title = document.createElement('strong');
|
|
title.textContent = notification.title;
|
|
const message = document.createElement('span');
|
|
message.textContent = notification.message;
|
|
const time = document.createElement('time');
|
|
time.dateTime = notification.createdAt || '';
|
|
time.textContent = formatNotificationTime(notification.createdAt);
|
|
copy.append(title, message, time);
|
|
|
|
const unreadDot = document.createElement('span');
|
|
unreadDot.className = 'notification-unread-dot';
|
|
unreadDot.setAttribute('aria-label', notification.isRead ? 'Đã đọc' : 'Chưa đọc');
|
|
|
|
item.append(icon, copy, unreadDot);
|
|
item.addEventListener('click', async () => {
|
|
try {
|
|
if (!notification.isRead) {
|
|
await notificationRequest(`/api/notifications/${encodeURIComponent(notification.id)}/read`, {
|
|
method: 'POST'
|
|
});
|
|
notification.isRead = true;
|
|
item.classList.remove('unread');
|
|
await loadNotifications(false);
|
|
}
|
|
} catch (error) {
|
|
notify('warning', error.message);
|
|
}
|
|
|
|
if (
|
|
notification.actionUrl
|
|
&& notification.actionUrl.startsWith('/')
|
|
&& !notification.actionUrl.startsWith('//')
|
|
) {
|
|
window.location.assign(notification.actionUrl);
|
|
}
|
|
});
|
|
|
|
list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function loadNotifications(announceNew = true) {
|
|
if (loading) {
|
|
reloadPending = true;
|
|
return;
|
|
}
|
|
loading = true;
|
|
|
|
try {
|
|
const data = await notificationRequest(
|
|
`/api/notifications?limit=20&unreadOnly=${currentFilter === 'unread' ? 'true' : 'false'}`
|
|
);
|
|
|
|
if (!data.available) {
|
|
badge.hidden = true;
|
|
summary.textContent = 'Chưa kích hoạt trong database';
|
|
readAllButton.disabled = true;
|
|
list.replaceChildren();
|
|
setEmptyState(
|
|
'Thông báo chưa khả dụng',
|
|
'Admin cần chạy migration 04_notifications.sql.',
|
|
true
|
|
);
|
|
return;
|
|
}
|
|
|
|
const notifications = Array.isArray(data.notifications) ? data.notifications : [];
|
|
if (initialized && announceNew) {
|
|
const importantNotification = notifications.find((notification) => (
|
|
!knownNotificationIds.has(notification.id)
|
|
&& !notification.isRead
|
|
&& ['warning', 'error'].includes(notification.severity)
|
|
));
|
|
|
|
if (importantNotification) {
|
|
notify(
|
|
importantNotification.severity === 'error' ? 'failure' : 'warning',
|
|
importantNotification.title
|
|
);
|
|
}
|
|
}
|
|
|
|
notifications.forEach((notification) => knownNotificationIds.add(notification.id));
|
|
initialized = true;
|
|
updateUnreadCount(data.unreadCount);
|
|
renderNotifications(notifications);
|
|
} catch (error) {
|
|
summary.textContent = 'Không thể tải thông báo';
|
|
setEmptyState('Không thể tải thông báo', 'Vui lòng thử lại sau.', true);
|
|
} finally {
|
|
loading = false;
|
|
if (reloadPending) {
|
|
reloadPending = false;
|
|
loadNotifications(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshUnreadCount() {
|
|
try {
|
|
const data = await notificationRequest('/api/notifications?countOnly=true');
|
|
if (!data.available) {
|
|
badge.hidden = true;
|
|
summary.textContent = 'Chưa kích hoạt trong database';
|
|
readAllButton.disabled = true;
|
|
return;
|
|
}
|
|
|
|
updateUnreadCount(data.unreadCount);
|
|
} catch (error) {
|
|
console.info('Cannot refresh notification count:', error);
|
|
}
|
|
}
|
|
|
|
toggle.addEventListener('click', () => {
|
|
const open = panel.hidden;
|
|
setPanelOpen(open);
|
|
if (open) loadNotifications(false);
|
|
});
|
|
|
|
readAllButton.addEventListener('click', async () => {
|
|
readAllButton.disabled = true;
|
|
try {
|
|
await notificationRequest('/api/notifications/read-all', { method: 'POST' });
|
|
await loadNotifications(false);
|
|
} catch (error) {
|
|
notify('failure', error.message);
|
|
}
|
|
});
|
|
|
|
filterButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
currentFilter = button.dataset.notificationFilter;
|
|
filterButtons.forEach((candidate) => {
|
|
const active = candidate === button;
|
|
candidate.classList.toggle('active', active);
|
|
candidate.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
});
|
|
loadNotifications(false);
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
if (!panel.hidden && !center.contains(event.target)) {
|
|
setPanelOpen(false);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' && !panel.hidden) {
|
|
setPanelOpen(false);
|
|
toggle.focus();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') {
|
|
if (panel.hidden) refreshUnreadCount();
|
|
else loadNotifications(true);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('notifications:refresh', () => {
|
|
if (panel.hidden) refreshUnreadCount();
|
|
else loadNotifications(false);
|
|
});
|
|
|
|
const broadcastButton = center.querySelector('[data-modal-open="systemNotificationModal"]');
|
|
if (broadcastButton) {
|
|
broadcastButton.addEventListener('click', () => setPanelOpen(false));
|
|
}
|
|
|
|
window.setInterval(() => {
|
|
if (document.visibilityState !== 'visible') return;
|
|
if (panel.hidden) refreshUnreadCount();
|
|
else loadNotifications(true);
|
|
}, 30000);
|
|
|
|
refreshUnreadCount();
|
|
}
|
|
|
|
function initSystemNotificationForm() {
|
|
const form = document.querySelector('[data-system-notification-form]');
|
|
if (!form) return;
|
|
|
|
form.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
if (!form.reportValidity()) return;
|
|
|
|
confirmAction('Gửi thông báo này tới tất cả tài khoản đang hoạt động?', async () => {
|
|
const submitButton = form.querySelector('[data-system-notification-submit]');
|
|
const formData = new FormData(form);
|
|
submitButton.disabled = true;
|
|
|
|
try {
|
|
const result = await notificationRequest('/api/notifications/broadcasts', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: formData.get('title'),
|
|
severity: formData.get('severity'),
|
|
message: formData.get('message'),
|
|
actionUrl: formData.get('actionUrl')
|
|
})
|
|
});
|
|
|
|
closeModal(form.closest('.modal-backdrop'));
|
|
form.reset();
|
|
notify('success', `Đã gửi thông báo tới ${result.recipientCount || 0} người dùng.`);
|
|
document.dispatchEvent(new CustomEvent('notifications:refresh'));
|
|
} catch (error) {
|
|
notify('failure', error.message);
|
|
} finally {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
hydrateFluentIcons(document);
|
|
initNotiflix();
|
|
initFileDropzones();
|
|
initRegistrationUniqueChecks();
|
|
initProfileForms();
|
|
initEditAppForms();
|
|
initNotificationCenter();
|
|
initSystemNotificationForm();
|
|
|
|
if (body.dataset.notice) {
|
|
notify(body.dataset.noticeType || 'info', body.dataset.notice);
|
|
const url = new URL(window.location.href);
|
|
if (url.searchParams.has('notice') || url.searchParams.has('noticeType')) {
|
|
url.searchParams.delete('notice');
|
|
url.searchParams.delete('noticeType');
|
|
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
}
|
|
delete body.dataset.notice;
|
|
delete body.dataset.noticeType;
|
|
}
|
|
|
|
if (menuButton) {
|
|
menuButton.addEventListener('click', () => {
|
|
setMobileNav(!body.classList.contains('mobile-nav-open'));
|
|
});
|
|
}
|
|
|
|
if (sidebarBackdrop) {
|
|
sidebarBackdrop.addEventListener('click', () => setMobileNav(false));
|
|
}
|
|
|
|
document.querySelectorAll('[data-table-search]').forEach((input) => {
|
|
input.addEventListener('input', () => applyTableFilters(input.dataset.tableSearch));
|
|
});
|
|
|
|
document.querySelectorAll('[data-filter-select]').forEach((select) => {
|
|
select.addEventListener('change', () => applyTableFilters(select.dataset.filterTable));
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const appEditButton = event.target.closest('[data-app-edit]');
|
|
if (appEditButton) {
|
|
openAppEdit(appEditButton);
|
|
return;
|
|
}
|
|
|
|
const packageUpdateButton = event.target.closest('[data-package-update]');
|
|
if (packageUpdateButton) {
|
|
openPackageUpdate(packageUpdateButton.dataset.packageUpdate);
|
|
return;
|
|
}
|
|
|
|
const refreshButton = event.target.closest('[data-refresh-page]');
|
|
if (refreshButton) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
const userViewButton = event.target.closest('[data-user-view]');
|
|
if (userViewButton) {
|
|
const row = userViewButton.closest('tr');
|
|
if (row) {
|
|
openUserDetail(row);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const userEditButton = event.target.closest('[data-user-edit]');
|
|
if (userEditButton) {
|
|
const row = userEditButton.closest('tr');
|
|
if (row) {
|
|
openUserEdit(row);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const modalOpenButton = event.target.closest('[data-modal-open]');
|
|
if (modalOpenButton) {
|
|
openModal(modalOpenButton.dataset.modalOpen);
|
|
return;
|
|
}
|
|
|
|
const modalCloseButton = event.target.closest('[data-modal-close]');
|
|
if (modalCloseButton) {
|
|
closeModal(modalCloseButton.closest('.modal-backdrop'));
|
|
}
|
|
|
|
const modalBackdrop = event.target.classList.contains('modal-backdrop') ? event.target : null;
|
|
if (modalBackdrop) {
|
|
closeModal(modalBackdrop);
|
|
}
|
|
|
|
const toastButton = event.target.closest('[data-toast]');
|
|
if (toastButton) {
|
|
notify(toastButton.dataset.toastType || 'info', toastButton.dataset.toast);
|
|
}
|
|
|
|
const confirmButton = event.target.closest('[data-confirm]');
|
|
if (confirmButton) {
|
|
confirmAction(confirmButton.dataset.confirm);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('submit', (event) => {
|
|
const form = event.target.closest('form[data-confirm-submit]');
|
|
if (!form || form.dataset.confirmed === 'true') return;
|
|
|
|
event.preventDefault();
|
|
confirmAction(form.dataset.confirmSubmit, () => {
|
|
form.dataset.confirmed = 'true';
|
|
form.submit();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
|
|
const openModalNode = document.querySelector('.modal-backdrop.open');
|
|
if (openModalNode) {
|
|
closeModal(openModalNode);
|
|
return;
|
|
}
|
|
|
|
setMobileNav(false);
|
|
});
|
|
})();
|