// VaultSentinel - Account Management Application // Main JavaScript functionality class AccountManager { constructor() { // Check if user is logged in const currentUser = this.loadFromStorage('currentUser'); if (!currentUser) { window.location.href = '../pages/login.html'; return; } this.currentUser = currentUser; this.accounts = []; this.applications = []; this.users = []; this.assets = []; this.roles = []; this.accountPage = 1; this.accountPageSize = 9; this.appPage = 1; this.appPageSize = 9; this.userPage = 1; this.userPageSize = 9; this.assetPage = 1; this.assetPageSize = 10; this.assetBorrowPage = 1; this.assetBorrowPageSize = 10; this.apiBase = '/api'; this.currentPage = 'dashboard'; this.accountSearchTerm = ''; this.applicationSearchTerm = ''; this.accountServiceFilter = ''; this.userSearchTerm = ''; this.userRoleFilter = ''; this.assetSearchTerm = ''; this.assetStatusFilter = ''; this.assetBorrows = []; this.assetBorrowSearchTerm = ''; this.assetBorrowProductSearchTimer = undefined; this.assetBorrowProductItems = []; this.assetBorrowProductQuery = ''; this.assetBorrowProductOffset = 0; this.assetBorrowProductLimit = 40; this.assetBorrowProductHasMore = false; this.assetBorrowProductLoading = false; this.assetDepartments = []; this.assetDepartmentSearchTerm = ''; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; this.configureNotifications(); this.initPromise = this.init(); this.pendingAccountAppId = undefined; this.editingAssetBorrowerEntries = []; this.pendingBorrowAssetId = undefined; this.editingAssetDepartmentId = undefined; this.pendingDeleteAssetDepartmentId = undefined; this.assetBorrowRequestType = 'borrow'; this.pendingAssetRequestRejectId = undefined; this.assetBorrowAutoRefreshTimer = undefined; } configureNotifications() { if (window.Notiflix?.Notify) { Notiflix.Notify.init({ position: 'right-top', timeout: 2500, clickToClose: true, pauseOnHover: true, distance: '12px', fontSize: '14px' }); } } notifySuccess(message) { if (window.Notiflix?.Notify) { Notiflix.Notify.success(message); } else { alert(message); } } notifyFailure(message) { if (window.Notiflix?.Notify) { Notiflix.Notify.failure(message); } else { alert(message); } } notifyWarning(message) { if (window.Notiflix?.Notify) { Notiflix.Notify.warning(message); } else { alert(message); } } getUserId() { const u = this.currentUser; const detected = u?.UserId ?? u?.userId ?? u?.id ?? u?.ID ?? u?.userid ?? u?.user_id ?? u?.user?.UserId ?? u?.user?.userId; // Fallback: if only username/role exist (no id), use default admin id = 1 return detected ?? 1; } getCurrentUserRoleRaw() { return this.currentUser?.Role ?? this.currentUser?.role ?? this.currentUser?.RoleName ?? this.currentUser?.user?.Role ?? this.currentUser?.user?.role ?? ''; } getCurrentUserRole() { return String(this.getCurrentUserRoleRaw() || '').trim().toLowerCase(); } getCurrentUserDisplayName() { const fullName = String( this.currentUser?.FullName ?? this.currentUser?.fullname ?? this.currentUser?.user?.FullName ?? this.currentUser?.user?.fullname ?? '' ).trim(); const username = String( this.currentUser?.Username ?? this.currentUser?.username ?? this.currentUser?.user?.Username ?? this.currentUser?.user?.username ?? '' ).trim(); return fullName || username || 'Unknown'; } isCurrentUserAdmin() { return this.getCurrentUserRole() === 'admin'; } canCurrentUserManageAssets() { const role = this.getCurrentUserRole(); return role === 'admin' || role === 'asset'; } ensureAssetManagePermission(actionLabel = 'thực hiện thao tác này') { if (this.canCurrentUserManageAssets()) { return true; } this.notifyWarning(`Bạn chỉ có quyền xem tài sản. Chỉ role Asset/Admin mới được ${actionLabel}.`); return false; } getAuthHeaders(includeJson = false) { const headers = { 'x-user-id': String(this.getUserId()), 'x-user-role': this.getCurrentUserRole() }; if (includeJson) { headers['Content-Type'] = 'application/json'; } return headers; } async init() { await this.fetchApplications(); await this.fetchAccounts(); await this.fetchAssets(); await this.fetchAssetBorrows(); await this.fetchAssetDepartments(); if (this.canCurrentUserManageAssets()) { await this.fetchUsers(); } // Check if user is admin and fetch roles if (this.isCurrentUserAdmin()) { await this.fetchRoles(); // Show Users menu const usersNav = document.getElementById('usersNav'); if (usersNav) usersNav.style.display = ''; const usersSection = document.getElementById('usersSection'); if (usersSection) usersSection.style.display = ''; } this.setupEventListeners(); this.setupResponsiveShell(); this.loadModals(); // Load modals từ file riêng // Single-page navigation based on hash this.handleRoute(location.hash || '#dashboard'); window.addEventListener('hashchange', () => this.handleRoute(location.hash)); } handleRoute(hash) { const route = (hash || '#dashboard').replace('#', '') || 'dashboard'; if (this.isMobileViewport()) { this.closeMobileNav(); } this.renderView(route); } renderView(page) { this.currentPage = page; const mainContent = document.getElementById('mainContent'); if (!mainContent) return; if (page === 'applications') { mainContent.innerHTML = this.getApplicationsContent(); this.setupAccountRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); this.setupAppPagerListeners(); } else if (page === 'assets') { mainContent.innerHTML = this.getAssetsContent(); this.setupAssetRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); this.setupAssetPagerListeners(); } else if (page === 'asset-borrows') { mainContent.innerHTML = this.getAssetBorrowsContent(); this.setupAssetBorrowListeners(); this.setupAddButtonListeners(); } else if (page === 'asset-departments') { mainContent.innerHTML = this.getAssetDepartmentsContent(); this.setupAssetDepartmentListeners(); this.setupAddButtonListeners(); } else if (page === 'accounts') { mainContent.innerHTML = this.getAccountsContent(); this.setupAccountRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); this.setupAccountPagerListeners(); } else if (page === 'users') { // Check if user is admin if (!this.isCurrentUserAdmin()) { mainContent.innerHTML = this.renderDashboard(); } else { mainContent.innerHTML = this.getUsersContent(); this.setupUsersRowListeners(); this.setupAddButtonListeners(); this.setupUsersPagerListeners(); } } else { mainContent.innerHTML = this.renderDashboard(); } if (page === 'asset-borrows') { this.startAssetBorrowAutoRefresh(); } else { this.stopAssetBorrowAutoRefresh(); } this.restoreSearchFocus(); this.updatePendingAssetRequestsBadge(); this.setActiveNav(page); } setActiveNav(page) { document.querySelectorAll('[data-nav]').forEach(link => { const isActive = link.dataset.nav === page; link.classList.toggle('border-l-4', isActive); link.classList.toggle('border-blue-600', isActive); link.classList.toggle('bg-slate-200/80', isActive); link.classList.toggle('dark:bg-slate-800', isActive); link.classList.toggle('text-slate-900', isActive); link.classList.toggle('dark:text-slate-50', isActive); link.classList.toggle('font-bold', isActive); }); } isMobileViewport() { return window.matchMedia(`(max-width: ${this.mobileBreakpoint}px)`).matches; } setupResponsiveShell() { const menuBtn = document.getElementById('mobileMenuBtn'); const backdrop = document.getElementById('sidebarBackdrop'); if (menuBtn && !menuBtn.dataset.boundClick) { menuBtn.addEventListener('click', () => this.toggleMobileNav()); menuBtn.dataset.boundClick = 'true'; } if (backdrop && !backdrop.dataset.boundClick) { backdrop.addEventListener('click', () => this.closeMobileNav()); backdrop.dataset.boundClick = 'true'; } document.querySelectorAll('[data-nav]').forEach(link => { if (!link.dataset.boundMobileClose) { link.addEventListener('click', () => { if (this.isMobileViewport()) { this.closeMobileNav(); } }); link.dataset.boundMobileClose = 'true'; } }); if (!this.boundResizeHandler) { this.boundResizeHandler = () => { if (!this.isMobileViewport()) { this.closeMobileNav(); } }; window.addEventListener('resize', this.boundResizeHandler); } if (!this.isMobileViewport()) { this.closeMobileNav(); } } toggleMobileNav() { if (document.body.classList.contains('mobile-nav-open')) { this.closeMobileNav(); return; } this.openMobileNav(); } openMobileNav() { if (!this.isMobileViewport()) return; document.body.classList.add('mobile-nav-open'); const menuBtn = document.getElementById('mobileMenuBtn'); if (menuBtn) { menuBtn.setAttribute('aria-expanded', 'true'); } } closeMobileNav() { document.body.classList.remove('mobile-nav-open'); const menuBtn = document.getElementById('mobileMenuBtn'); if (menuBtn) { menuBtn.setAttribute('aria-expanded', 'false'); } } async fetchApplications() { const res = await fetch(`${this.apiBase}/applications`); const data = await res.json(); if (data.success) { this.applications = data.data; } else { console.error('Load applications failed:', data.message); } } async fetchAccounts() { try { const res = await fetch(`${this.apiBase}/accounts/all`); const data = await res.json(); if (data.success) { this.accounts = data.data; } else { console.error('Load accounts failed:', data.message); } } catch (err) { console.error('Fetch accounts error:', err); } } async fetchUsers() { try { const res = await fetch(`${this.apiBase}/users`); const data = await res.json(); if (data.success) { this.users = data.data; this.refreshAssetCustodianOptions(document.getElementById('assetCustodianInput')?.value || ''); this.refreshBorrowAssetUserOptions(document.getElementById('borrowAssetUserInput')?.value || ''); } else { console.error('Load users failed:', data.message); } } catch (err) { console.error('Fetch users error:', err); } } getUserDisplayName(user) { const fullname = String(user?.FullName || user?.fullname || '').trim(); const username = String(user?.Username || user?.username || '').trim(); return fullname || username || ''; } getUniqueUserDisplayNames() { const users = Array.isArray(this.users) ? this.users : []; const seenNames = new Set(); return users .map(user => this.getUserDisplayName(user)) .filter(name => { if (!name) return false; const key = name.toLowerCase(); if (seenNames.has(key)) return false; seenNames.add(key); return true; }) .sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' })); } populateUserSelectOptions(selectId, { selectedValue = '', emptyLabel = '-- Chon --' } = {}) { const select = document.getElementById(selectId); if (!select) { return; } const normalizedSelected = String(selectedValue || '').trim(); const userNames = this.getUniqueUserDisplayNames(); select.innerHTML = ''; const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = emptyLabel; select.appendChild(emptyOption); let hasSelected = false; userNames.forEach(name => { const option = document.createElement('option'); option.value = name; option.textContent = name; if (normalizedSelected && name === normalizedSelected) { option.selected = true; hasSelected = true; } select.appendChild(option); }); if (normalizedSelected && !hasSelected) { const legacyOption = document.createElement('option'); legacyOption.value = normalizedSelected; legacyOption.textContent = normalizedSelected; legacyOption.selected = true; select.appendChild(legacyOption); } else if (!normalizedSelected) { select.value = ''; } } refreshAssetCustodianOptions(selectedValue = '') { this.populateUserSelectOptions('assetCustodianInput', { selectedValue, emptyLabel: '-- Chon nguoi phu trach --' }); } refreshBorrowAssetUserOptions(selectedValue = '') { this.populateUserSelectOptions('borrowAssetUserInput', { selectedValue, emptyLabel: '-- Chọn người mượn --' }); } getAssetBorrowProductDisplayName(asset) { if (!asset) { return '-- Chọn tài sản --'; } const code = String(asset.AssetCode || '').trim(); const name = String(asset.AssetName || '').trim(); return `${code} - ${name}`.replace(/^\s*-\s*|\s*-\s*$/g, '').trim() || name || '-- Chọn tài sản --'; } getAssetBorrowProductById(assetIdValue) { const assetId = Number(assetIdValue); if (!Number.isFinite(assetId) || assetId <= 0) { return null; } return this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) || this.assets.find(item => Number(item?.AssetId) === assetId) || null; } updateAssetBorrowProductDisplay(assetIdValue) { const hiddenInput = document.getElementById('assetBorrowProductInput'); const displayNode = document.getElementById('assetBorrowProductDisplayText'); const unitInput = document.getElementById('assetBorrowUnitInput'); const asset = this.getAssetBorrowProductById(assetIdValue); if (hiddenInput) { hiddenInput.value = asset ? String(asset.AssetId) : ''; } if (displayNode) { displayNode.textContent = this.getAssetBorrowProductDisplayName(asset); displayNode.classList.toggle('text-slate-600', !asset); displayNode.classList.toggle('text-slate-700', !!asset); } if (unitInput) { unitInput.value = asset ? String(asset.Unit || '').trim() : ''; } } openAssetBorrowProductDropdown() { const dropdown = document.getElementById('assetBorrowProductDropdown'); const searchInput = document.getElementById('assetBorrowProductSearchInput'); if (!dropdown) { return; } dropdown.classList.remove('hidden'); if (searchInput) { searchInput.focus(); searchInput.select(); } } closeAssetBorrowProductDropdown() { const dropdown = document.getElementById('assetBorrowProductDropdown'); if (dropdown) { dropdown.classList.add('hidden'); } } renderAssetBorrowProductList() { const listNode = document.getElementById('assetBorrowProductList'); const loadingNode = document.getElementById('assetBorrowProductLoading'); const hiddenInput = document.getElementById('assetBorrowProductInput'); if (!listNode) { return; } const selectedAssetId = Number(hiddenInput?.value || 0); listNode.style.maxHeight = '224px'; listNode.style.overflow = 'auto'; if (!this.assetBorrowProductItems.length && !this.assetBorrowProductLoading) { listNode.innerHTML = `
Không tìm thấy tài sản phù hợp.
`; } else { listNode.innerHTML = this.assetBorrowProductItems.map(asset => { const assetId = Number(asset?.AssetId); const isSelected = Number.isFinite(selectedAssetId) && selectedAssetId === assetId; const displayName = this.getAssetBorrowProductDisplayName(asset); return ` `; }).join(''); } if (loadingNode) { loadingNode.classList.toggle('hidden', !this.assetBorrowProductLoading); } document.querySelectorAll('.asset-borrow-product-option').forEach(button => { if (button.dataset.boundClick === 'true') { return; } button.addEventListener('click', () => { const assetId = Number(button.dataset.assetId); this.updateAssetBorrowProductDisplay(assetId); this.closeAssetBorrowProductDropdown(); }); button.dataset.boundClick = 'true'; }); } resetAssetBorrowProductSearchState(query = '') { this.assetBorrowProductQuery = String(query || '').trim(); this.assetBorrowProductOffset = 0; this.assetBorrowProductHasMore = true; this.assetBorrowProductItems = []; } async searchAssetBorrowProducts(keyword = '', selectedAssetId = '', options = {}) { const { reset = true } = options; const query = String(keyword || '').trim(); const currentSelectedId = selectedAssetId || document.getElementById('assetBorrowProductInput')?.value || ''; if (reset) { this.resetAssetBorrowProductSearchState(query); } if (this.assetBorrowProductLoading || !this.assetBorrowProductHasMore) { return; } this.assetBorrowProductLoading = true; this.renderAssetBorrowProductList(); const encodedKeyword = encodeURIComponent(this.assetBorrowProductQuery); const offset = this.assetBorrowProductOffset; const limit = this.assetBorrowProductLimit; const appendRows = (rows = [], hasMore = false) => { const source = Array.isArray(rows) ? rows : []; if (source.length) { const merged = new Map( this.assetBorrowProductItems.map(item => [String(item.AssetId), item]) ); source.forEach(item => { merged.set(String(item.AssetId), item); }); this.assetBorrowProductItems = Array.from(merged.values()); this.assetBorrowProductOffset += source.length; } this.assetBorrowProductHasMore = Boolean(hasMore); this.assetBorrowProductLoading = false; this.renderAssetBorrowProductList(); const selectedValue = String(currentSelectedId || '').trim(); if (selectedValue && this.getAssetBorrowProductById(selectedValue)) { this.updateAssetBorrowProductDisplay(selectedValue); } else if (!document.getElementById('assetBorrowProductInput')?.value && this.assetBorrowProductItems.length) { this.updateAssetBorrowProductDisplay(this.assetBorrowProductItems[0].AssetId); } else { this.updateAssetBorrowProductDisplay(document.getElementById('assetBorrowProductInput')?.value || ''); } }; const fallbackFromLocalAssets = () => { const source = Array.isArray(this.assets) ? this.assets : []; const normalized = this.assetBorrowProductQuery.toLowerCase(); const filtered = source.filter(asset => { if (!normalized) { return true; } const haystack = [ asset?.AssetCode, asset?.AssetName, asset?.Model ].map(value => String(value || '').toLowerCase()); return haystack.some(value => value.includes(normalized)); }); const pageRows = filtered.slice(offset, offset + limit); const hasMore = (offset + pageRows.length) < filtered.length; appendRows(pageRows, hasMore); }; try { const response = await fetch(`${this.apiBase}/assets/search?q=${encodedKeyword}&limit=${limit}&offset=${offset}`, { headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { fallbackFromLocalAssets(); return; } appendRows(data.data || [], data.hasMore === true); } catch (err) { console.error('Search asset borrow products error:', err); fallbackFromLocalAssets(); } } setupAssetBorrowRequestModalListeners() { const picker = document.getElementById('assetBorrowProductPicker'); const displayBtn = document.getElementById('assetBorrowProductDisplayBtn'); const dropdown = document.getElementById('assetBorrowProductDropdown'); const productList = document.getElementById('assetBorrowProductList'); const productSearchInput = document.getElementById('assetBorrowProductSearchInput'); if (displayBtn && displayBtn.dataset.boundClick !== 'true') { displayBtn.addEventListener('click', async () => { const isOpen = dropdown && !dropdown.classList.contains('hidden'); if (isOpen) { this.closeAssetBorrowProductDropdown(); return; } this.openAssetBorrowProductDropdown(); if (!this.assetBorrowProductItems.length) { await this.searchAssetBorrowProducts(productSearchInput?.value || '', document.getElementById('assetBorrowProductInput')?.value || '', { reset: true }); } }); displayBtn.dataset.boundClick = 'true'; } if (productSearchInput && productSearchInput.dataset.boundInput !== 'true') { productSearchInput.addEventListener('input', () => { if (this.assetBorrowProductSearchTimer) { clearTimeout(this.assetBorrowProductSearchTimer); } const selectedAssetId = document.getElementById('assetBorrowProductInput')?.value || ''; this.assetBorrowProductSearchTimer = setTimeout(() => { this.searchAssetBorrowProducts(productSearchInput.value, selectedAssetId, { reset: true }); }, 250); }); productSearchInput.dataset.boundInput = 'true'; } if (productList && productList.dataset.boundScroll !== 'true') { productList.addEventListener('scroll', () => { const threshold = 40; const isNearBottom = productList.scrollTop + productList.clientHeight >= productList.scrollHeight - threshold; if (!isNearBottom) { return; } if (this.assetBorrowProductHasMore && !this.assetBorrowProductLoading) { this.searchAssetBorrowProducts( productSearchInput?.value || '', document.getElementById('assetBorrowProductInput')?.value || '', { reset: false } ); } }); productList.dataset.boundScroll = 'true'; } if (picker && picker.dataset.boundOutsideClick !== 'true') { document.addEventListener('click', (event) => { if (!picker.contains(event.target)) { this.closeAssetBorrowProductDropdown(); } }); picker.dataset.boundOutsideClick = 'true'; } } getUniqueAssetDepartmentNames() { const rows = Array.isArray(this.assetDepartments) ? this.assetDepartments : []; const seen = new Set(); return rows .map(item => String(item?.DepartmentName || '').trim()) .filter(name => { if (!name) return false; const key = name.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }) .sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' })); } refreshAssetDepartmentOptions(selectedValue = '') { const select = document.getElementById('assetDepartmentInput'); if (!select) { return; } const normalizedSelected = String(selectedValue || select.value || '').trim(); const departmentNames = this.getUniqueAssetDepartmentNames(); select.innerHTML = ''; const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = '-- Chọn phòng ban --'; select.appendChild(emptyOption); let hasSelected = false; departmentNames.forEach(name => { const option = document.createElement('option'); option.value = name; option.textContent = name; if (normalizedSelected && name === normalizedSelected) { option.selected = true; hasSelected = true; } select.appendChild(option); }); if (normalizedSelected && !hasSelected) { const legacyOption = document.createElement('option'); legacyOption.value = normalizedSelected; legacyOption.textContent = normalizedSelected; legacyOption.selected = true; select.appendChild(legacyOption); } else if (!normalizedSelected) { select.value = ''; } } async fetchAssets() { try { const res = await fetch(`${this.apiBase}/assets`); const data = await res.json(); if (data.success) { this.assets = data.data.map(asset => this.normalizeAssetComputedFields(asset)); this.syncSelectedAssetIds(); const borrowModal = document.getElementById('assetBorrowRequestModal'); if (borrowModal?.classList.contains('open')) { await this.searchAssetBorrowProducts( document.getElementById('assetBorrowProductSearchInput')?.value || '', document.getElementById('assetBorrowProductInput')?.value || '' ); } } else { console.error('Load assets failed:', data.message); } } catch (err) { console.error('Fetch assets error:', err); } } async fetchAssetBorrows() { try { const res = await fetch(`${this.apiBase}/asset-borrows`, { headers: this.getAuthHeaders(false) }); const data = await res.json(); if (data.success) { this.assetBorrows = Array.isArray(data.data) ? data.data : []; this.updatePendingAssetRequestsBadge(); } else { console.error('Load asset borrows failed:', data.message); } } catch (err) { console.error('Fetch asset borrows error:', err); } } async fetchAssetDepartments() { try { const res = await fetch(`${this.apiBase}/asset-departments`); const data = await res.json(); if (data.success) { this.assetDepartments = Array.isArray(data.data) ? data.data : []; this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); } else { console.error('Load asset departments failed:', data.message); } } catch (err) { console.error('Fetch asset departments error:', err); } } async fetchRoles() { try { const res = await fetch(`${this.apiBase}/roles`); const data = await res.json(); if (data.success) { this.roles = data.data; } else { console.error('Load roles failed:', data.message); } } catch (err) { console.error('Fetch roles error:', err); } } async loadModals() { try { const existingContainer = document.getElementById('modalsContainer'); if (existingContainer && existingContainer.children.length) { return; } const response = await fetch('../modals.html'); const modalsHTML = await response.text(); const container = existingContainer || document.createElement('div'); if (!container.id) container.id = 'modalsContainer'; container.innerHTML = modalsHTML; if (!container.parentElement) { document.body.appendChild(container); } this.setupFormListeners(); this.setupAccountRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); } catch (error) { console.error('Lỗi load modals:', error); } } restoreSearchFocus() { const accountSearch = document.getElementById('accountSearch'); const appSearch = document.getElementById('appSearch'); const assetSearch = document.getElementById('assetSearch'); const assetBorrowSearch = document.getElementById('assetBorrowSearch'); const assetDepartmentSearch = document.getElementById('assetDepartmentSearch'); if (accountSearch && accountSearch.dataset.focused === 'true') { const pos = accountSearch.selectionStart || accountSearch.value.length; accountSearch.focus(); accountSearch.setSelectionRange(pos, pos); } if (appSearch && appSearch.dataset.focused === 'true') { const pos = appSearch.selectionStart || appSearch.value.length; appSearch.focus(); appSearch.setSelectionRange(pos, pos); } if (assetSearch && assetSearch.dataset.focused === 'true') { const pos = assetSearch.selectionStart || assetSearch.value.length; assetSearch.focus(); assetSearch.setSelectionRange(pos, pos); } if (assetBorrowSearch && assetBorrowSearch.dataset.focused === 'true') { const pos = assetBorrowSearch.selectionStart || assetBorrowSearch.value.length; assetBorrowSearch.focus(); assetBorrowSearch.setSelectionRange(pos, pos); } if (assetDepartmentSearch && assetDepartmentSearch.dataset.focused === 'true') { const pos = assetDepartmentSearch.selectionStart || assetDepartmentSearch.value.length; assetDepartmentSearch.focus(); assetDepartmentSearch.setSelectionRange(pos, pos); } } setupEventListeners() { // Modal close buttons document.querySelectorAll('[data-close-modal]').forEach(btn => { btn.addEventListener('click', () => this.closeModals()); }); // Close with Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closeMobileNav(); this.closeModals(); } }); // Form submissions // Logout button const logoutBtn = document.getElementById('logoutBtn'); if (logoutBtn) { logoutBtn.addEventListener('click', () => this.handleLogout()); } const profileBtn = document.getElementById('profileBtn'); if (profileBtn) { profileBtn.addEventListener('click', () => this.openProfileModal()); } const pendingAssetRequestsBtn = document.getElementById('pendingAssetRequestsBtn'); if (pendingAssetRequestsBtn) { pendingAssetRequestsBtn.addEventListener('click', () => this.openPendingAssetRequestsModal()); } // Update account display this.updateAccountDisplay(); // Account table row clicks this.setupAccountRowListeners(); this.setupFilters(); this.setupResponsiveShell(); } setupFormListeners() { const accountForm = document.getElementById('accountForm'); if (accountForm) { if (!accountForm.dataset.boundSubmit) { accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e)); accountForm.dataset.boundSubmit = 'true'; } } const appForm = document.getElementById('appForm'); if (appForm) { if (!appForm.dataset.boundSubmit) { appForm.addEventListener('submit', (e) => this.handleAppSubmit(e)); appForm.dataset.boundSubmit = 'true'; } } const assetForm = document.getElementById('assetForm'); if (assetForm) { if (!assetForm.dataset.boundSubmit) { assetForm.addEventListener('submit', (e) => this.handleAssetSubmit(e)); assetForm.dataset.boundSubmit = 'true'; } this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); this.setupAssetStockListeners(); this.setupAssetFormValidationListeners(); } const borrowAssetForm = document.getElementById('borrowAssetForm'); if (borrowAssetForm) { if (!borrowAssetForm.dataset.boundSubmit) { borrowAssetForm.addEventListener('submit', (e) => this.handleBorrowAssetSubmit(e)); borrowAssetForm.dataset.boundSubmit = 'true'; } } const assetBorrowRequestForm = document.getElementById('assetBorrowRequestForm'); if (assetBorrowRequestForm) { if (!assetBorrowRequestForm.dataset.boundSubmit) { assetBorrowRequestForm.addEventListener('submit', (e) => this.handleAssetBorrowRequestSubmit(e)); assetBorrowRequestForm.dataset.boundSubmit = 'true'; } } const assetRequestRejectForm = document.getElementById('assetRequestRejectForm'); if (assetRequestRejectForm) { if (!assetRequestRejectForm.dataset.boundSubmit) { assetRequestRejectForm.addEventListener('submit', (e) => this.handleAssetRequestRejectSubmit(e)); assetRequestRejectForm.dataset.boundSubmit = 'true'; } } this.setupAssetBorrowRequestModalListeners(); const assetDepartmentForm = document.getElementById('assetDepartmentForm'); if (assetDepartmentForm) { if (!assetDepartmentForm.dataset.boundSubmit) { assetDepartmentForm.addEventListener('submit', (e) => this.handleAssetDepartmentSubmit(e)); assetDepartmentForm.dataset.boundSubmit = 'true'; } } document.querySelectorAll('.confirm-delete-asset-department').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => this.confirmDeleteAssetDepartment()); btn.dataset.boundClick = 'true'; }); // Close when clicking backdrop outside modal content document.querySelectorAll('.modal-backdrop').forEach(backdrop => { backdrop.addEventListener('click', (evt) => { if (evt.target === backdrop) { this.closeModals(); } }); }); } updateAccountDisplay() { // Use the logged-in user from constructor const usernameEl = document.getElementById('accountUsername'); const roleEl = document.getElementById('accountRole'); if (usernameEl) usernameEl.textContent = this.currentUser?.username || this.currentUser?.Username || 'User'; if (roleEl) roleEl.textContent = this.getCurrentUserRoleRaw() || 'Guest'; } getFilteredAccounts() { const svcFilter = this.accountServiceFilter || ''; const search = (this.accountSearchTerm || '').toLowerCase(); return this.accounts.filter(acc => { const matchesService = !svcFilter || String(acc.AppId) === String(svcFilter); if (!matchesService) return false; if (!search) return true; const hay = [acc.AccountUsername, acc.Email, acc.AppName, acc.AppType].map(v => (v || '').toLowerCase()); return hay.some(val => val.includes(search)); }); } getFilteredApplications() { const search = (this.applicationSearchTerm || '').toLowerCase(); if (!search) return this.applications; return this.applications.filter(app => { const hay = [app.Name, app.Type, app.Description, app.Url, app.Icon].map(v => (v || '').toLowerCase()); return hay.some(val => val.includes(search)); }); } getFilteredAssets() { const statusFilter = (this.assetStatusFilter || '').toLowerCase(); const search = (this.assetSearchTerm || '').toLowerCase(); return this.assets.filter(asset => { const status = String(asset.Status || '').toLowerCase(); const matchesStatus = !statusFilter || status === statusFilter; if (!matchesStatus) { return false; } if (!search) { return true; } const haystack = [ asset.AssetCode, asset.AssetName, asset.Model, asset.SerialNumber, asset.ImportInPeriod, asset.ExportInPeriod, asset.EndingBalance, asset.Department, asset.Project, asset.Location, asset.Custodian, asset.Borrower, asset.ExportedBy, asset.Notes ].map(v => String(v || '').toLowerCase()); return haystack.some(value => value.includes(search)); }); } getFilteredAssetBorrows() { const search = String(this.assetBorrowSearchTerm || '').toLowerCase(); const rows = Array.isArray(this.assetBorrows) ? this.assetBorrows : []; return rows.filter(item => { if (!search) { return true; } const haystack = [ item.BorrowerName, item.AssetCode, item.AssetName, this.getAssetRequestTypeMeta(item.RequestType).label, this.getAssetRequestStatusMeta(item.RequestStatus).label, item.Unit, item.BorrowQuantity, item.BorrowDate, item.RequestNote, item.RejectReason ].map(value => String(value || '').toLowerCase()); return haystack.some(value => value.includes(search)); }); } syncSelectedAssetIds() { if (!(this.selectedAssetIds instanceof Set)) { this.selectedAssetIds = new Set(); } const validIds = new Set( this.assets .map(asset => Number(asset.AssetId)) .filter(id => Number.isFinite(id)) ); this.selectedAssetIds = new Set( [...this.selectedAssetIds].filter(id => validIds.has(Number(id))) ); } getPaged(items, page, pageSize) { const total = items.length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const current = Math.min(Math.max(1, page), totalPages); const start = (current - 1) * pageSize; return { current, total, totalPages, data: items.slice(start, start + pageSize), start: total === 0 ? 0 : start + 1, end: Math.min(total, start + pageSize) }; } maskForeignAccountUsername(username) { const value = String(username || '').trim(); if (!value) return '-'; if (value.length < 5) { return `${value.slice(0, 1)}*****`; } return `${value.slice(0, 3)}*****`; } handleLogout() { if (confirm('Are you sure you want to logout?')) { this.saveToStorage('currentUser', null); localStorage.clear(); window.location.href = '../pages/login.html'; } } renderDashboard() { return `

System Overview

Account & Service Management

Applications
${this.applications.length} ${this.applications.filter(a => (a.Status || a.status) === 'online').length} Active
Total Accounts
${this.accounts.length} Managed
Last Updated
${new Date().toLocaleDateString()}
Status
Operational check_circle

history Recent Accounts

${this.accounts.length > 0 ? `
${this.accounts.slice(-5).reverse().map(acc => { const username = acc.AccountUsername || acc.username || '-'; const service = acc.AppName || acc.service || '-'; const owner = acc.Email || acc.owner || this.currentUser?.Username || this.currentUser?.username || '-'; return `

${username}

${service} ? ${owner}

`;}).join('')}
` : `

No accounts yet. Create one

`}
`; } getAccountsContent() { const filteredAccounts = this.getFilteredAccounts(); const currentUserId = this.getUserId(); const pageInfo = this.getPaged(filteredAccounts, this.accountPage, this.accountPageSize); this.accountPage = pageInfo.current; return `
Service
Search
${pageInfo.data.length > 0 ? `
${pageInfo.data.map(acc => { const isOwnAccount = acc.UserId == currentUserId; const accountUsername = acc.AccountUsername || '-'; const displayAccountUsername = isOwnAccount ? accountUsername : this.maskForeignAccountUsername(accountUsername); const createdDate = this.formatDateTime(acc.CreatedDate); const updatedDate = this.formatDateTime(acc.UpdatedDate); const actionContent = isOwnAccount ? `` : '-'; return ` `; }).join('')}
Owner Username Service Created Date Last Updated Actions
Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
` : `

No accounts yet. Create one to get started.

`}
`; } getApplicationsContent() { const filteredApps = this.getFilteredApplications(); const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize); this.appPage = pageInfo.current; return `
Search
${pageInfo.data.map(app => ` `).join('')}
Name Type Description URL Status Actions
${app.Icon || 'apps'}
${app.Name}
${app.Type} ${app.Description || '-'} ${(app.Url || app.url) ? `${app.Url || app.url}` : '-'}
${(app.Status || app.status) === 'online' ? 'Online' : 'Offline'}
Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`; } getAssetStatusMeta(status) { const normalized = String(status || '').toLowerCase(); if (normalized === 'in_stock') { return { label: 'Trong kho', className: 'bg-emerald-100 text-emerald-700' }; } if (normalized === 'maintenance') { return { label: 'Bảo trì', className: 'bg-amber-100 text-amber-700' }; } if (normalized === 'disposed') { return { label: 'Thanh lý', className: 'bg-rose-100 text-rose-700' }; } return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' }; } formatDateOnly(value) { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleDateString(); } toDateInputValue(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } formatBorrowerDisplay(name, quantity = 1) { const cleanName = String(name || '').trim(); if (!cleanName) return null; const quantityNumber = Number(quantity); const safeQuantity = Number.isInteger(quantityNumber) && quantityNumber > 0 ? quantityNumber : 1; return `${cleanName} - số lượng: ${safeQuantity}`; } parseNonNegativeInteger(value, fallback = 0) { if (value === null || value === undefined || value === '') { return fallback; } const parsed = Number.parseInt(String(value).replace(/,/g, '').trim(), 10); if (!Number.isFinite(parsed) || parsed < 0) { return fallback; } return parsed; } escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } normalizeAssetRequestType(value) { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'return' ? 'return' : 'borrow'; } normalizeAssetRequestStatus(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'approved') return 'approved'; if (normalized === 'rejected') return 'rejected'; return 'pending'; } getAssetRequestTypeMeta(value) { const requestType = this.normalizeAssetRequestType(value); if (requestType === 'return') { return { value: 'return', label: 'Trả tài sản', className: 'bg-emerald-100 text-emerald-700 border border-emerald-200' }; } return { value: 'borrow', label: 'Mượn tài sản', className: 'bg-blue-100 text-blue-700 border border-blue-200' }; } getAssetRequestStatusMeta(value) { const status = this.normalizeAssetRequestStatus(value); if (status === 'approved') { return { value: 'approved', label: 'Chấp nhận', className: 'bg-green-100 text-green-700 border border-green-200' }; } if (status === 'rejected') { return { value: 'rejected', label: 'Từ chối', className: 'bg-red-100 text-red-700 border border-red-200' }; } return { value: 'pending', label: 'Đang chờ', className: 'bg-yellow-100 text-yellow-700 border border-yellow-200' }; } getPendingAssetRequestCount() { if (!this.canCurrentUserManageAssets()) { return 0; } return (Array.isArray(this.assetBorrows) ? this.assetBorrows : []) .filter(item => this.normalizeAssetRequestStatus(item?.RequestStatus) === 'pending') .length; } updatePendingAssetRequestsBadge() { const shouldShow = this.canCurrentUserManageAssets(); const count = this.getPendingAssetRequestCount(); const displayCount = count > 99 ? '99+' : String(count); const topButton = document.getElementById('pendingAssetRequestsBtn'); if (topButton) { topButton.classList.toggle('hidden', !shouldShow); } const topBadge = document.getElementById('pendingAssetRequestsBadge'); if (topBadge) { topBadge.classList.toggle('hidden', !shouldShow || count <= 0); topBadge.textContent = displayCount; } const pageBadge = document.getElementById('pendingAssetBorrowsCountBadge'); if (pageBadge) { pageBadge.classList.toggle('hidden', count <= 0); pageBadge.textContent = displayCount; } } parseBorrowerEntries(rawBorrower) { if (Array.isArray(rawBorrower)) { const merged = []; rawBorrower.forEach(item => { if (!item) return; const name = String(item.name || item.Name || '').trim(); const quantity = this.parseNonNegativeInteger(item.quantity ?? item.Quantity, 0); if (!name || quantity <= 0) return; const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); if (existed) { existed.quantity += quantity; } else { merged.push({ name, quantity }); } }); return merged; } const source = String(rawBorrower || '').trim(); if (!source) { return []; } const chunks = source .split(/[\n;]+/g) .map(item => String(item || '').trim()) .filter(Boolean); const merged = []; chunks.forEach(chunk => { let name = chunk; let quantity = 1; const labeledMatch = chunk.match(/^(.*?)(?:\s*-\s*[^:]+:\s*(\d+))\s*$/i); if (labeledMatch) { name = String(labeledMatch[1] || '').trim(); quantity = this.parseNonNegativeInteger(labeledMatch[2], 1); } else { const colonMatch = chunk.match(/^(.*?)\s*:\s*(\d+)\s*$/); const xMatch = chunk.match(/^(.*?)\s*x\s*(\d+)\s*$/i); const parenMatch = chunk.match(/^(.*?)\s*\(\s*(\d+)\s*\)\s*$/); const fallbackMatch = colonMatch || xMatch || parenMatch; if (fallbackMatch) { name = String(fallbackMatch[1] || '').trim(); quantity = this.parseNonNegativeInteger(fallbackMatch[2], 1); } } if (!name || quantity <= 0) { return; } const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); if (existed) { existed.quantity += quantity; } else { merged.push({ name, quantity }); } }); return merged; } formatBorrowerEntries(entries, separator = '; ') { if (!Array.isArray(entries) || !entries.length) { return ''; } return entries .map(entry => this.formatBorrowerDisplay(entry?.name, entry?.quantity)) .filter(Boolean) .join(separator); } formatBorrowerSummaryText(rawBorrower) { const entries = this.parseBorrowerEntries(rawBorrower); return this.formatBorrowerEntries(entries, '\n'); } formatBorrowerTableHtml(rawBorrower) { const entries = this.parseBorrowerEntries(rawBorrower); if (!entries.length) { return '-'; } return entries .map(entry => this.formatBorrowerDisplay(entry.name, entry.quantity)) .filter(Boolean) .map(item => `
${this.escapeHtml(item)}
`) .join(''); } mergeBorrowerEntries(existingEntries, borrowerName, borrowQuantity) { const merged = this.parseBorrowerEntries(existingEntries); const name = String(borrowerName || '').trim(); const quantity = this.parseNonNegativeInteger(borrowQuantity, 0); if (!name || quantity <= 0) { return merged; } const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); if (existed) { existed.quantity += quantity; } else { merged.push({ name, quantity }); } return merged; } buildAssetQuantityMetrics(asset, borrowerEntriesOverride = null) { const quantity = this.parseNonNegativeInteger(asset?.Quantity ?? asset?.quantity, 0); const importInPeriod = this.parseNonNegativeInteger(asset?.ImportInPeriod ?? asset?.importInPeriod, 0); const borrowerEntries = Array.isArray(borrowerEntriesOverride) ? this.parseBorrowerEntries(borrowerEntriesOverride) : this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower); const exportInPeriod = borrowerEntries.reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); return { quantity, importInPeriod, exportInPeriod, endingBalance, borrowerEntries }; } normalizeAssetComputedFields(asset) { if (!asset || typeof asset !== 'object') { return asset; } const metrics = this.buildAssetQuantityMetrics(asset); return { ...asset, Quantity: metrics.quantity, ImportInPeriod: metrics.importInPeriod, ExportInPeriod: metrics.exportInPeriod, EndingBalance: metrics.endingBalance, Borrower: this.formatBorrowerEntries(metrics.borrowerEntries, '; ') || null }; } recalculateAssetStockFields() { const quantityInput = document.getElementById('assetQuantityInput'); const importInput = document.getElementById('assetImportInPeriodInput'); const exportInput = document.getElementById('assetExportInPeriodInput'); const endingInput = document.getElementById('assetEndingBalanceInput'); if (!quantityInput || !importInput) { return; } const metrics = this.buildAssetQuantityMetrics( { Quantity: quantityInput.value, ImportInPeriod: importInput.value }, this.editingAssetBorrowerEntries ); if (exportInput) { exportInput.value = String(metrics.exportInPeriod); } if (endingInput) { endingInput.value = String(metrics.endingBalance); } } setupAssetStockListeners() { ['assetQuantityInput', 'assetImportInPeriodInput'].forEach(fieldId => { const input = document.getElementById(fieldId); if (!input || input.dataset.boundStockListener) { return; } input.addEventListener('input', () => this.recalculateAssetStockFields()); input.addEventListener('change', () => this.recalculateAssetStockFields()); input.dataset.boundStockListener = 'true'; }); } getFilteredAssetDepartments() { const search = String(this.assetDepartmentSearchTerm || '').toLowerCase(); const source = Array.isArray(this.assetDepartments) ? this.assetDepartments : []; return source.filter(item => { if (!search) { return true; } const name = String(item?.DepartmentName || '').toLowerCase(); return name.includes(search); }); } buildAssetDepartmentsRowsHtml(departments = []) { const canManageAssets = this.canCurrentUserManageAssets(); if (!departments.length) { return ` Chưa có phòng ban nào. `; } return departments.map((item, index) => { const departmentId = Number(item?.DepartmentId); const assetCount = Number(item?.AssetCount) || 0; const departmentName = this.escapeHtml(item?.DepartmentName || '-'); return ` ${index + 1} ${departmentName} ${assetCount}
`; }).join(''); } getAssetDepartmentsContent() { const filteredDepartments = this.getFilteredAssetDepartments(); const canManageAssets = this.canCurrentUserManageAssets(); return `
Tìm kiếm
${this.buildAssetDepartmentsRowsHtml(filteredDepartments)}
STT Phòng ban Sd tài sản Thao tác
Tổng phòng ban: ${filteredDepartments.length}
`; } renderAssetDepartmentsTableBody() { const tbody = document.querySelector('.asset-departments-table-body'); if (!tbody) { return; } const filteredDepartments = this.getFilteredAssetDepartments(); tbody.innerHTML = this.buildAssetDepartmentsRowsHtml(filteredDepartments); const countElement = document.getElementById('assetDepartmentCount'); if (countElement) { countElement.textContent = String(filteredDepartments.length); } this.setupAssetDepartmentActionListeners(); } setupAssetDepartmentActionListeners() { document.querySelectorAll('.edit-asset-department').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const departmentId = Number(btn.dataset.departmentId); if (!Number.isFinite(departmentId)) { return; } this.handleUpdateAssetDepartment(departmentId); }); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.delete-asset-department').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const departmentId = Number(btn.dataset.departmentId); if (!Number.isFinite(departmentId)) { return; } this.handleDeleteAssetDepartment(departmentId); }); btn.dataset.boundClick = 'true'; }); } setupAssetDepartmentListeners() { const searchInput = document.getElementById('assetDepartmentSearch'); if (searchInput && searchInput.dataset.boundInput !== 'true') { searchInput.addEventListener('input', (event) => { this.assetDepartmentSearchTerm = String(event.target.value || '').trim(); this.renderAssetDepartmentsTableBody(); }); searchInput.addEventListener('focus', () => { searchInput.dataset.focused = 'true'; }); searchInput.addEventListener('blur', () => { searchInput.dataset.focused = 'false'; }); searchInput.dataset.boundInput = 'true'; } this.setupAssetDepartmentActionListeners(); } async refreshAssetDepartmentsUI() { await this.fetchAssetDepartments(); if (this.currentPage === 'asset-departments') { this.renderAssetDepartmentsTableBody(); } } getAssetDepartmentById(departmentId) { return this.assetDepartments.find(item => Number(item?.DepartmentId) === Number(departmentId)) || null; } openAssetDepartmentModal(department = null) { const modal = document.getElementById('assetDepartmentModal'); const titleNode = document.getElementById('assetDepartmentModalTitle'); const nameInput = document.getElementById('assetDepartmentNameInput'); if (!modal || !nameInput) { this.notifyFailure('Không mở được biểu mẫu phòng ban'); return; } const editing = department && Number.isFinite(Number(department.DepartmentId)); this.editingAssetDepartmentId = editing ? Number(department.DepartmentId) : undefined; if (titleNode) { titleNode.textContent = editing ? 'Sửa phòng ban' : 'Thêm phòng ban'; } nameInput.value = editing ? String(department.DepartmentName || '') : ''; modal.classList.add('open'); nameInput.focus(); nameInput.select(); } openDeleteAssetDepartmentModal(department) { const modal = document.getElementById('deleteAssetDepartmentModal'); const nameNode = document.getElementById('deleteAssetDepartmentName'); if (!modal) { this.notifyFailure('Không mở được hộp thoại xóa phòng ban'); return; } this.pendingDeleteAssetDepartmentId = Number(department?.DepartmentId); if (nameNode) { nameNode.textContent = String(department?.DepartmentName || '-'); } modal.classList.add('open'); } async handleCreateAssetDepartment() { if (!this.ensureAssetManagePermission('thêm phòng ban')) { return; } this.openAssetDepartmentModal(null); } async handleAssetDepartmentSubmit(event) { event.preventDefault(); if (!this.ensureAssetManagePermission('thêm hoặc sửa phòng ban')) { return; } const nameInput = document.getElementById('assetDepartmentNameInput'); const departmentName = String(nameInput?.value || '').trim(); if (!departmentName) { this.notifyWarning('Tên phòng ban là bắt buộc'); return; } const isEdit = Number.isFinite(Number(this.editingAssetDepartmentId)); const endpoint = isEdit ? `${this.apiBase}/asset-departments/${this.editingAssetDepartmentId}` : `${this.apiBase}/asset-departments`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(endpoint, { method, headers: this.getAuthHeaders(true), body: JSON.stringify({ departmentName }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Lưu phòng ban thất bại'); return; } this.editingAssetDepartmentId = undefined; closeAssetDepartmentModal(); this.notifySuccess(isEdit ? 'Cập nhật phòng ban thành công' : 'Thêm phòng ban thành công'); await this.refreshAssetDepartmentsUI(); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Lưu phòng ban thất bại'); } } async handleUpdateAssetDepartment(departmentId) { if (!this.ensureAssetManagePermission('sửa phòng ban')) { return; } const targetDepartment = this.getAssetDepartmentById(departmentId); if (!targetDepartment) { this.notifyWarning('Không tìm thấy phòng ban'); return; } this.openAssetDepartmentModal(targetDepartment); } async handleDeleteAssetDepartment(departmentId) { if (!this.ensureAssetManagePermission('xóa phòng ban')) { return; } const targetDepartment = this.getAssetDepartmentById(departmentId); if (!targetDepartment) { this.notifyWarning('Không tìm thấy phòng ban'); return; } this.openDeleteAssetDepartmentModal(targetDepartment); } async confirmDeleteAssetDepartment() { if (!this.ensureAssetManagePermission('xóa phòng ban')) { return; } if (!Number.isFinite(Number(this.pendingDeleteAssetDepartmentId))) { return; } try { const response = await fetch(`${this.apiBase}/asset-departments/${this.pendingDeleteAssetDepartmentId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xóa phòng ban thất bại'); return; } this.pendingDeleteAssetDepartmentId = undefined; closeDeleteAssetDepartmentModal(); this.notifySuccess('Xóa phòng ban thành công'); await this.refreshAssetDepartmentsUI(); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Xóa phòng ban thất bại'); } } buildAssetBorrowRowHtml(item, rowNumber) { const assetName = item.AssetName || '-'; const assetCode = item.AssetCode ? `
${this.escapeHtml(item.AssetCode)}
` : ''; const typeMeta = this.getAssetRequestTypeMeta(item.RequestType); const statusMeta = this.getAssetRequestStatusMeta(item.RequestStatus); const note = String(item?.RequestNote || '').trim(); const rejectReason = String(item?.RejectReason || '').trim(); return ` ${rowNumber} ${this.escapeHtml(item.BorrowerName || '-')}
${this.escapeHtml(assetName)}
${assetCode} ${typeMeta.label} ${statusMeta.label} ${this.escapeHtml(item.Unit || '-')} ${Number(item.BorrowQuantity) || 0} ${this.formatDateOnly(item.BorrowDate)} ${this.escapeHtml(note || '-')} ${this.escapeHtml(rejectReason || '-')} `; } buildAssetBorrowEmptyRowHtml() { return ` Chưa có đơn mượn/trả tài sản nào. `; } renderAssetBorrowsPager(pageInfo) { const pager = document.getElementById('assetBorrowsPager'); if (!pager) { return; } pager.innerHTML = ` Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } getAssetBorrowsContent() { const canManageAssets = this.canCurrentUserManageAssets(); const filteredBorrows = this.getFilteredAssetBorrows(); const pageInfo = this.getPaged(filteredBorrows, this.assetBorrowPage, this.assetBorrowPageSize); this.assetBorrowPage = pageInfo.current; const pendingCount = this.getPendingAssetRequestCount(); return `
Tìm kiếm
${pageInfo.data.length > 0 ? pageInfo.data.map((item, index) => this.buildAssetBorrowRowHtml(item, pageInfo.start + index)).join('') : this.buildAssetBorrowEmptyRowHtml()}
STT Tên đầy đủ Tài sản Danh mục Trạng thái Đơn vị Số lượng Ngày Ghi chú Lý do
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } renderAssetBorrowsTableBody() { const tbody = document.querySelector('.asset-borrows-table-body'); if (!tbody) { return; } const pageInfo = this.getPaged(this.getFilteredAssetBorrows(), this.assetBorrowPage, this.assetBorrowPageSize); this.assetBorrowPage = pageInfo.current; if (!pageInfo.data.length) { tbody.innerHTML = this.buildAssetBorrowEmptyRowHtml(); } else { tbody.innerHTML = pageInfo.data .map((item, index) => this.buildAssetBorrowRowHtml(item, pageInfo.start + index)) .join(''); } this.renderAssetBorrowsPager(pageInfo); this.setupAssetBorrowPagerListeners(); this.updatePendingAssetRequestsBadge(); } setupAssetBorrowPagerListeners() { document.querySelectorAll('.asset-borrow-page-btn').forEach(btn => { btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) { return; } this.assetBorrowPage = targetPage; this.renderAssetBorrowsTableBody(); }); }); } setupAssetBorrowListeners() { const searchInput = document.getElementById('assetBorrowSearch'); if (searchInput && searchInput.dataset.boundInput !== 'true') { searchInput.addEventListener('input', (event) => { this.assetBorrowSearchTerm = String(event.target.value || '').trim(); this.assetBorrowPage = 1; this.renderAssetBorrowsTableBody(); }); searchInput.addEventListener('focus', () => { searchInput.dataset.focused = 'true'; }); searchInput.addEventListener('blur', () => { searchInput.dataset.focused = 'false'; }); searchInput.dataset.boundInput = 'true'; } this.setupAssetBorrowPagerListeners(); } async refreshAssetBorrowsUI() { await this.fetchAssetBorrows(); if (this.currentPage === 'asset-borrows') { this.renderAssetBorrowsTableBody(); } const pendingModal = document.getElementById('assetPendingRequestsModal'); if (pendingModal?.classList.contains('open')) { this.renderPendingAssetRequestsModal(); } } startAssetBorrowAutoRefresh() { if (this.assetBorrowAutoRefreshTimer) { return; } this.assetBorrowAutoRefreshTimer = setInterval(() => { if (this.currentPage === 'asset-borrows') { this.refreshAssetBorrowsUI(); } }, 15000); } stopAssetBorrowAutoRefresh() { if (!this.assetBorrowAutoRefreshTimer) { return; } clearInterval(this.assetBorrowAutoRefreshTimer); this.assetBorrowAutoRefreshTimer = undefined; } async openAssetBorrowRequestModal(requestType = 'borrow') { if (!this.assets.length) { await this.fetchAssets(); } this.assetBorrowRequestType = this.normalizeAssetRequestType(requestType); const modal = document.getElementById('assetBorrowRequestModal'); const typeInput = document.getElementById('assetBorrowRequestTypeInput'); const titleNode = document.getElementById('assetBorrowRequestModalTitle'); const dateLabel = document.getElementById('assetBorrowDateLabel'); const submitBtn = document.getElementById('assetBorrowRequestSubmitBtn'); const noteInput = document.getElementById('assetBorrowNoteInput'); const requesterInput = document.getElementById('assetBorrowRequesterInput'); const productSearchInput = document.getElementById('assetBorrowProductSearchInput'); const productInput = document.getElementById('assetBorrowProductInput'); const quantityInput = document.getElementById('assetBorrowQuantityInput'); const dateInput = document.getElementById('assetBorrowDateInput'); if (!modal || !requesterInput || !productInput || !quantityInput || !dateInput || !productSearchInput || !typeInput) { this.notifyFailure('Không tìm thấy biểu mẫu đơn mượn/trả tài sản.'); return; } const isReturnRequest = this.assetBorrowRequestType === 'return'; typeInput.value = this.assetBorrowRequestType; if (titleNode) { titleNode.textContent = isReturnRequest ? 'Tạo đơn trả tài sản' : 'Tạo đơn mượn tài sản'; } if (dateLabel) { dateLabel.textContent = isReturnRequest ? 'Ngày trả' : 'Ngày mượn'; } if (submitBtn) { submitBtn.textContent = isReturnRequest ? 'Tạo đơn trả' : 'Tạo đơn mượn'; } requesterInput.value = this.getCurrentUserDisplayName(); quantityInput.value = '1'; quantityInput.min = '1'; dateInput.value = this.toDateInputValue(new Date()); if (noteInput) { noteInput.value = ''; } productSearchInput.value = ''; productInput.value = ''; this.updateAssetBorrowProductDisplay(''); this.closeAssetBorrowProductDropdown(); await this.searchAssetBorrowProducts('', '', { reset: true }); if (!this.assetBorrowProductItems.length) { this.notifyWarning('Hiện chưa có tài sản để tạo đơn.'); return; } modal.classList.add('open'); } async handleAssetBorrowRequestSubmit(event) { event.preventDefault(); const typeInput = document.getElementById('assetBorrowRequestTypeInput'); const productInput = document.getElementById('assetBorrowProductInput'); const quantityInput = document.getElementById('assetBorrowQuantityInput'); const unitInput = document.getElementById('assetBorrowUnitInput'); const dateInput = document.getElementById('assetBorrowDateInput'); const requesterInput = document.getElementById('assetBorrowRequesterInput'); const noteInput = document.getElementById('assetBorrowNoteInput'); const requestType = this.normalizeAssetRequestType(typeInput?.value || this.assetBorrowRequestType); const assetId = Number(productInput?.value || 0); if (!Number.isFinite(assetId) || assetId <= 0) { this.notifyWarning('Vui lòng chọn tài sản.'); return; } const quantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); if (quantity <= 0) { this.notifyWarning('Số lượng phải lớn hơn 0.'); return; } const borrowDate = String(dateInput?.value || '').trim() || this.toDateInputValue(new Date()); const unit = String(unitInput?.value || '').trim(); const borrowerName = String(requesterInput?.value || this.getCurrentUserDisplayName() || '').trim(); const note = String(noteInput?.value || '').trim(); try { const response = await fetch(`${this.apiBase}/asset-borrows`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ assetId, requestType, quantity, unit, borrowDate, borrowerName, note }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Tạo đơn thất bại'); return; } closeAssetBorrowRequestModal(); this.notifySuccess(requestType === 'return' ? 'Tạo đơn trả tài sản thành công' : 'Tạo đơn mượn tài sản thành công'); await this.refreshAssetBorrowsUI(); } catch (err) { console.error(err); this.notifyFailure('Tạo đơn thất bại'); } } buildPendingAssetRequestCardHtml(item) { const typeMeta = this.getAssetRequestTypeMeta(item?.RequestType); const dateLabel = typeMeta.value === 'return' ? 'Ngày trả' : 'Ngày mượn'; const note = String(item?.RequestNote || '').trim(); const requestId = Number(item?.BorrowId) || 0; return `
${typeMeta.label} #${requestId || '-'}
Tên đầy đủ: ${this.escapeHtml(item?.BorrowerName || '-')}
Tên tài sản: ${this.escapeHtml(item?.AssetCode || '')} ${item?.AssetCode ? '- ' : ''}${this.escapeHtml(item?.AssetName || '-')}
Số lượng: ${Number(item?.BorrowQuantity) || 0} ${this.escapeHtml(item?.Unit || '')}
Ghi chú: ${this.escapeHtml(note || '-')}
${dateLabel}: ${this.formatDateOnly(item?.BorrowDate)}
`; } bindPendingAssetRequestActionButtons() { document.querySelectorAll('.asset-request-approve-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const requestId = Number(btn.dataset.requestId); if (!Number.isFinite(requestId) || requestId <= 0) { return; } this.processAssetBorrowRequest(requestId, 'approved'); }); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.asset-request-reject-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const requestId = Number(btn.dataset.requestId); if (!Number.isFinite(requestId) || requestId <= 0) { return; } this.openAssetRequestRejectModal(requestId); }); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.asset-request-delete-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const requestId = Number(btn.dataset.requestId); if (!Number.isFinite(requestId) || requestId <= 0) { return; } this.deletePendingAssetBorrowRequest(requestId); }); btn.dataset.boundClick = 'true'; }); } renderPendingAssetRequestsModal() { const borrowList = document.getElementById('pendingBorrowRequestsList'); const returnList = document.getElementById('pendingReturnRequestsList'); if (!borrowList || !returnList) { return; } const pendingRequests = (Array.isArray(this.assetBorrows) ? this.assetBorrows : []) .filter(item => this.normalizeAssetRequestStatus(item?.RequestStatus) === 'pending'); const pendingBorrowRequests = pendingRequests.filter(item => this.normalizeAssetRequestType(item?.RequestType) === 'borrow'); const pendingReturnRequests = pendingRequests.filter(item => this.normalizeAssetRequestType(item?.RequestType) === 'return'); const borrowCountBadge = document.getElementById('pendingBorrowCountBadge'); const returnCountBadge = document.getElementById('pendingReturnCountBadge'); if (borrowCountBadge) { borrowCountBadge.textContent = pendingBorrowRequests.length > 99 ? '99+' : String(pendingBorrowRequests.length); } if (returnCountBadge) { returnCountBadge.textContent = pendingReturnRequests.length > 99 ? '99+' : String(pendingReturnRequests.length); } borrowList.innerHTML = pendingBorrowRequests.length ? pendingBorrowRequests.map(item => this.buildPendingAssetRequestCardHtml(item)).join('') : `
Không có đơn mượn nào đang chờ.
`; returnList.innerHTML = pendingReturnRequests.length ? pendingReturnRequests.map(item => this.buildPendingAssetRequestCardHtml(item)).join('') : `
Không có đơn trả nào đang chờ.
`; this.bindPendingAssetRequestActionButtons(); } async openPendingAssetRequestsModal() { if (!this.canCurrentUserManageAssets()) { this.notifyWarning('Chỉ role Asset/Admin mới được xử lý đơn chờ.'); return; } const modal = document.getElementById('assetPendingRequestsModal'); if (!modal) { this.notifyFailure('Không tìm thấy hộp thoại đơn chờ.'); return; } await this.fetchAssetBorrows(); this.renderPendingAssetRequestsModal(); modal.style.zIndex = '120'; modal.classList.add('open'); } openAssetRequestRejectModal(requestId) { if (!this.canCurrentUserManageAssets()) { return; } const rejectModal = document.getElementById('assetRequestRejectModal'); const idInput = document.getElementById('assetRequestRejectIdInput'); const reasonInput = document.getElementById('assetRequestRejectReasonInput'); if (!rejectModal || !idInput || !reasonInput) { this.notifyFailure('Không tìm thấy hộp thoại từ chối đơn.'); return; } this.pendingAssetRequestRejectId = Number(requestId); idInput.value = String(requestId); reasonInput.value = ''; rejectModal.style.zIndex = '130'; rejectModal.classList.add('open'); reasonInput.focus(); } async handleAssetRequestRejectSubmit(event) { event.preventDefault(); const idInput = document.getElementById('assetRequestRejectIdInput'); const reasonInput = document.getElementById('assetRequestRejectReasonInput'); const requestId = Number(idInput?.value || this.pendingAssetRequestRejectId); const reason = String(reasonInput?.value || '').trim(); if (!Number.isFinite(requestId) || requestId <= 0) { this.notifyWarning('Không xác định được đơn cần từ chối.'); return; } if (!reason) { this.notifyWarning('Vui lòng nhập lý do từ chối.'); return; } await this.processAssetBorrowRequest(requestId, 'rejected', reason); } async processAssetBorrowRequest(requestId, action, rejectReason = '') { if (!this.canCurrentUserManageAssets()) { this.notifyWarning('Chỉ role Asset/Admin mới được xử lý đơn chờ.'); return; } try { const response = await fetch(`${this.apiBase}/asset-borrows/${requestId}/process`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ action, rejectReason }) }); const data = await response.json(); if (!response.ok || !data.success) { const failureMessage = data?.message || 'Xử lý đơn thất bại'; this.notifyFailure(failureMessage); const canAutoSuggestDelete = action === 'approved' && typeof failureMessage === 'string' && failureMessage.toLowerCase().includes('xóa đơn chờ'); if (canAutoSuggestDelete) { const shouldDelete = window.confirm(`Đơn #${requestId} không còn hợp lệ. Bạn có muốn xóa đơn chờ này không?`); if (shouldDelete) { await this.deletePendingAssetBorrowRequest(requestId); } } return; } if (action === 'rejected') { this.pendingAssetRequestRejectId = undefined; closeAssetRequestRejectModal(); } this.notifySuccess(action === 'approved' ? 'Đã chấp nhận đơn' : 'Đã từ chối đơn'); await this.fetchAssetBorrows(); await this.fetchAssets(); if (this.currentPage === 'asset-borrows') { this.renderAssetBorrowsTableBody(); } if (this.currentPage === 'assets') { this.renderAssetsTableBody(); } const pendingModal = document.getElementById('assetPendingRequestsModal'); if (pendingModal?.classList.contains('open')) { this.renderPendingAssetRequestsModal(); } this.updatePendingAssetRequestsBadge(); } catch (err) { console.error(err); this.notifyFailure('Xử lý đơn thất bại'); } } async deletePendingAssetBorrowRequest(requestId) { if (!this.canCurrentUserManageAssets()) { this.notifyWarning('Chỉ role Asset/Admin mới được xóa đơn chờ.'); return; } const targetId = Number(requestId); if (!Number.isFinite(targetId) || targetId <= 0) { this.notifyWarning('Không xác định được đơn cần xóa.'); return; } const confirmed = window.confirm(`Bạn có chắc muốn xóa đơn chờ #${targetId}?`); if (!confirmed) { return; } try { const response = await fetch(`${this.apiBase}/asset-borrows/${targetId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xóa đơn chờ thất bại'); return; } this.notifySuccess(data.message || 'Đã xóa đơn chờ'); await this.fetchAssetBorrows(); if (this.currentPage === 'asset-borrows') { this.renderAssetBorrowsTableBody(); } const pendingModal = document.getElementById('assetPendingRequestsModal'); if (pendingModal?.classList.contains('open')) { this.renderPendingAssetRequestsModal(); } this.updatePendingAssetRequestsBadge(); } catch (err) { console.error(err); this.notifyFailure('Xóa đơn chờ thất bại'); } } getAssetsContent() { this.syncSelectedAssetIds(); const canManageAssets = this.canCurrentUserManageAssets(); const filteredAssets = this.getFilteredAssets(); const pageInfo = this.getPaged(filteredAssets, this.assetPage, this.assetPageSize); const selectedCount = canManageAssets ? this.selectedAssetIds.size : 0; const pageAssetIds = pageInfo.data .map(asset => Number(asset.AssetId)) .filter(id => Number.isFinite(id)); const selectedOnPageCount = pageAssetIds.filter(id => this.selectedAssetIds.has(id)).length; const allOnPageSelected = canManageAssets && pageAssetIds.length > 0 && selectedOnPageCount === pageAssetIds.length; this.assetPage = pageInfo.current; return `
Trạng thái
Tìm kiếm
${pageInfo.data.length > 0 ? `
${pageInfo.data.map((asset, index) => { const statusMeta = this.getAssetStatusMeta(asset.Status); const assetId = Number(asset.AssetId); const isSelected = Number.isFinite(assetId) && this.selectedAssetIds.has(assetId); const rowNumber = pageInfo.start + index; return ` `; }).join('')}
STT Tên tài sản Model Serial Số lượng (Tồn đầu kỳ) Nhập trong kỳ Xuất trong kỳ Tồn cuối kỳ Đơn vị Phòng ban Dự án Người phụ trách Trạng thái Vị trí Ngày mua Người mượn Ghi chú Ngày tạo Người xuất Thao tác
${rowNumber} ${asset.AssetCode || '-'} ${asset.AssetName || '-'} ${asset.Model || '-'} ${asset.SerialNumber || '-'} ${asset.Quantity || 0} ${asset.ImportInPeriod ?? 0} ${asset.ExportInPeriod ?? 0} ${asset.EndingBalance ?? 0} ${asset.Unit || '-'} ${asset.Department || '-'} ${asset.Project || '-'} ${asset.Custodian || '-'} ${statusMeta.label} ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} ${this.formatBorrowerTableHtml(asset.Borrower)} ${asset.Notes || '-'} ${this.formatDateOnly(asset.CreatedDate)} ${asset.ExportedBy || '-'}
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
` : `

Chưa có dữ liệu tài sản. Hãy thêm tài sản đầu tiên.

`}
`; } renderAssetsTableBody() { const tbody = document.querySelector('.assets-table-body'); if (!tbody) return; this.syncSelectedAssetIds(); const canManageAssets = this.canCurrentUserManageAssets(); const pageInfo = this.getPaged(this.getFilteredAssets(), this.assetPage, this.assetPageSize); this.assetPage = pageInfo.current; tbody.innerHTML = pageInfo.data.map((asset, index) => { const statusMeta = this.getAssetStatusMeta(asset.Status); const assetId = Number(asset.AssetId); const isSelected = Number.isFinite(assetId) && this.selectedAssetIds.has(assetId); const rowNumber = pageInfo.start + index; return ` ${rowNumber} ${asset.AssetCode || '-'} ${asset.AssetName || '-'} ${asset.Model || '-'} ${asset.SerialNumber || '-'} ${asset.Quantity || 0} ${asset.ImportInPeriod ?? 0} ${asset.ExportInPeriod ?? 0} ${asset.EndingBalance ?? 0} ${asset.Unit || '-'} ${asset.Department || '-'} ${asset.Project || '-'} ${asset.Custodian || '-'} ${statusMeta.label} ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} ${this.formatBorrowerTableHtml(asset.Borrower)} ${asset.Notes || '-'} ${this.formatDateOnly(asset.CreatedDate)} ${asset.ExportedBy || '-'}
`; }).join(''); const pager = document.getElementById('assetsPager'); if (pager) { pager.innerHTML = ` Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } this.setupAssetRowListeners(); this.setupAssetPagerListeners(); } setupAssetSelectionListeners() { if (!this.canCurrentUserManageAssets()) { this.selectedAssetIds.clear(); document.querySelectorAll('.asset-row-checkbox').forEach(checkbox => { checkbox.checked = false; checkbox.disabled = true; }); const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox'); if (selectAllCheckbox) { selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = false; selectAllCheckbox.disabled = true; } this.updateAssetBulkActionState(); return; } document.querySelectorAll('.asset-row-checkbox').forEach(checkbox => { checkbox.addEventListener('change', () => { const assetId = Number(checkbox.dataset.assetId); if (!Number.isFinite(assetId)) { return; } if (checkbox.checked) { this.selectedAssetIds.add(assetId); } else { this.selectedAssetIds.delete(assetId); } this.updateAssetBulkActionState(); }); }); const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox'); if (selectAllCheckbox && !selectAllCheckbox.dataset.boundChange) { selectAllCheckbox.addEventListener('change', () => { const shouldSelect = selectAllCheckbox.checked; document.querySelectorAll('.asset-row-checkbox').forEach(checkbox => { checkbox.checked = shouldSelect; const assetId = Number(checkbox.dataset.assetId); if (!Number.isFinite(assetId)) { return; } if (shouldSelect) { this.selectedAssetIds.add(assetId); } else { this.selectedAssetIds.delete(assetId); } }); this.updateAssetBulkActionState(); }); selectAllCheckbox.dataset.boundChange = 'true'; } const bulkDeleteBtn = document.getElementById('bulkDeleteAssetsBtn'); if (bulkDeleteBtn && !bulkDeleteBtn.dataset.boundClick) { bulkDeleteBtn.addEventListener('click', async () => { await this.handleBulkDeleteAssets(); }); bulkDeleteBtn.dataset.boundClick = 'true'; } this.updateAssetBulkActionState(); } updateAssetBulkActionState() { const canManageAssets = this.canCurrentUserManageAssets(); const rowCheckboxes = Array.from(document.querySelectorAll('.asset-row-checkbox')); const selectedOnPage = rowCheckboxes.filter(checkbox => checkbox.checked).length; if (!canManageAssets) { this.selectedAssetIds.clear(); } rowCheckboxes.forEach(checkbox => { checkbox.disabled = !canManageAssets; }); const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox'); if (selectAllCheckbox) { const hasRows = rowCheckboxes.length > 0; selectAllCheckbox.checked = canManageAssets && hasRows && selectedOnPage === rowCheckboxes.length; selectAllCheckbox.indeterminate = canManageAssets && selectedOnPage > 0 && selectedOnPage < rowCheckboxes.length; selectAllCheckbox.disabled = !canManageAssets; } const selectedCount = canManageAssets ? this.selectedAssetIds.size : 0; const selectedCountNode = document.getElementById('selectedAssetCount'); if (selectedCountNode) { selectedCountNode.textContent = String(selectedCount); } const bulkDeleteBtn = document.getElementById('bulkDeleteAssetsBtn'); if (bulkDeleteBtn) { const disabled = !canManageAssets || selectedCount === 0; bulkDeleteBtn.disabled = disabled; bulkDeleteBtn.classList.toggle('opacity-50', disabled); bulkDeleteBtn.classList.toggle('cursor-not-allowed', disabled); } const borrowAssetBtn = document.getElementById('borrowAssetBtn'); if (borrowAssetBtn) { const disabled = !canManageAssets || selectedCount !== 1; borrowAssetBtn.disabled = disabled; borrowAssetBtn.classList.toggle('opacity-50', disabled); borrowAssetBtn.classList.toggle('cursor-not-allowed', disabled); } } async handleBulkDeleteAssets() { if (!this.ensureAssetManagePermission('xoa tai san')) { return; } const selectedIds = [...this.selectedAssetIds]; if (!selectedIds.length) { this.notifyWarning('Vui lòng chọn ít nhất 1 tài sản để xóa'); return; } const confirmed = window.confirm(`Bạn có chắc mudn xóa ${selectedIds.length} tài sản dã chọn?`); if (!confirmed) { return; } let successCount = 0; let failedCount = 0; for (const assetId of selectedIds) { try { const response = await fetch(`${this.apiBase}/assets/${assetId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { failedCount += 1; continue; } this.selectedAssetIds.delete(assetId); successCount += 1; } catch (err) { console.error(err); failedCount += 1; } } if (successCount > 0 && failedCount === 0) { this.notifySuccess(`Đã xóa ${successCount} tài sản`); } else if (successCount > 0) { this.notifyWarning(`Đã xóa ${successCount}/${selectedIds.length} tài sản. ${failedCount} dòng xóa thất bại`); } else { this.notifyFailure('Xóa tài sản thất bại'); } await this.refreshAssetsUI(); } setupAssetPagerListeners() { document.querySelectorAll('.asset-page-btn').forEach(btn => { btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) return; this.assetPage = targetPage; this.renderAssetsTableBody(); }); }); } renderAssetDetails(asset) { const detailsContainer = document.getElementById('assetDetailsContent'); if (!detailsContainer) { return; } const borrowerSummary = this.formatBorrowerSummaryText(asset?.Borrower); const fields = [ ['Mã tài sản', asset?.AssetCode], ['Tên tài sản', asset?.AssetName], ['Model', asset?.Model], ['Sd serial', asset?.SerialNumber], ['Số lượng (Tồn đầu kỳ)', `${asset?.Quantity || 0} ${asset?.Unit || ''}`.trim()], ['Nhập trong kỳ', asset?.ImportInPeriod ?? 0], ['Xuất trong kỳ', asset?.ExportInPeriod ?? 0], ['Tồn cuối kỳ', asset?.EndingBalance ?? 0], ['Phòng ban', asset?.Department], ['Dự án', asset?.Project], ['Vị trí', asset?.Location], ['Người phụ trách', asset?.Custodian], ['Ngày mua', this.formatDateOnly(asset?.PurchaseDate)], ['Người xuất', asset?.ExportedBy], ['Trạng thái', this.getAssetStatusMeta(asset?.Status).label], ['Ghi chú', asset?.Notes] ]; detailsContainer.innerHTML = `
${borrowerSummary || '-'}
${fields.map(([label, value]) => `
${value || '-'}
`).join('')} `; } populateAssetForm(asset) { const sourceAsset = asset || {}; const borrowerEntries = this.parseBorrowerEntries(sourceAsset?.Borrower); this.editingAssetBorrowerEntries = borrowerEntries; this.clearAssetFormValidation(); document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || ''; document.getElementById('assetNameInput').value = sourceAsset?.AssetName || ''; document.getElementById('assetStatusInput').value = String(sourceAsset?.Status || 'in_use').toLowerCase(); document.getElementById('assetModelInput').value = sourceAsset?.Model || ''; document.getElementById('assetSerialInput').value = sourceAsset?.SerialNumber || ''; document.getElementById('assetQuantityInput').value = this.parseNonNegativeInteger(sourceAsset?.Quantity, 0); document.getElementById('assetImportInPeriodInput').value = this.parseNonNegativeInteger(sourceAsset?.ImportInPeriod, 0); document.getElementById('assetUnitInput').value = sourceAsset?.Unit || ''; this.refreshAssetDepartmentOptions(sourceAsset?.Department || ''); document.getElementById('assetProjectInput').value = sourceAsset?.Project || ''; document.getElementById('assetLocationInput').value = sourceAsset?.Location || ''; this.refreshAssetCustodianOptions(sourceAsset?.Custodian || ''); const borrowerSummaryInput = document.getElementById('assetBorrowerSummaryInput'); if (borrowerSummaryInput) { borrowerSummaryInput.value = this.formatBorrowerSummaryText(sourceAsset?.Borrower) || '(Chua co nguoi muon)'; borrowerSummaryInput.readOnly = true; } const exportInput = document.getElementById('assetExportInPeriodInput'); const endingInput = document.getElementById('assetEndingBalanceInput'); if (exportInput) exportInput.readOnly = true; if (endingInput) endingInput.readOnly = true; document.getElementById('assetPurchaseDateInput').value = this.toDateInputValue(sourceAsset?.PurchaseDate); document.getElementById('assetPriceInput').value = sourceAsset?.PurchasePrice || ''; document.getElementById('assetNotesInput').value = sourceAsset?.Notes || ''; this.recalculateAssetStockFields(); } openAssetModal() { if (!this.ensureAssetManagePermission('them hoac sua tai san')) { return; } if (this.editingAssetId === undefined) { this.populateAssetForm(null); } this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); if (!this.users.length) { this.fetchUsers(); } this.setAssetCodeFieldMode(this.editingAssetId !== undefined); this.clearAssetFormValidation(); document.getElementById('assetModal').classList.add('open'); } setupAssetFormValidationListeners() { const bindInput = (inputId, errorId) => { const input = document.getElementById(inputId); if (!input || input.dataset.boundValidation === 'true') { return; } input.addEventListener('input', () => this.clearAssetFieldValidation(inputId, errorId)); input.addEventListener('change', () => this.clearAssetFieldValidation(inputId, errorId)); input.dataset.boundValidation = 'true'; }; bindInput('assetCodeInput', 'assetCodeError'); bindInput('assetNameInput', 'assetNameError'); } clearAssetFieldValidation(inputId, errorId) { const input = document.getElementById(inputId); const errorNode = document.getElementById(errorId); if (input) { input.classList.remove('border-error/30', 'ring-2', 'ring-error/20'); input.removeAttribute('aria-invalid'); } if (errorNode) { errorNode.textContent = ''; errorNode.classList.add('hidden'); } } clearAssetFormValidation() { this.clearAssetFieldValidation('assetCodeInput', 'assetCodeError'); this.clearAssetFieldValidation('assetNameInput', 'assetNameError'); } setAssetFieldValidationError(inputId, errorId, message) { const input = document.getElementById(inputId); const errorNode = document.getElementById(errorId); if (input) { input.classList.add('border-error/30', 'ring-2', 'ring-error/20'); input.setAttribute('aria-invalid', 'true'); } if (errorNode) { errorNode.textContent = message || ''; errorNode.classList.remove('hidden'); } } setAssetCodeFieldMode(isEdit) { const codeInput = document.getElementById('assetCodeInput'); const codeLabel = document.getElementById('assetCodeLabel'); const codeHint = document.getElementById('assetCodeHint'); if (codeInput) { codeInput.required = !!isEdit; codeInput.placeholder = isEdit ? 'Bắt buộc khi cập nhật' : 'Để trống để hệ thống tự tạo'; } if (codeLabel) { codeLabel.innerHTML = isEdit ? 'Mã tài sản *' : 'Mã tài sản'; } if (codeHint) { codeHint.textContent = isEdit ? 'Khi cập nhật, mã tài sản là bắt buộc.' : 'Để trống khi thêm mới, hệ thống sẽ tự tạo mã.'; } } generateManualAssetCodeForCreate(payload = {}) { const toToken = (value) => String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[\u0111\u0110]/g, 'd') .toUpperCase() .replace(/[^A-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 32); const base = toToken(payload.model) || toToken(payload.serialNumber) || toToken(payload.assetName) || 'ASSET'; const now = new Date(); const timestamp = [ String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0'), String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0'), String(now.getSeconds()).padStart(2, '0'), String(now.getMilliseconds()).padStart(3, '0') ].join(''); const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0'); return `AST-${base}-${timestamp}${randomSuffix}`; } collectAssetFormPayload() { const quantity = this.parseNonNegativeInteger(document.getElementById('assetQuantityInput')?.value ?? 0, 0); const importInPeriod = this.parseNonNegativeInteger(document.getElementById('assetImportInPeriodInput')?.value ?? 0, 0); const borrowerEntries = Array.isArray(this.editingAssetBorrowerEntries) ? this.editingAssetBorrowerEntries : []; const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; const metrics = this.buildAssetQuantityMetrics( { Quantity: quantity, ImportInPeriod: importInPeriod, Borrower: borrower }, borrowerEntries ); const purchasePrice = String(document.getElementById('assetPriceInput')?.value ?? '').trim(); return { assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '', assetName: document.getElementById('assetNameInput')?.value?.trim() || '', status: document.getElementById('assetStatusInput')?.value || 'in_use', model: document.getElementById('assetModelInput')?.value?.trim() || '', serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '', quantity, importInPeriod, exportInPeriod: metrics.exportInPeriod, endingBalance: metrics.endingBalance, unit: document.getElementById('assetUnitInput')?.value?.trim() || '', department: document.getElementById('assetDepartmentInput')?.value?.trim() || '', project: document.getElementById('assetProjectInput')?.value?.trim() || '', location: document.getElementById('assetLocationInput')?.value?.trim() || '', custodian: document.getElementById('assetCustodianInput')?.value?.trim() || '', borrower, purchaseDate: document.getElementById('assetPurchaseDateInput')?.value || null, purchasePrice: purchasePrice || null, notes: document.getElementById('assetNotesInput')?.value?.trim() || '' }; } buildAssetPayloadFromAsset(asset, borrowerEntriesOverride = null) { if (!asset) { return null; } const borrowerEntries = Array.isArray(borrowerEntriesOverride) ? borrowerEntriesOverride : this.parseBorrowerEntries(asset?.Borrower); const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; const metrics = this.buildAssetQuantityMetrics( { Quantity: asset?.Quantity, ImportInPeriod: asset?.ImportInPeriod, Borrower: borrower }, borrowerEntries ); const rawPrice = asset?.PurchasePrice; const normalizedPrice = rawPrice === undefined || rawPrice === null || String(rawPrice).trim() === '' ? null : String(rawPrice).trim(); return { assetCode: String(asset?.AssetCode || '').trim(), assetName: String(asset?.AssetName || '').trim(), status: String(asset?.Status || 'in_use'), model: String(asset?.Model || '').trim(), serialNumber: String(asset?.SerialNumber || '').trim(), quantity: metrics.quantity, importInPeriod: metrics.importInPeriod, exportInPeriod: metrics.exportInPeriod, endingBalance: metrics.endingBalance, unit: String(asset?.Unit || '').trim(), department: String(asset?.Department || '').trim(), project: String(asset?.Project || '').trim(), location: String(asset?.Location || '').trim(), custodian: String(asset?.Custodian || '').trim(), borrower, purchaseDate: this.toDateInputValue(asset?.PurchaseDate) || null, purchasePrice: normalizedPrice, notes: String(asset?.Notes || '').trim() }; } getSingleSelectedAssetForBorrowing(showWarning = true) { const selectedIds = [...this.selectedAssetIds]; if (!selectedIds.length) { if (showWarning) { this.notifyWarning('Vui lòng chọn 1 tài sản để mượn.'); } return null; } if (selectedIds.length > 1) { if (showWarning) { this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần mượn.'); } return null; } const assetId = Number(selectedIds[0]); const asset = this.assets.find(item => Number(item?.AssetId) === assetId) || null; if (!asset && showWarning) { this.notifyFailure('Không tìm thấy tài sản dã chọn.'); } return asset; } async openBorrowAssetModal() { if (!this.ensureAssetManagePermission('muon tai san')) { return; } const asset = this.getSingleSelectedAssetForBorrowing(true); if (!asset) { return; } if (!this.users.length) { await this.fetchUsers(); } const metrics = this.buildAssetQuantityMetrics(asset); if (metrics.endingBalance <= 0) { this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể mượn thêm.'); return; } this.pendingBorrowAssetId = Number(asset.AssetId); const assetIdInput = document.getElementById('borrowAssetIdInput'); const assetNameInput = document.getElementById('borrowAssetNameInput'); const endingInput = document.getElementById('borrowCurrentEndingInput'); const quantityInput = document.getElementById('borrowQuantityInput'); const borrowByInput = document.getElementById('borrowByInput'); const borrowRoleInput = document.getElementById('borrowRoleInput'); const modal = document.getElementById('borrowAssetModal'); if (!modal || !assetNameInput || !endingInput || !quantityInput) { this.notifyFailure('Không tìm thấy biểu mẫu mượn tài sản.'); return; } if (assetIdInput) { assetIdInput.value = String(asset.AssetId || ''); } assetNameInput.value = `${asset.AssetCode || ''} - ${asset.AssetName || ''}`.trim(); endingInput.value = String(metrics.endingBalance); quantityInput.value = '1'; quantityInput.min = '1'; quantityInput.max = String(metrics.endingBalance); this.refreshBorrowAssetUserOptions(''); if (borrowByInput) { borrowByInput.value = this.getCurrentUserDisplayName(); } if (borrowRoleInput) { borrowRoleInput.value = String(this.getCurrentUserRoleRaw() || '').trim() || '-'; } modal.classList.add('open'); } async handleBorrowAssetSubmit(e) { e.preventDefault(); if (!this.ensureAssetManagePermission('muon tai san')) { return; } const assetIdInput = document.getElementById('borrowAssetIdInput'); const borrowerInput = document.getElementById('borrowAssetUserInput'); const quantityInput = document.getElementById('borrowQuantityInput'); const selectedAssetId = Number(assetIdInput?.value || this.pendingBorrowAssetId); if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) { this.notifyFailure('Không xác định được tài sản cần mượn.'); return; } const asset = this.assets.find(item => Number(item?.AssetId) === selectedAssetId); if (!asset) { this.notifyFailure('Không tìm thấy tài sản cần mượn.'); return; } const borrowerName = String(borrowerInput?.value || '').trim(); if (!borrowerName) { this.notifyWarning('Vui lòng chọn người mượn.'); return; } const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); if (borrowQuantity <= 0) { this.notifyWarning('Số lượng mượn phải lớn hơn 0.'); return; } const currentMetrics = this.buildAssetQuantityMetrics(asset); if (currentMetrics.endingBalance <= 0) { this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể mượn thêm.'); return; } if (borrowQuantity > currentMetrics.endingBalance) { this.notifyWarning(`Số lượng mượn (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`); return; } const updatedEntries = this.mergeBorrowerEntries(currentMetrics.borrowerEntries, borrowerName, borrowQuantity); const payload = this.buildAssetPayloadFromAsset(asset, updatedEntries); if (!payload) { this.notifyFailure('Không tạo được dữ liệu mượn tài sản.'); return; } try { const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}`, { method: 'PUT', headers: this.getAuthHeaders(true), body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Mượn tài sản thất bại'); return; } this.pendingBorrowAssetId = undefined; this.notifySuccess('Mượn tài sản thành công'); this.closeModals(); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Mượn tài sản thất bại'); } } async handleAssetSubmit(e) { e.preventDefault(); if (!this.ensureAssetManagePermission('them hoac sua tai san')) { return; } const isEdit = this.editingAssetId !== undefined; const payload = this.collectAssetFormPayload(); this.clearAssetFormValidation(); if (!payload.assetName) { this.setAssetFieldValidationError('assetNameInput', 'assetNameError', 'Vui lòng nhập tên tài sản.'); this.notifyWarning('Vui lòng nhập đầy đủ các trường bắt buộc.'); document.getElementById('assetNameInput')?.focus(); return; } if (isEdit && !payload.assetCode) { this.setAssetFieldValidationError('assetCodeInput', 'assetCodeError', 'Mã tài sản là bắt buộc khi cập nhật.'); this.notifyWarning('Vui lòng nhập đầy đủ các trường bắt buộc.'); document.getElementById('assetCodeInput')?.focus(); return; } if (!isEdit && !payload.assetCode) { payload.assetCode = this.generateManualAssetCodeForCreate(payload); const codeInput = document.getElementById('assetCodeInput'); if (codeInput) { codeInput.value = payload.assetCode; } } const url = isEdit ? `${this.apiBase}/assets/${this.editingAssetId}` : `${this.apiBase}/assets`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(url, { method, headers: this.getAuthHeaders(true), body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Lưu tài sản thất bại'); return; } this.editingAssetId = undefined; this.notifySuccess(isEdit ? 'Cập nhật tài sản thành công' : 'Thêm tài sản thành công'); this.closeModals(); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Lưu tài sản thất bại'); } } async refreshAssetsUI() { await this.fetchAssets(); await this.fetchAssetDepartments(); if (this.currentPage === 'assets') { this.renderView('assets'); } } setupAssetRowListeners() { this.setupAssetSelectionListeners(); const canManageAssets = this.canCurrentUserManageAssets(); document.querySelectorAll('.view-asset').forEach(btn => { btn.addEventListener('click', () => { const assetId = Number(btn.dataset.assetId); const asset = this.assets.find(a => a.AssetId === assetId); this.currentViewAsset = asset; this.currentViewAssetId = assetId; this.renderAssetDetails(asset); document.getElementById('viewAssetModal').classList.add('open'); }); }); document.querySelectorAll('.edit-asset').forEach(btn => { btn.addEventListener('click', () => { if (!this.ensureAssetManagePermission('sua tai san')) { return; } const assetId = Number(btn.dataset.assetId); const asset = this.assets.find(a => a.AssetId === assetId); this.editingAssetId = asset?.AssetId; this.populateAssetForm(asset); this.closeModals(); this.openAssetModal(); }); }); document.querySelectorAll('.delete-asset').forEach(btn => { btn.addEventListener('click', () => { if (!this.ensureAssetManagePermission('xoa tai san')) { return; } const assetId = Number(btn.dataset.assetId); const asset = this.assets.find(a => a.AssetId === assetId); this.pendingDeleteAssetId = assetId; document.getElementById('deleteAssetName').textContent = asset?.AssetName || asset?.AssetCode || '-'; document.getElementById('deleteAssetModal').classList.add('open'); }); }); document.querySelectorAll('.confirm-delete-asset').forEach(btn => { if (btn.dataset.boundClick) { return; } btn.addEventListener('click', async () => { if (!this.ensureAssetManagePermission('xoa tai san')) { return; } if (this.pendingDeleteAssetId === undefined) { return; } const targetDeleteId = Number(this.pendingDeleteAssetId); try { const response = await fetch(`${this.apiBase}/assets/${this.pendingDeleteAssetId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xóa tài sản thất bại'); return; } this.pendingDeleteAssetId = undefined; this.selectedAssetIds.delete(targetDeleteId); this.closeModals(); this.notifySuccess('Xóa tài sản thành công'); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Xóa tài sản thất bại'); } }); btn.dataset.boundClick = 'true'; }); const editFromViewBtn = document.querySelector('.edit-asset-from-view'); if (editFromViewBtn && !editFromViewBtn.dataset.boundClick) { editFromViewBtn.disabled = !canManageAssets; editFromViewBtn.classList.toggle('opacity-50', !canManageAssets); editFromViewBtn.classList.toggle('cursor-not-allowed', !canManageAssets); editFromViewBtn.addEventListener('click', () => { if (!this.ensureAssetManagePermission('sua tai san')) { return; } const asset = this.currentViewAsset; this.editingAssetId = asset?.AssetId; this.populateAssetForm(asset); this.closeModals(); this.openAssetModal(); }); editFromViewBtn.dataset.boundClick = 'true'; } } normalizeImportHeader(key) { return String(key || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[\u0111\u0110]/g, 'd') .toLowerCase() .replace(/[^a-z0-9]/g, ''); } isImportHeaderMatch(actualHeader, alias) { const normalizedHeader = this.normalizeImportHeader(actualHeader); const normalizedAlias = this.normalizeImportHeader(alias); if (!normalizedHeader || !normalizedAlias) { return false; } if (normalizedHeader === normalizedAlias) { return true; } // Avoid over-matching very short aliases such as "PN". if (normalizedAlias.length < 4 || normalizedHeader.length < 4) { return false; } return normalizedHeader.includes(normalizedAlias) || normalizedAlias.includes(normalizedHeader); } findImportValue(row, aliases) { for (const [key, value] of Object.entries(row || {})) { if (aliases.some(alias => this.isImportHeaderMatch(key, alias))) { return value; } } return ''; } isHeaderLikeImportValue(value) { const normalized = this.normalizeImportHeader(value); if (!normalized) { return false; } const knownHeaderTokens = new Set([ 'stt', 'ngayve', 'mavattu', 'mavt', 'mataisan', 'mats', 'matscd', 'tenlinhkiensp', 'tentaisan', 'tentaisanccdc', 'model', 'dvt', 'donvi', 'tondauky', 'tondauki', 'nhaptrongky', 'nhaptrongki', 'xuattrongky', 'xuattrongki', 'toncuoiky', 'toncuoiki', 'lidoxuat', 'lydoxuat', 'tinhtrang', 'vitri', 'duan', 'assetcode', 'assetname', 'quantity', 'importinperiod', 'exportinperiod', 'endingbalance', 'unit', 'location', 'department', 'project', 'status', 'notes' ]); return knownHeaderTokens.has(normalized); } isLikelyHeaderArtifactAssetRow(mappedRow) { const row = mappedRow || {}; const fields = [ row.assetCode, row.assetName, row.model, row.unit, row.status, row.location, row.department, row.project, row.importInPeriod, row.exportInPeriod, row.endingBalance, row.notes ]; const headerLikeCount = fields.reduce((count, value) => { return count + (this.isHeaderLikeImportValue(value) ? 1 : 0); }, 0); if (headerLikeCount >= 2) { return true; } return this.isHeaderLikeImportValue(row.assetName) && this.isHeaderLikeImportValue(row.model); } hasImportAliasInRow(row, aliases) { const normalizedRow = (Array.isArray(row) ? row : []) .map(cell => this.normalizeImportHeader(cell)) .filter(Boolean); if (!normalizedRow.length) { return false; } return aliases.some(alias => { const normalizedAlias = this.normalizeImportHeader(alias); return normalizedRow.some(headerValue => this.isImportHeaderMatch(headerValue, normalizedAlias)); }); } isLikelyAssetHeaderRow(row) { const codeAliases = ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC']; const nameAliases = ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC']; const modelAliases = ['Model', 'Dong may']; const quantityAliases = ['Ton dau ky', 'Ton dau ki', 'Quantity', 'So luong', 'SL', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki']; const unitAliases = ['Unit', 'Don vi', 'DVT']; const sttAliases = ['STT', 'So thu tu']; const hasCode = this.hasImportAliasInRow(row, codeAliases); const hasName = this.hasImportAliasInRow(row, nameAliases); const hasModel = this.hasImportAliasInRow(row, modelAliases); const hasQty = this.hasImportAliasInRow(row, quantityAliases); const hasUnit = this.hasImportAliasInRow(row, unitAliases); const hasStt = this.hasImportAliasInRow(row, sttAliases); if (hasStt && hasName && (hasModel || hasQty || hasUnit || hasCode)) { return true; } if (hasCode && hasName) { return true; } return hasName && (hasModel || hasQty || hasUnit); } findAssetImportHeaderRowIndex(matrixRows) { const codeAliases = ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC']; const nameAliases = ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC']; const modelAliases = ['Model', 'Dong may']; const quantityAliases = ['Ton dau ky', 'Ton dau ki', 'Quantity', 'So luong', 'SL', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki']; const unitAliases = ['Unit', 'Don vi', 'DVT']; const sttAliases = ['STT', 'So thu tu']; const maxScanRows = Math.min(Array.isArray(matrixRows) ? matrixRows.length : 0, 50); let bestIndex = -1; let bestScore = 0; for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { const row = Array.isArray(matrixRows[rowIndex]) ? matrixRows[rowIndex] : []; const normalizedRow = row .map(cell => this.normalizeImportHeader(cell)) .filter(Boolean); if (!normalizedRow.length) { continue; } const normalizedSet = new Set(normalizedRow); const hasAnyAlias = aliasList => aliasList.some(alias => { const normalizedAlias = this.normalizeImportHeader(alias); for (const headerValue of normalizedSet) { if (this.isImportHeaderMatch(headerValue, normalizedAlias)) { return true; } } return false; }); const hasCode = hasAnyAlias(codeAliases); const hasName = hasAnyAlias(nameAliases); const hasModel = hasAnyAlias(modelAliases); const hasQty = hasAnyAlias(quantityAliases); const hasUnit = hasAnyAlias(unitAliases); const hasStt = hasAnyAlias(sttAliases); if (hasStt && hasName && (hasModel || hasQty || hasUnit || hasCode)) { return rowIndex; } if (hasCode && hasName) { return rowIndex; } let score = 0; if (hasName) score += 4; if (hasCode) score += 3; if (hasStt) score += 3; if (hasModel) score += 2; if (hasQty) score += 1; if (hasUnit) score += 1; if (score > bestScore) { bestScore = score; bestIndex = rowIndex; } } // Fallback for inventory templates that omit one canonical column name. if (bestScore >= 4) { return bestIndex; } return -1; } mapImportedAssetRowsFromMatrix(matrixRows, headerRowIndex) { const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : []; if (!headerRow.length) { return []; } const sttAliases = ['STT', 'So thu tu']; return matrixRows .slice(headerRowIndex + 1) .filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '')) .map((row, rowOffset) => { const rowObject = {}; headerRow.forEach((header, index) => { const headerText = String(header ?? '').trim(); if (!headerText) { return; } rowObject[headerText] = row[index] ?? ''; }); const sttValue = String(this.findImportValue(rowObject, sttAliases)).trim(); if (sttValue && Number.isNaN(Number(sttValue))) { return null; } if (sttValue === '' && this.findImportValue(rowObject, sttAliases) !== '') { return null; } return this.mapImportedAssetRow(rowObject, headerRowIndex + rowOffset + 2); }) .filter(Boolean) .filter(row => !this.isLikelyHeaderArtifactAssetRow(row)) .filter(row => row.assetCode && row.assetName); } inferImportColumnIndex(headerRow, aliases) { const headers = Array.isArray(headerRow) ? headerRow : []; for (let index = 0; index < headers.length; index += 1) { if (aliases.some(alias => this.isImportHeaderMatch(headers[index], alias))) { return index; } } return -1; } parseImportNumericValue(value, fallback = 1) { if (value === undefined || value === null || value === '') { return fallback; } const normalized = String(value).trim().replace(/,/g, ''); if (!normalized) { return fallback; } const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : fallback; } buildAssetImportIndexMap(headerRow) { const indexMap = { stt: this.inferImportColumnIndex(headerRow, ['STT', 'So thu tu']), assetCode: this.inferImportColumnIndex(headerRow, ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC']), assetName: this.inferImportColumnIndex(headerRow, ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC']), model: this.inferImportColumnIndex(headerRow, ['Model', 'Dong may']), serialNumber: this.inferImportColumnIndex(headerRow, ['Serial Number', 'Serial', 'So serial', 'So seri']), quantity: this.inferImportColumnIndex(headerRow, ['Ton dau ky', 'Ton dau ki', 'Opening Balance', 'Quantity', 'So luong', 'SL']), importInPeriod: this.inferImportColumnIndex(headerRow, ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap']), exportInPeriod: this.inferImportColumnIndex(headerRow, ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat']), endingBalance: this.inferImportColumnIndex(headerRow, ['Ton cuoi ki', 'Ton cuoi ky', 'Ton cuoi', 'Ending Balance']), unit: this.inferImportColumnIndex(headerRow, ['Unit', 'Don vi', 'DVT']), department: this.inferImportColumnIndex(headerRow, ['Department', 'Bo phan', 'Phong ban']), project: this.inferImportColumnIndex(headerRow, ['Project', 'Du an', 'Cong trinh']), location: this.inferImportColumnIndex(headerRow, ['Location', 'Vi tri', 'Noi dat']), custodian: this.inferImportColumnIndex(headerRow, ['Custodian', 'Nguoi quan ly', 'Nguoi su dung']), purchaseDate: this.inferImportColumnIndex(headerRow, ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve']), purchasePrice: this.inferImportColumnIndex(headerRow, ['Purchase Price', 'Gia mua', 'Don gia']), status: this.inferImportColumnIndex(headerRow, ['Status', 'Trang thai', 'Tinh trang']), notes: this.inferImportColumnIndex(headerRow, ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat']) }; // Fallback by relative offsets for common inventory sheets: // STT | Ngay ve | Ma vat tu | Ten linh kien/sp | Model | DVT | ... | Ton cuoi ki | Ly do xuat | Tinh trang | Vi tri | Du an if (indexMap.stt >= 0) { if (indexMap.purchaseDate < 0) indexMap.purchaseDate = indexMap.stt + 1; if (indexMap.assetCode < 0) indexMap.assetCode = indexMap.stt + 2; if (indexMap.assetName < 0) indexMap.assetName = indexMap.stt + 3; if (indexMap.model < 0) indexMap.model = indexMap.stt + 4; if (indexMap.unit < 0) indexMap.unit = indexMap.stt + 5; if (indexMap.quantity < 0) indexMap.quantity = indexMap.stt + 6; if (indexMap.importInPeriod < 0) indexMap.importInPeriod = indexMap.stt + 7; if (indexMap.exportInPeriod < 0) indexMap.exportInPeriod = indexMap.stt + 8; if (indexMap.endingBalance < 0) indexMap.endingBalance = indexMap.stt + 9; if (indexMap.notes < 0) indexMap.notes = indexMap.stt + 10; if (indexMap.status < 0) indexMap.status = indexMap.stt + 11; if (indexMap.location < 0) indexMap.location = indexMap.stt + 12; if (indexMap.project < 0) indexMap.project = indexMap.stt + 13; } return indexMap; } mapImportedAssetRowsByColumnIndex(matrixRows, headerRowIndex) { const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : []; if (!headerRow.length) { return []; } const indexMap = this.buildAssetImportIndexMap(headerRow); if (indexMap.assetCode < 0 && indexMap.assetName < 0 && indexMap.model < 0 && indexMap.stt < 0) { return []; } const pick = (row, index) => { if (index < 0 || !Array.isArray(row)) { return ''; } return row[index] ?? ''; }; return matrixRows .slice(headerRowIndex + 1) .filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '')) .map((row, rowOffset) => { const sttRaw = String(pick(row, indexMap.stt)).trim(); if (indexMap.stt >= 0) { const normalizedStt = sttRaw.replace(/\.$/, ''); if (!normalizedStt || Number.isNaN(Number(normalizedStt))) { return null; } } const endingBalance = this.parseImportNumericValue( pick(row, indexMap.endingBalance), 0 ); const mapped = { assetCode: String(pick(row, indexMap.assetCode)).trim(), assetName: String(pick(row, indexMap.assetName)).trim(), model: String(pick(row, indexMap.model)).trim(), serialNumber: String(pick(row, indexMap.serialNumber)).trim(), quantity: this.parseImportNumericValue(pick(row, indexMap.quantity), 0), importInPeriod: this.parseImportNumericValue(pick(row, indexMap.importInPeriod), 0), exportInPeriod: this.parseImportNumericValue(pick(row, indexMap.exportInPeriod), 0), endingBalance, unit: String(pick(row, indexMap.unit)).trim(), department: String(pick(row, indexMap.department)).trim(), project: String(pick(row, indexMap.project)).trim(), location: String(pick(row, indexMap.location)).trim(), custodian: String(pick(row, indexMap.custodian)).trim(), purchaseDate: pick(row, indexMap.purchaseDate), purchasePrice: pick(row, indexMap.purchasePrice), status: String(pick(row, indexMap.status)).trim(), notes: String(pick(row, indexMap.notes)).trim() }; return this.finalizeImportedAssetRow(mapped, headerRowIndex + rowOffset + 2); }) .filter(Boolean) .filter(row => !this.isLikelyHeaderArtifactAssetRow(row)) .filter(row => row.assetCode && row.assetName); } findBestAssetImportRowsFromMatrix(matrixRows) { const maxScanRows = Math.min(Array.isArray(matrixRows) ? matrixRows.length : 0, 60); let bestRows = []; for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { const row = Array.isArray(matrixRows[rowIndex]) ? matrixRows[rowIndex] : []; if (!this.isLikelyAssetHeaderRow(row)) { continue; } const candidateRows = this.mapImportedAssetRowsByColumnIndex(matrixRows, rowIndex); if (candidateRows.length > bestRows.length) { bestRows = candidateRows; } if (bestRows.length >= 10) { break; } } return bestRows; } mapImportedAssetRowsBySttPattern(matrixRows) { const rows = Array.isArray(matrixRows) ? matrixRows : []; const maxScanRows = Math.min(rows.length, 60); // Prefer dynamic header detection with STT, then parse by resolved column indexes. let bestRows = []; for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : []; const hasStt = this.hasImportAliasInRow(row, ['STT', 'So thu tu']); const hasName = this.hasImportAliasInRow(row, ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC']); const hasModel = this.hasImportAliasInRow(row, ['Model', 'Dong may']); const hasQty = this.hasImportAliasInRow(row, ['Ton dau ky', 'Ton dau ki', 'Quantity', 'So luong', 'SL', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki']); if (!hasStt || (!hasName && !hasModel && !hasQty)) { continue; } const candidateRows = this.mapImportedAssetRowsByColumnIndex(rows, rowIndex); if (candidateRows.length > bestRows.length) { bestRows = candidateRows; } } if (bestRows.length >= 3) { return bestRows; } // Last-resort fallback for shifted templates where columns are still in STT-order. let detectedSttCol = -1; for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : []; const sttCol = row.findIndex(cell => this.isImportHeaderMatch(cell, 'STT') || this.isImportHeaderMatch(cell, 'So thu tu')); if (sttCol >= 0) { detectedSttCol = sttCol; break; } } if (detectedSttCol < 0) { detectedSttCol = 0; } const sttDataRows = rows.filter(row => { if (!Array.isArray(row)) { return false; } const stt = String(row[detectedSttCol] ?? '').trim().replace(/\.$/, ''); if (!/^\d+$/.test(stt)) { return false; } const hasCoreValue = [2, 3, 4, 5, 9, 12] .map(offset => detectedSttCol + offset) .some(index => String(row[index] ?? '').trim() !== ''); return hasCoreValue; }); if (sttDataRows.length < 3) { return []; } return sttDataRows .map((row, idx) => { const endingBalance = this.parseImportNumericValue(row[detectedSttCol + 9] ?? '', 0); const mapped = { assetCode: String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(), assetName: String(row[detectedSttCol + 3] ?? '').trim() || String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(), model: String(row[detectedSttCol + 4] ?? '').trim(), serialNumber: '', quantity: this.parseImportNumericValue(row[detectedSttCol + 6] ?? '', 0), importInPeriod: this.parseImportNumericValue(row[detectedSttCol + 7] ?? '', 0), exportInPeriod: this.parseImportNumericValue(row[detectedSttCol + 8] ?? '', 0), endingBalance, unit: String(row[detectedSttCol + 5] ?? '').trim(), department: '', project: String(row[detectedSttCol + 13] ?? '').trim(), location: String(row[detectedSttCol + 12] ?? '').trim(), custodian: '', purchaseDate: row[detectedSttCol + 1] ?? '', purchasePrice: '', status: String(row[detectedSttCol + 11] ?? '').trim(), notes: String(row[detectedSttCol + 10] ?? '').trim() }; return this.finalizeImportedAssetRow(mapped, idx + 2); }) .filter(row => !this.isLikelyHeaderArtifactAssetRow(row)) .filter(row => row.assetCode && row.assetName); } sanitizeAssetCodeToken(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toUpperCase() .replace(/[^A-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 40); } generateImportAssetCode(mapped, rowNumber = 0) { const fromModel = this.sanitizeAssetCodeToken(mapped.model); const fromSerial = this.sanitizeAssetCodeToken(mapped.serialNumber); const fromName = this.sanitizeAssetCodeToken(mapped.assetName); const base = fromModel || fromSerial || fromName || 'ASSET'; const suffix = String(rowNumber || 0).padStart(4, '0'); return `IMP-${base}-${suffix}`; } finalizeImportedAssetRow(mapped, rowNumber = 0) { const result = { ...mapped }; if (!result.assetName) { const fallbackName = String(result.model || result.serialNumber || result.assetCode || '').trim(); result.assetName = fallbackName; } if (!result.assetCode && result.assetName) { result.assetCode = this.generateImportAssetCode(result, rowNumber); } return result; } mapImportedAssetRow(row, rowNumber = 0) { const endingBalance = this.parseImportNumericValue( this.findImportValue(row, ['Ton cuoi ki', 'Ton cuoi ky', 'Ton cuoi', 'Ending Balance']), this.parseImportNumericValue(this.findImportValue(row, ['Quantity', 'So luong', 'SL']), 0) ); const mapped = { assetCode: String(this.findImportValue(row, ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'])).trim(), assetName: String(this.findImportValue(row, ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC'])).trim(), model: String(this.findImportValue(row, ['Model', 'Dong may'])).trim(), serialNumber: String(this.findImportValue(row, ['Serial Number', 'Serial', 'So serial', 'So seri'])).trim(), quantity: this.parseImportNumericValue(this.findImportValue(row, ['Ton dau ky', 'Ton dau ki', 'Opening Balance', 'Quantity', 'So luong', 'SL']), 0), importInPeriod: this.parseImportNumericValue(this.findImportValue(row, ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap']), 0), exportInPeriod: this.parseImportNumericValue(this.findImportValue(row, ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat']), 0), endingBalance, unit: String(this.findImportValue(row, ['Unit', 'Don vi', 'DVT'])).trim(), department: String(this.findImportValue(row, ['Department', 'Bo phan', 'Phong ban'])).trim(), project: String(this.findImportValue(row, ['Project', 'Du an', 'Cong trinh'])).trim(), location: String(this.findImportValue(row, ['Location', 'Vi tri', 'Noi dat'])).trim(), custodian: String(this.findImportValue(row, ['Custodian', 'Nguoi quan ly', 'Nguoi su dung'])).trim(), purchaseDate: this.findImportValue(row, ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve']), purchasePrice: this.findImportValue(row, ['Purchase Price', 'Gia mua', 'Don gia']), status: String(this.findImportValue(row, ['Status', 'Trang thai', 'Tinh trang'])).trim(), notes: String(this.findImportValue(row, ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat'])).trim() }; const finalized = this.finalizeImportedAssetRow(mapped, rowNumber); return this.isLikelyHeaderArtifactAssetRow(finalized) ? null : finalized; } shouldFallbackToClientAssetImport(statusCode, message = '') { const normalizedMessage = String(message || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); const parserErrorHints = [ 'khong tim thay dong du lieu hop le', 'khong tim thay dong hop le', 'khong tim thay dong', 'cannot parse import file', 'excel file does not contain a worksheet', 'import data is empty', 'no valid rows found' ]; const isParserRelatedError = parserErrorHints.some(hint => normalizedMessage.includes(hint)); return (statusCode === 400 || statusCode === 422) && isParserRelatedError; } async importAssetsByFileUpload(file) { if (!this.ensureAssetManagePermission('nhap du lieu tai san')) { return { uploaded: false, shouldFallback: false, message: 'No permission' }; } const formData = new FormData(); formData.append('file', file); try { const response = await fetch(`${this.apiBase}/assets/import`, { method: 'POST', headers: this.getAuthHeaders(false), body: formData }); let data = null; try { data = await response.json(); } catch (parseErr) { data = null; } if (!response.ok || !data?.success) { const message = data?.message || 'Nhập Excel thất bại'; const shouldFallback = this.shouldFallbackToClientAssetImport(response.status, message); console.warn('Asset file-upload import failed', { status: response.status, message, diagnostics: data?.diagnostics || null, shouldFallback }); if (!shouldFallback) { this.notifyFailure(message); } return { uploaded: false, shouldFallback, message }; } this.notifySuccess(data.message || 'Nhập Excel thành công'); await this.refreshAssetsUI(); return { uploaded: true, shouldFallback: false }; } catch (err) { console.warn('Asset file-upload import network error, fallback to client parser', err); return { uploaded: false, shouldFallback: true }; } } async processAssetImportFile(event) { if (!this.ensureAssetManagePermission('nhap du lieu tai san')) { event.target.value = ''; return; } const file = event.target?.files?.[0]; if (!file) { return; } try { const uploadResult = await this.importAssetsByFileUpload(file); if (uploadResult.uploaded || !uploadResult.shouldFallback) { event.target.value = ''; return; } } catch (uploadErr) { // Ignore and continue with client-side parser fallback. } if (!window.XLSX) { this.notifyFailure('Chưa tải được thư viện xử lý Excel'); event.target.value = ''; return; } try { const buffer = await file.arrayBuffer(); const workbook = window.XLSX.read(buffer, { type: 'array' }); const sheetNames = Array.isArray(workbook.SheetNames) ? workbook.SheetNames : []; if (!sheetNames.length) { this.notifyFailure('Tệp Excel không có sheet dữ liệu'); return; } let mappedRows = []; let debugSheetName = ''; let debugHeaderRowIndex = -1; let debugMatrixRows = []; for (const sheetName of sheetNames) { const sheet = workbook.Sheets[sheetName]; if (!sheet) { continue; } const matrixRows = window.XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false }); const headerRowIndex = this.findAssetImportHeaderRowIndex(matrixRows); if (!debugMatrixRows.length) { debugSheetName = sheetName; debugHeaderRowIndex = headerRowIndex; debugMatrixRows = matrixRows; } let candidateRows = []; if (headerRowIndex >= 0) { candidateRows = this.mapImportedAssetRowsFromMatrix(matrixRows, headerRowIndex); if (!candidateRows.length) { candidateRows = this.mapImportedAssetRowsByColumnIndex(matrixRows, headerRowIndex); } } const bestRowsFromMatrix = this.findBestAssetImportRowsFromMatrix(matrixRows); if (bestRowsFromMatrix.length > candidateRows.length) { candidateRows = bestRowsFromMatrix; } const sttPatternRows = this.mapImportedAssetRowsBySttPattern(matrixRows); if (sttPatternRows.length > candidateRows.length) { candidateRows = sttPatternRows; console.info('Asset import switched to STT-pattern parser', { sheetName, parsedRows: sttPatternRows.length }); } if (candidateRows.length > mappedRows.length) { mappedRows = candidateRows; debugSheetName = sheetName; debugHeaderRowIndex = headerRowIndex; debugMatrixRows = matrixRows; } } if (!mappedRows.length) { const headerPreview = debugMatrixRows .slice(0, 8) .map((row, rowIndex) => ({ rowIndex, values: (Array.isArray(row) ? row : []).slice(0, 14).map(cell => String(cell ?? '').trim()) })) .filter(item => item.values.some(value => value)); console.warn('Asset import parser could not find valid rows', { sheetName: debugSheetName || sheetNames[0] || '', headerRowIndex: debugHeaderRowIndex, headerPreview }); this.notifyWarning('Không tìm thấy dòng hợp lệ. Vui lòng kiểm tra dòng tiêu đề có cột mã/tên tài sản hoặc mã/tên vật tư.'); return; } const response = await fetch(`${this.apiBase}/assets/import`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ rows: mappedRows }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Nhập Excel thất bại'); return; } this.notifySuccess(data.message || 'Nhập Excel thành công'); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Nhập Excel thất bại'); } finally { event.target.value = ''; } } exportAssetsToExcel() { if (!window.XLSX) { this.notifyFailure('Chưa tải được thư viện xử lý Excel'); return; } const exportRows = this.assets.map(asset => ({ 'Asset Code': asset.AssetCode || '', 'Asset Name': asset.AssetName || '', 'Model': asset.Model || '', 'Serial Number': asset.SerialNumber || '', 'Quantity': asset.Quantity || 0, 'Import In Period': asset.ImportInPeriod ?? 0, 'Export In Period': asset.ExportInPeriod ?? 0, 'Ending Balance': asset.EndingBalance ?? 0, 'Unit': asset.Unit || '', 'Department': asset.Department || '', 'Project': asset.Project || '', 'Location': asset.Location || '', 'Custodian': asset.Custodian || '', 'Borrower': asset.Borrower || '', 'Exported By': asset.ExportedBy || '', 'Purchase Date': this.toDateInputValue(asset.PurchaseDate), 'Purchase Price': asset.PurchasePrice || '', 'Status': asset.Status || '', 'Notes': asset.Notes || '' })); const worksheet = window.XLSX.utils.json_to_sheet(exportRows); const workbook = window.XLSX.utils.book_new(); window.XLSX.utils.book_append_sheet(workbook, worksheet, 'TaiSan'); const now = new Date(); const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; window.XLSX.writeFile(workbook, `danh-sach-tai-san-${timestamp}.xlsx`); this.notifySuccess('Xuất Excel thành công'); } renderAccountsTableBody() { const tbody = document.querySelector('.accounts-table-body'); if (!tbody) return; const currentUserId = this.getUserId(); const pageInfo = this.getPaged(this.getFilteredAccounts(), this.accountPage, this.accountPageSize); this.accountPage = pageInfo.current; tbody.innerHTML = pageInfo.data.map(acc => { const isOwnAccount = acc.UserId == currentUserId; const accountUsername = acc.AccountUsername || '-'; const displayAccountUsername = isOwnAccount ? accountUsername : this.maskForeignAccountUsername(accountUsername); const createdDate = this.formatDateTime(acc.CreatedDate); const updatedDate = this.formatDateTime(acc.UpdatedDate); const actionContent = isOwnAccount ? ` ` : '-'; return ` ${acc.Email || '-'} ${displayAccountUsername} ${acc.AppName || '-'} ${createdDate} ${updatedDate} ${actionContent} `; }).join(''); const pager = document.getElementById('accountsPager'); if (pager) { pager.innerHTML = ` Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`; } this.setupAccountRowListeners(); this.setupAccountPagerListeners(); } renderApplicationsTableBody() { const tbody = document.querySelector('.apps-table-body'); if (!tbody) return; const pageInfo = this.getPaged(this.getFilteredApplications(), this.appPage, this.appPageSize); this.appPage = pageInfo.current; tbody.innerHTML = pageInfo.data.map(app => `
${app.Icon || 'apps'}
${app.Name}
${app.Type} ${app.Description || '-'} ${(app.Url || app.url) ? `${app.Url || app.url}` : '-'}
${(app.Status || app.status) === 'online' ? 'Online' : 'Offline'}
`).join(''); const pager = document.getElementById('appsPager'); if (pager) { pager.innerHTML = ` Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`; } this.setupAccountRowListeners(); this.setupAppPagerListeners(); } setupAccountPagerListeners() { document.querySelectorAll('.account-page-btn').forEach(btn => { btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) return; this.accountPage = targetPage; this.renderAccountsTableBody(); }); }); } setupAppPagerListeners() { document.querySelectorAll('.app-page-btn').forEach(btn => { btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) return; this.appPage = targetPage; this.renderApplicationsTableBody(); }); }); } setupAccountRowListeners() { // View Account listeners document.querySelectorAll('.view-account').forEach(btn => { btn.addEventListener('click', (e) => { if (btn.disabled) return; // Only view own accounts const accountId = Number(btn.dataset.accountId); const account = this.accounts.find(a => a.AccountId === accountId); this.currentViewAccountId = accountId; this.currentViewAccount = account; document.getElementById('viewAccountService').textContent = account?.AppName || '-'; document.getElementById('viewAccountOwner').textContent = account?.Email || '-'; document.getElementById('viewAccountUsername').textContent = account?.AccountUsername || '-'; const passwordEl = document.getElementById('viewAccountPassword'); const toggleBtn = document.querySelector('.toggle-password'); const toggleIcon = document.getElementById('toggleIcon'); const storedPwd = account?.AccountPassword || ''; passwordEl.dataset.password = storedPwd; passwordEl.textContent = storedPwd ? '********' : '(no password stored)'; passwordEl.dataset.visible = 'false'; if (toggleIcon) toggleIcon.textContent = 'visibility'; // Rebind toggle each time modal opens to keep state fresh if (toggleBtn) { toggleBtn.onclick = () => { const currentPwd = passwordEl.dataset.password || ''; const isVisible = passwordEl.dataset.visible === 'true'; if (isVisible) { passwordEl.textContent = currentPwd ? '********' : '(no password stored)'; passwordEl.dataset.visible = 'false'; if (toggleIcon) toggleIcon.textContent = 'visibility'; } else { passwordEl.textContent = currentPwd || '(no password stored)'; passwordEl.dataset.visible = 'true'; if (toggleIcon) toggleIcon.textContent = 'visibility_off'; } }; } document.getElementById('viewAccountModal').classList.add('open'); }); }); // Delete Account listeners - show confirmation modal document.querySelectorAll('.delete-account').forEach(btn => { btn.addEventListener('click', (e) => { if (btn.disabled) return; // Don't delete others' accounts const accountId = Number(btn.dataset.accountId); const account = this.accounts.find(a => a.AccountId === accountId); this.pendingDeleteAccountId = accountId; document.getElementById('deleteAccountUsername').textContent = account?.AccountUsername || ''; document.getElementById('deleteAccountModal').classList.add('open'); }); }); // Confirm Delete Account document.querySelectorAll('.confirm-delete-account').forEach(btn => { btn.addEventListener('click', () => { if (this.pendingDeleteAccountId !== undefined) { fetch(`${this.apiBase}/accounts/${this.pendingDeleteAccountId}`, { method: 'DELETE' }) .then(res => res.json()) .then(data => { if (data.success) { this.notifySuccess('Account deleted successfully'); this.closeModals(); this.refreshAccountsUI(); } else { this.notifyFailure(data.message || 'Delete account failed'); } }) .catch(err => { console.error(err); this.notifyFailure('Delete account failed'); }); } }); }); // Edit Account listeners document.querySelectorAll('.edit-account').forEach(btn => { btn.addEventListener('click', (e) => { if (btn.disabled) return; // Don't edit others' accounts const accountId = Number(btn.dataset.accountId); const account = this.accounts.find(a => a.AccountId === accountId); // Populate form with existing data const form = document.getElementById('accountForm'); if (form) { const userInput = form.querySelector('#accountUsername'); const passInput = form.querySelector('#accountPassword'); const ownerInput = form.querySelector('#accountOwner'); const serviceSelect = form.querySelector('#accountService'); if (userInput) userInput.value = account?.AccountUsername || ''; if (passInput) passInput.value = account?.AccountPassword || ''; if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || ''; if (serviceSelect) serviceSelect.value = account?.AppId || ''; } this.pendingAccountAppId = account?.AppId; this.editingAccountId = account?.AccountId; this.closeModals(); this.openAccountModal(); }); }); // Edit from View modal document.querySelectorAll('.edit-account-from-view').forEach(btn => { btn.addEventListener('click', () => { const account = this.currentViewAccount; const form = document.getElementById('accountForm'); if (form) { const userInput = form.querySelector('#accountUsername'); const passInput = form.querySelector('#accountPassword'); const ownerInput = form.querySelector('#accountOwner'); const serviceSelect = form.querySelector('#accountService'); if (userInput) userInput.value = account?.AccountUsername || ''; if (passInput) passInput.value = account?.AccountPassword || ''; if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || ''; if (serviceSelect) serviceSelect.value = account?.AppId || ''; } this.pendingAccountAppId = account?.AppId; this.editingAccountId = account?.AccountId; this.closeModals(); this.openAccountModal(); }); }); // View App listeners document.querySelectorAll('.view-app').forEach(btn => { btn.addEventListener('click', (e) => { const appId = Number(btn.dataset.appId); const app = this.applications.find(a => a.AppId === appId); this.currentViewAppId = appId; document.getElementById('viewAppName').textContent = app?.Name || '-'; document.getElementById('viewAppType').textContent = app?.Type || '-'; const iconVal = app?.Icon || app?.icon || 'apps'; const iconSymbolEl = document.getElementById('viewAppIconSymbol'); const iconNameEl = document.getElementById('viewAppIconName'); if (iconSymbolEl) iconSymbolEl.textContent = iconVal; if (iconNameEl) iconNameEl.textContent = iconVal; document.getElementById('viewAppDescription').textContent = app?.Description || '-'; const urlEl = document.getElementById('viewAppUrl'); const urlVal = app?.Url || app?.url; if (urlEl) { if (urlVal) { urlEl.innerHTML = `${urlVal}`; } else { urlEl.textContent = '-'; } } const statusValue = app?.Status || app?.status; document.getElementById('viewAppStatus').textContent = statusValue === 'online' ? 'Online' : 'Offline'; document.getElementById('viewAppModal').classList.add('open'); }); }); // Delete App listeners - show confirmation modal document.querySelectorAll('.delete-app').forEach(btn => { btn.addEventListener('click', (e) => { const appId = Number(btn.dataset.appId); const app = this.applications.find(a => a.AppId === appId); this.pendingDeleteAppId = appId; document.getElementById('deleteAppName').textContent = app?.Name || ''; document.getElementById('deleteAppModal').classList.add('open'); }); }); // Confirm Delete App document.querySelectorAll('.confirm-delete-app').forEach(btn => { btn.addEventListener('click', () => { if (this.pendingDeleteAppId !== undefined) { fetch(`${this.apiBase}/applications/${this.pendingDeleteAppId}`, { method: 'DELETE' }) .then(res => res.json()) .then(data => { if (data.success) { this.notifySuccess('Application deleted successfully'); this.closeModals(); this.refreshApplicationsUI(); } else { this.notifyFailure(data.message || 'Delete application failed'); } }) .catch(err => { console.error(err); this.notifyFailure('Delete application failed'); }); } }); }); // Edit App listeners document.querySelectorAll('.edit-app').forEach(btn => { btn.addEventListener('click', (e) => { const appId = Number(btn.dataset.appId); const app = this.applications.find(a => a.AppId === appId); document.getElementById('appName').value = app?.Name || ''; document.getElementById('appType').value = app?.Type || ''; document.getElementById('appStatus').value = app?.Status || 'online'; document.getElementById('appDescription').value = app?.Description || ''; document.getElementById('appIcon').value = app?.Icon || app?.icon || ''; document.getElementById('appUrl').value = app?.Url || app?.url || ''; this.editingAppId = app?.AppId; this.closeModals(); this.openAppModal(); }); }); // Edit App from View modal document.querySelectorAll('.edit-app-from-view').forEach(btn => { btn.addEventListener('click', () => { const appId = this.currentViewAppId; const app = this.applications.find(a => a.AppId === appId); document.getElementById('appName').value = app?.Name || ''; document.getElementById('appType').value = app?.Type || ''; document.getElementById('appStatus').value = app?.Status || 'online'; document.getElementById('appDescription').value = app?.Description || ''; document.getElementById('appIcon').value = app?.Icon || app?.icon || ''; document.getElementById('appUrl').value = app?.Url || ''; this.editingAppId = app?.AppId; this.closeModals(); this.openAppModal(); }); }); } setupAddButtonListeners() { // Add Account button document.querySelectorAll('#addAccountBtn').forEach(btn => { btn.addEventListener('click', () => { this.editingAccountId = undefined; this.pendingAccountAppId = undefined; this.openAccountModal(); }); }); // Add Application button document.querySelectorAll('#addAppBtn').forEach(btn => { btn.addEventListener('click', () => { this.editingAppId = undefined; this.openAppModal(); }); }); // Add Asset button document.querySelectorAll('#addAssetBtn').forEach(btn => { btn.addEventListener('click', () => { this.editingAssetId = undefined; this.openAssetModal(); }); }); const addAssetDepartmentBtn = document.getElementById('addAssetDepartmentBtn'); if (addAssetDepartmentBtn && !addAssetDepartmentBtn.dataset.boundClick) { addAssetDepartmentBtn.addEventListener('click', () => this.handleCreateAssetDepartment()); addAssetDepartmentBtn.dataset.boundClick = 'true'; } const addAssetBorrowRequestBtn = document.getElementById('addAssetBorrowRequestBtn'); if (addAssetBorrowRequestBtn && !addAssetBorrowRequestBtn.dataset.boundClick) { addAssetBorrowRequestBtn.addEventListener('click', () => this.openAssetBorrowRequestModal('borrow')); addAssetBorrowRequestBtn.dataset.boundClick = 'true'; } const addAssetReturnRequestBtn = document.getElementById('addAssetReturnRequestBtn'); if (addAssetReturnRequestBtn && !addAssetReturnRequestBtn.dataset.boundClick) { addAssetReturnRequestBtn.addEventListener('click', () => this.openAssetBorrowRequestModal('return')); addAssetReturnRequestBtn.dataset.boundClick = 'true'; } const openPendingAssetBorrowsBtn = document.getElementById('openPendingAssetBorrowsBtn'); if (openPendingAssetBorrowsBtn && !openPendingAssetBorrowsBtn.dataset.boundClick) { openPendingAssetBorrowsBtn.addEventListener('click', () => this.openPendingAssetRequestsModal()); openPendingAssetBorrowsBtn.dataset.boundClick = 'true'; } const borrowAssetBtn = document.getElementById('borrowAssetBtn'); if (borrowAssetBtn && !borrowAssetBtn.dataset.boundClick) { borrowAssetBtn.addEventListener('click', () => this.openBorrowAssetModal()); borrowAssetBtn.dataset.boundClick = 'true'; } const importAssetBtn = document.getElementById('importAssetBtn'); const assetImportInput = document.getElementById('assetImportInput'); const exportAssetBtn = document.getElementById('exportAssetBtn'); if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) { importAssetBtn.addEventListener('click', () => { if (!this.ensureAssetManagePermission('nhap du lieu tai san')) { return; } assetImportInput.click(); }); importAssetBtn.dataset.boundClick = 'true'; } if (assetImportInput && !assetImportInput.dataset.boundChange) { assetImportInput.addEventListener('change', (event) => this.processAssetImportFile(event)); assetImportInput.dataset.boundChange = 'true'; } if (exportAssetBtn && !exportAssetBtn.dataset.boundClick) { exportAssetBtn.addEventListener('click', () => this.exportAssetsToExcel()); exportAssetBtn.dataset.boundClick = 'true'; } } setupFilters() { const serviceFilter = document.getElementById('serviceFilter'); if (serviceFilter) { serviceFilter.value = this.accountServiceFilter || ''; serviceFilter.addEventListener('change', (e) => { this.accountServiceFilter = e.target.value; this.renderAccountsTableBody(); }); } const accountSearch = document.getElementById('accountSearch'); if (accountSearch) { accountSearch.value = this.accountSearchTerm; const handleAccountSearch = event => { this.accountSearchTerm = event.target.value.toLowerCase(); this.renderAccountsTableBody(); }; accountSearch.addEventListener('input', handleAccountSearch); // Restore focus/selection after renders to avoid typing interruptions accountSearch.addEventListener('focus', () => { accountSearch.dataset.focused = 'true'; }); accountSearch.addEventListener('blur', () => { accountSearch.dataset.focused = 'false'; }); } const appSearch = document.getElementById('appSearch'); if (appSearch) { appSearch.value = this.applicationSearchTerm; const handleApplicationSearch = event => { this.applicationSearchTerm = event.target.value.toLowerCase(); this.renderApplicationsTableBody(); }; appSearch.addEventListener('input', handleApplicationSearch); // Restore focus/selection after renders to avoid typing interruptions appSearch.addEventListener('focus', () => { appSearch.dataset.focused = 'true'; }); appSearch.addEventListener('blur', () => { appSearch.dataset.focused = 'false'; }); } const assetStatusFilter = document.getElementById('assetStatusFilter'); if (assetStatusFilter) { assetStatusFilter.value = this.assetStatusFilter || ''; assetStatusFilter.addEventListener('change', (e) => { this.assetStatusFilter = String(e.target.value || '').toLowerCase(); this.assetPage = 1; this.renderAssetsTableBody(); }); } const assetSearch = document.getElementById('assetSearch'); if (assetSearch) { assetSearch.value = this.assetSearchTerm; const handleAssetSearch = event => { this.assetSearchTerm = event.target.value.toLowerCase(); this.assetPage = 1; this.renderAssetsTableBody(); }; assetSearch.addEventListener('input', handleAssetSearch); assetSearch.addEventListener('focus', () => { assetSearch.dataset.focused = 'true'; }); assetSearch.addEventListener('blur', () => { assetSearch.dataset.focused = 'false'; }); } } async handleAccountSubmit(e) { e.preventDefault(); const accountForm = document.getElementById('accountForm'); const userId = this.getUserId(); const appId = Number(accountForm?.querySelector('#accountService')?.value || 0); const accountUsername = (accountForm?.querySelector('#accountUsername')?.value || '').trim(); const accountPassword = (accountForm?.querySelector('#accountPassword')?.value || '').trim(); const accountEmail = ((accountForm?.querySelector('#accountOwner')?.value || '').trim()) || this.currentUser?.Username || this.currentUser?.username || ''; if (!accountForm) { this.notifyFailure('Account form not found.'); return; } if (!userId) { this.notifyFailure('User is not authenticated. Please login again.'); return; } if (!appId) { this.notifyWarning('Please select a service.'); return; } if (!accountUsername) { this.notifyWarning('Please enter a username.'); return; } if (!accountPassword) { this.notifyWarning('Please enter a password.'); return; } const payload = { userId, appId, accountUsername, accountPassword, email: accountEmail, accessLevel: 'user', notes: '' }; const isEdit = this.editingAccountId !== undefined; const url = isEdit ? `${this.apiBase}/accounts/${this.editingAccountId}` : `${this.apiBase}/accounts`; const method = isEdit ? 'PUT' : 'POST'; fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(res => res.json()).then(data => { if (data.success) { this.editingAccountId = undefined; this.pendingAccountAppId = undefined; this.notifySuccess(isEdit ? 'Account updated successfully' : 'Account created successfully'); this.closeModals(); this.refreshAccountsUI(); } else { this.notifyFailure(data.message || 'Save account failed'); } }).catch(err => { console.error(err); this.notifyFailure('Save account failed'); }); } async refreshAccountsUI() { await this.fetchAccounts(); if (this.currentPage === 'accounts') { this.renderView('accounts'); } } async handleAppSubmit(e) { e.preventDefault(); const payload = { name: document.getElementById('appName').value, type: document.getElementById('appType').value, status: document.getElementById('appStatus').value, icon: (document.getElementById('appIcon')?.value || 'apps').trim() || 'apps', description: document.getElementById('appDescription')?.value || '', url: (document.getElementById('appUrl')?.value || '').trim() }; const isEdit = this.editingAppId !== undefined; const url = isEdit ? `${this.apiBase}/applications/${this.editingAppId}` : `${this.apiBase}/applications`; const method = isEdit ? 'PUT' : 'POST'; fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(res => res.json()).then(data => { if (data.success) { this.editingAppId = undefined; this.notifySuccess(isEdit ? 'Application updated successfully' : 'Application created successfully'); this.closeModals(); this.refreshApplicationsUI(); } else { this.notifyFailure(data.message || 'Save application failed'); } }).catch(err => { console.error(err); this.notifyFailure('Save application failed'); }); } async refreshApplicationsUI() { await this.fetchApplications(); if (this.currentPage === 'applications') { this.renderView('applications'); } } openAccountModal() { // Refresh service options so newly added applications appear const serviceSelect = document.getElementById('accountService'); if (serviceSelect) { serviceSelect.innerHTML = `` + this.applications.map(app => ``).join(''); if (this.editingAccountId !== undefined && this.pendingAccountAppId) { serviceSelect.value = this.pendingAccountAppId; } } if (this.editingAccountId === undefined) { const form = document.getElementById('accountForm'); if (form) { const serviceSelect = form.querySelector('#accountService'); const ownerInput = form.querySelector('#accountOwner'); const userInput = form.querySelector('#accountUsername'); const passInput = form.querySelector('#accountPassword'); if (serviceSelect) serviceSelect.value = ''; if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || ''; if (userInput) userInput.value = ''; if (passInput) passInput.value = ''; } } document.getElementById('accountModal').classList.add('open'); } openAppModal() { if (this.editingAppId === undefined) { document.getElementById('appName').value = ''; document.getElementById('appType').value = ''; document.getElementById('appStatus').value = 'online'; const iconInput = document.getElementById('appIcon'); const desc = document.getElementById('appDescription'); const url = document.getElementById('appUrl'); if (iconInput) iconInput.value = ''; if (desc) desc.value = ''; if (url) url.value = ''; } document.getElementById('appModal').classList.add('open'); } closeModals() { document.querySelectorAll('.modal-backdrop').forEach(modal => { modal.classList.remove('open'); }); } async openProfileModal() { try { const response = await fetch(`${this.apiBase}/users/me`, { headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!data.success || !data.data) { this.notifyFailure(data.message || 'Cannot load profile'); return; } this.renderProfileModal(data.data); } catch (err) { console.error(err); this.notifyFailure('Cannot load profile'); } } renderProfileModal(profile) { const isVerified = profile?.EmailVerified === true || profile?.EmailVerified === 1; const html = ` `; const containerId = 'profileModalContainer'; let container = document.getElementById(containerId); if (!container) { container = document.createElement('div'); container.id = containerId; document.body.appendChild(container); } container.innerHTML = html; const form = document.getElementById('profileForm'); if (form) { form.addEventListener('submit', (e) => this.saveProfile(e)); } this.setupProfilePasswordToggles(); const modal = document.getElementById('profileModal'); if (modal) { modal.addEventListener('click', (e) => { if (e.target === modal) { closeProfileModal(); } }); } } setupProfilePasswordToggles() { document.querySelectorAll('[data-password-toggle]').forEach((toggleBtn) => { if (toggleBtn.dataset.bound === 'true') { return; } toggleBtn.addEventListener('click', () => { const inputId = toggleBtn.dataset.passwordToggle; if (!inputId) return; const input = document.getElementById(inputId); const icon = document.getElementById(`${inputId}Icon`); if (!input) return; const isHidden = input.type === 'password'; input.type = isHidden ? 'text' : 'password'; if (icon) { icon.textContent = isHidden ? 'visibility_off' : 'visibility'; } }); toggleBtn.dataset.bound = 'true'; }); } async saveProfile(e) { e.preventDefault(); const fullname = document.getElementById('profileFullName')?.value.trim() || ''; const email = document.getElementById('profileEmail')?.value.trim() || ''; const currentPassword = document.getElementById('profileCurrentPassword')?.value || ''; const newPassword = document.getElementById('profileNewPassword')?.value || ''; const confirmPassword = document.getElementById('profileConfirmPassword')?.value || ''; if (!fullname || !email) { this.notifyFailure('Full name and email are required'); return; } if (newPassword && newPassword !== confirmPassword) { this.notifyFailure('New password and confirm password do not match'); return; } if (newPassword && !currentPassword) { this.notifyFailure('Current password is required to change password'); return; } try { const response = await fetch(`${this.apiBase}/users/me`, { method: 'PUT', headers: this.getAuthHeaders(true), body: JSON.stringify({ fullname, email, currentPassword, newPassword }) }); const data = await response.json(); if (!data.success) { this.notifyFailure(data.message || 'Update profile failed'); return; } if (data.user) { this.currentUser = { ...this.currentUser, ...data.user, role: data.user.role || data.user.Role || this.currentUser.role || this.currentUser.Role }; this.saveToStorage('currentUser', this.currentUser); this.updateAccountDisplay(); } closeProfileModal(); this.notifySuccess(data.message || 'Profile updated'); if (data.verificationRequired && data.emailSent === false) { if (data.verificationPreviewUrl) { this.notifyWarning(`Email confirmation link (dev): ${data.verificationPreviewUrl}`); } else { this.notifyWarning('Email changed but verification email could not be sent.'); } } } catch (err) { console.error(err); this.notifyFailure('Update profile failed'); } } loadFromStorage(key) { const data = localStorage.getItem(key); return data ? JSON.parse(data) : null; } saveToStorage(key, data) { localStorage.setItem(key, JSON.stringify(data)); } formatDateTime(value) { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) { return String(value); } return date.toLocaleString(); } // ========== Users Management ========== getUsersContent() { const filteredUsers = this.getFilteredUsers(); const pageInfo = this.getPaged(filteredUsers, this.userPage, this.userPageSize); this.userPage = pageInfo.current; return `
search
${pageInfo.data.length === 0 ? ` ` : pageInfo.data.map(user => ` `).join('')}
Username Full Name Email Role Status Actions
No users found
${user.Username} ${user.FullName || '-'} ${user.Email || '-'} ${user.RoleName || user.Role || 'N/A'} ${user.IsActive ? 'Active' : 'Inactive'}
Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`; } setupUsersRowListeners() { const userRows = document.querySelectorAll('.user-row'); userRows.forEach(row => { const viewBtn = row.querySelector('.view-user-btn'); const editBtn = row.querySelector('.edit-user-btn'); const deleteBtn = row.querySelector('.delete-user-btn'); const userId = row.dataset.userId; if (viewBtn) { viewBtn.addEventListener('click', () => this.viewUserDetails(userId)); } if (editBtn) { editBtn.addEventListener('click', () => this.editUser(userId)); } if (deleteBtn && !deleteBtn.disabled) { deleteBtn.addEventListener('click', () => this.deleteUserConfirm(userId)); } }); // Search and Filter const searchInput = document.getElementById('userSearch'); const roleFilter = document.getElementById('roleFilter'); if (searchInput) { searchInput.oninput = (e) => { this.userSearchTerm = (e.target.value || '').toLowerCase(); this.userPage = 1; this.renderUsersTableBody(); }; } if (roleFilter) { roleFilter.value = this.userRoleFilter || ''; roleFilter.onchange = (e) => { this.userRoleFilter = e.target.value; this.userPage = 1; this.renderUsersTableBody(); }; } // Add User Button const addBtn = document.getElementById('addUserBtn'); if (addBtn) { addBtn.onclick = () => this.openUserModal(); } const addRoleBtn = document.getElementById('addRoleBtn'); if (addRoleBtn) { addRoleBtn.onclick = () => this.openRoleModal(); } } getFilteredUsers() { const search = (this.userSearchTerm || '').toLowerCase(); const roleId = this.userRoleFilter || ''; return this.users.filter(user => { const matchesSearch = !search || [user.Username, user.FullName, user.Email].some(val => (val || '').toLowerCase().includes(search)); const matchesRole = !roleId || String(user.RoleId) === String(roleId) || String(user.RoleID) === String(roleId); return matchesSearch && matchesRole; }); } renderUsersTableBody() { const tbody = document.querySelector('.users-table-body'); if (!tbody) return; const pageInfo = this.getPaged(this.getFilteredUsers(), this.userPage, this.userPageSize); this.userPage = pageInfo.current; tbody.innerHTML = pageInfo.data.length === 0 ? ` No users found ` : pageInfo.data.map(user => ` ${user.Username} ${user.FullName || '-'} ${user.Email || '-'} ${user.RoleName || user.Role || 'N/A'} ${user.IsActive ? 'Active' : 'Inactive'}
`).join(''); const pager = document.getElementById('usersPager'); if (pager) { pager.innerHTML = ` Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`; } this.setupUsersRowListeners(); this.setupUsersPagerListeners(); } setupUsersPagerListeners() { document.querySelectorAll('.user-page-btn').forEach(btn => { btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) return; this.userPage = targetPage; this.renderUsersTableBody(); }); }); } openUserModal() { this.showUserFormModal(null); } openRoleModal() { this.showRoleFormModal(); } showRoleFormModal() { const html = ` `; const editingContainer = document.getElementById('roleModalContainer'); if (editingContainer) { editingContainer.innerHTML = html; } else { const container = document.createElement('div'); container.id = 'roleModalContainer'; document.body.appendChild(container); container.innerHTML = html; } const form = document.getElementById('roleForm'); if (form) { form.addEventListener('submit', (e) => this.saveRole(e)); } const modal = document.getElementById('roleModal'); if (modal) { modal.addEventListener('click', function(e) { if (e.target === this) { closeRoleModal(); } }); } } async saveRole(e) { e.preventDefault(); const roleName = document.getElementById('roleName')?.value.trim(); const description = document.getElementById('roleDescription')?.value.trim(); if (!roleName) { this.notifyFailure('Role name is required'); return; } const roleExists = this.roles.some(role => String(role.RoleName || '').trim().toLowerCase() === roleName.toLowerCase() ); if (roleExists) { this.notifyWarning('Role already exists'); return; } try { const response = await fetch(`${this.apiBase}/roles`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ roleName, description: description || null }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Create role failed'); return; } this.notifySuccess('Role created'); closeRoleModal(); await this.fetchRoles(); if (this.currentPage === 'users') { this.renderView('users'); } } catch (err) { console.error(err); this.notifyFailure('Create role failed'); } } showUserFormModal(userId) { const user = userId ? this.users.find(u => u.UserId == userId) : null; const html = ` `; // Insert modal in DOM const editingContainer = document.getElementById('userModalContainer'); if (editingContainer) { editingContainer.innerHTML = html; } else { const container = document.createElement('div'); container.id = 'userModalContainer'; document.body.appendChild(container); container.innerHTML = html; } // Add form submit listener const form = document.getElementById('userForm'); if (form) { form.addEventListener('submit', (e) => this.saveUser(e, userId)); } const passwordInput = document.getElementById('userPassword'); const passwordToggleBtn = document.getElementById('userPasswordToggle'); const passwordToggleIcon = document.getElementById('userPasswordToggleIcon'); if (passwordInput && passwordToggleBtn) { passwordToggleBtn.addEventListener('click', () => { const isHidden = passwordInput.type === 'password'; passwordInput.type = isHidden ? 'text' : 'password'; if (passwordToggleIcon) { passwordToggleIcon.textContent = isHidden ? 'visibility_off' : 'visibility'; } }); } // Close on backdrop click const modal = document.getElementById('userModal'); if (modal) { modal.addEventListener('click', function(e) { if (e.target === this) { closeUserModal(); } }); } } async saveUser(e, userId) { e.preventDefault(); const username = document.getElementById('userUsername').value.trim(); const fullname = document.getElementById('userFullName').value.trim(); const email = document.getElementById('userEmail').value.trim(); const password = document.getElementById('userPassword')?.value.trim(); const roleId = document.getElementById('userRole').value; const isActive = document.getElementById('userActive').checked; // Validate required fields if (!username || !fullname) { this.notifyFailure('Username and Full Name are required'); return; } // Password required for new user if (!userId && !password) { this.notifyFailure('Password is required for new user'); return; } const method = userId ? 'PUT' : 'POST'; const url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`; const payload = userId ? { email: email || null, fullname, roleId: parseInt(roleId), status: 'Active', isActive, ...(password ? { password } : {}) } : { username, password, email: email || null, fullname, roleId: parseInt(roleId) }; try { const response = await fetch(url, { method, headers: this.getAuthHeaders(true), body: JSON.stringify(payload) }); const data = await response.json(); if (data.success) { this.notifySuccess(userId ? 'User updated' : 'User created'); closeUserModal(); this.refreshUsersUI(); } else { this.notifyFailure(data.message || 'Save failed'); } } catch (err) { console.error(err); this.notifyFailure('Save failed'); } } async editUser(userId) { this.showUserFormModal(userId); } async viewUserDetails(userId) { try { const response = await fetch(`${this.apiBase}/users/${userId}`, { headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!data.success || !data.data) { this.notifyFailure(data.message || 'Cannot load user details'); return; } this.showUserDetailsModal(data.data); } catch (err) { console.error(err); this.notifyFailure('Cannot load user details'); } } showUserDetailsModal(user) { const html = ` `; const detailsContainer = document.getElementById('userDetailsModalContainer'); if (detailsContainer) { detailsContainer.innerHTML = html; } else { const container = document.createElement('div'); container.id = 'userDetailsModalContainer'; container.innerHTML = html; document.body.appendChild(container); } const passwordValue = user?.Password || ''; const hasReadablePassword = user?.PasswordAvailable === true || Boolean(passwordValue); const usernameEl = document.getElementById('userDetailUsername'); const fullNameEl = document.getElementById('userDetailFullName'); const emailEl = document.getElementById('userDetailEmail'); const roleEl = document.getElementById('userDetailRole'); const statusEl = document.getElementById('userDetailStatus'); const createdDateEl = document.getElementById('userDetailCreatedDate'); const lastLoginEl = document.getElementById('userDetailLastLogin'); const passwordEl = document.getElementById('userDetailPassword'); const passwordToggleBtn = document.getElementById('userDetailPasswordToggle'); const passwordToggleIcon = document.getElementById('userDetailPasswordToggleIcon'); const editBtn = document.getElementById('userDetailEditBtn'); const detailsModal = document.getElementById('userDetailsModal'); if (usernameEl) usernameEl.textContent = user?.Username || '-'; if (fullNameEl) fullNameEl.textContent = user?.FullName || '-'; if (emailEl) emailEl.textContent = user?.Email || '-'; if (roleEl) roleEl.textContent = user?.RoleName || user?.Role || '-'; if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive'; if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate); if (lastLoginEl) lastLoginEl.textContent = this.formatDateTime(user?.LastLogin); if (passwordEl) { passwordEl.dataset.password = passwordValue; passwordEl.dataset.visible = 'false'; passwordEl.textContent = hasReadablePassword ? '********' : '(khong the hien thi - can reset password 1 lan)'; } if (passwordToggleBtn && passwordEl) { passwordToggleBtn.addEventListener('click', () => { if (!hasReadablePassword) { return; } const isVisible = passwordEl.dataset.visible === 'true'; if (isVisible) { passwordEl.textContent = '********'; passwordEl.dataset.visible = 'false'; if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility'; } else { passwordEl.textContent = passwordValue; passwordEl.dataset.visible = 'true'; if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility_off'; } }); } if (passwordToggleBtn && !hasReadablePassword) { passwordToggleBtn.disabled = true; passwordToggleBtn.classList.add('opacity-50', 'cursor-not-allowed'); } if (editBtn) { editBtn.addEventListener('click', () => { closeUserDetailsModal(); this.editUser(user?.UserId); }); } if (detailsModal) { detailsModal.addEventListener('click', function(e) { if (e.target === this) { closeUserDetailsModal(); } }); } } async deleteUserConfirm(userId) { const user = this.users.find(u => u.UserId == userId); if (!user) return; if (confirm(`Are you sure you want to delete user "${user.Username}"?`)) { await this.deleteUser(userId); } } async deleteUser(userId) { try { const response = await fetch(`${this.apiBase}/users/${userId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (data.success) { this.notifySuccess('User deleted'); this.refreshUsersUI(); } else { this.notifyFailure(data.message || 'Delete failed'); } } catch (err) { console.error(err); this.notifyFailure('Delete failed'); } } async refreshUsersUI() { await this.fetchUsers(); if (this.currentPage === 'users') { this.renderView('users'); } } } // Global modal close functions function closeAllModals() { document.querySelectorAll('.modal-backdrop').forEach(modal => { modal.classList.remove('open'); }); } function closeAccountModal() { document.getElementById('accountModal').classList.remove('open'); } function closeViewAccountModal() { document.getElementById('viewAccountModal').classList.remove('open'); } function closeDeleteAccountModal() { document.getElementById('deleteAccountModal').classList.remove('open'); } function closeAppModal() { document.getElementById('appModal').classList.remove('open'); } function closeViewAppModal() { document.getElementById('viewAppModal').classList.remove('open'); } function closeDeleteAppModal() { document.getElementById('deleteAppModal').classList.remove('open'); } function closeAssetModal() { document.getElementById('assetModal').classList.remove('open'); } function closeViewAssetModal() { document.getElementById('viewAssetModal').classList.remove('open'); } function closeDeleteAssetModal() { document.getElementById('deleteAssetModal').classList.remove('open'); } function closeBorrowAssetModal() { const modal = document.getElementById('borrowAssetModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetBorrowRequestModal() { const modal = document.getElementById('assetBorrowRequestModal'); const dropdown = document.getElementById('assetBorrowProductDropdown'); if (modal) { modal.classList.remove('open'); } if (dropdown) { dropdown.classList.add('hidden'); } } function closeAssetPendingRequestsModal() { const modal = document.getElementById('assetPendingRequestsModal'); if (modal) { modal.classList.remove('open'); } const rejectModal = document.getElementById('assetRequestRejectModal'); if (rejectModal) { rejectModal.classList.remove('open'); } } function closeAssetRequestRejectModal() { const modal = document.getElementById('assetRequestRejectModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetDepartmentModal() { const modal = document.getElementById('assetDepartmentModal'); if (modal) { modal.classList.remove('open'); } } function closeDeleteAssetDepartmentModal() { const modal = document.getElementById('deleteAssetDepartmentModal'); if (modal) { modal.classList.remove('open'); } } function closeUserModal() { const userModalContainer = document.getElementById('userModalContainer'); if (userModalContainer) { userModalContainer.innerHTML = ''; } } function closeRoleModal() { const roleModalContainer = document.getElementById('roleModalContainer'); if (roleModalContainer) { roleModalContainer.innerHTML = ''; } } function closeUserDetailsModal() { const detailsContainer = document.getElementById('userDetailsModalContainer'); if (detailsContainer) { detailsContainer.innerHTML = ''; } } function closeProfileModal() { const profileContainer = document.getElementById('profileModalContainer'); if (profileContainer) { profileContainer.innerHTML = ''; } } // Initialize app when DOM is ready let app; document.addEventListener('DOMContentLoaded', () => { app = new AccountManager(); });