// VaultSentinel - Account Management Application // Main JavaScript functionality const APP_TIME_ZONE = 'Asia/Ho_Chi_Minh'; const APP_DATE_FORMATTER = new Intl.DateTimeFormat('vi-VN', { timeZone: APP_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit' }); const APP_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('vi-VN', { timeZone: APP_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }); const APP_TIME_PARTS_FORMATTER = new Intl.DateTimeFormat('en-CA', { timeZone: APP_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }); 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.consumables = []; 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.consumablePage = 1; this.consumablePageSize = 10; this.consumableExportPage = 1; this.consumableExportPageSize = 10; this.assetBorrowPage = 1; this.assetBorrowPageSize = 10; this.myBorrowedAssetPage = 1; this.myBorrowedAssetPageSize = 10; this.apiBase = '/api'; this.currentPage = 'dashboard'; this.accountSearchTerm = ''; this.applicationSearchTerm = ''; this.accountServiceFilter = ''; this.userSearchTerm = ''; this.userRoleFilter = ''; this.assetSearchTerm = ''; this.assetStatusFilter = ''; this.consumableSearchTerm = ''; this.consumableMonthFilter = ''; this.consumableStatusFilter = ''; this.consumableExportSearchTerm = ''; this.consumableExportRecipientFilter = ''; this.consumableExportProjectFilter = ''; this.consumableExportDateFilter = ''; this.assetBorrows = []; this.assetBorrowSearchTerm = ''; this.assetBorrowTypeFilter = ''; this.myBorrowedAssetSearchTerm = ''; this.assetBorrowProductSearchTimer = undefined; this.assetBorrowProductItems = []; this.assetBorrowProductQuery = ''; this.assetBorrowProductOffset = 0; this.assetBorrowProductLimit = 40; this.assetBorrowProductHasMore = false; this.assetBorrowProductLoading = false; this.consumableBorrowProductQuery = ''; this.assetDepartments = []; this.assetDepartmentSearchTerm = ''; this.assetProjects = []; this.assetProjectSearchTerm = ''; this.assetExportHistories = []; this.assetDamageHistories = []; this.consumableExportHistories = []; this.consumableBorrowRequests = []; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; this.configureNotifications(); this.initPromise = this.init(); this.pendingAccountAppId = undefined; this.editingAssetBorrowerEntries = []; this.editingAssetStockSnapshot = null; this.pendingBorrowAssetId = undefined; this.editingAssetDepartmentId = undefined; this.pendingDeleteAssetDepartmentId = undefined; this.editingAssetProjectId = undefined; this.pendingDeleteAssetProjectId = undefined; this.assetBorrowRequestType = 'borrow'; this.pendingAssetRequestRejectId = undefined; this.assetBorrowAutoRefreshTimer = undefined; this.pendingAssetRequestDeleteConfirmResolver = undefined; this.pendingBulkAssetDeleteConfirmResolver = undefined; this.pendingAssetDamageId = undefined; this.editingConsumableId = undefined; this.pendingDeleteConsumableId = undefined; this.pendingConsumableExportId = undefined; this.pendingConsumableReturnHistoryId = undefined; this.pendingConsumableRequestRejectId = 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.fetchConsumables(); await this.fetchConsumableBorrowRequests(); await this.fetchAssetBorrows(); await this.fetchAssetDepartments(); await this.fetchAssetProjects(); 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 === 'consumables') { mainContent.innerHTML = this.getConsumablesContent(); this.setupConsumableRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); this.setupConsumablePagerListeners(); } else if (page === 'consumable-exports') { mainContent.innerHTML = this.getConsumableExportsContent(); this.setupConsumableExportHistoryListeners(); this.refreshConsumableExportsPage(); } else if (page === 'asset-borrows') { mainContent.innerHTML = this.getAssetBorrowsContent(); this.setupAssetBorrowListeners(); this.setupAddButtonListeners(); } else if (page === 'my-borrowed-assets') { mainContent.innerHTML = this.getMyBorrowedAssetsContent(); this.setupMyBorrowedAssetsListeners(); } else if (page === 'asset-departments') { mainContent.innerHTML = this.getAssetDepartmentsContent(); this.setupAssetDepartmentListeners(); this.setupAddButtonListeners(); } else if (page === 'asset-projects') { mainContent.innerHTML = this.getAssetProjectsContent(); this.setupAssetProjectListeners(); 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 || ''); this.refreshConsumableExportUserOptions(document.getElementById('consumableExportUserInput')?.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 --' }); } refreshConsumableExportUserOptions(selectedValue = '') { const select = document.getElementById('consumableExportUserInput'); if (!select) { return; } const normalizedSelected = String(selectedValue || '').trim(); const users = (Array.isArray(this.users) ? this.users : []) .map(user => ({ id: Number(user?.UserId ?? user?.userId ?? user?.id), name: this.getUserDisplayName(user) })) .filter(user => Number.isInteger(user.id) && user.id > 0 && user.name) .sort((a, b) => a.name.localeCompare(b.name, 'vi', { sensitivity: 'base' })); select.innerHTML = ''; users.forEach(user => { const option = document.createElement('option'); option.value = String(user.id); option.textContent = user.name; option.dataset.userName = user.name; if (normalizedSelected === String(user.id) || normalizedSelected === user.name) { option.selected = true; } select.appendChild(option); }); } refreshBorrowAssetProjectOptions(selectedValue = '') { const select = document.getElementById('borrowAssetProjectInput'); if (!select) { return; } const normalizedSelected = String(selectedValue || select.value || '').trim(); const projectNames = this.getUniqueAssetProjectNames(); select.innerHTML = ''; const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = '-- Chọn dự án --'; select.appendChild(emptyOption); let hasSelected = false; projectNames.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 = ''; } } 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 --'; } isBorrowAssetRequestMode() { const typeInput = document.getElementById('assetBorrowRequestTypeInput'); return this.normalizeAssetRequestType(typeInput?.value || this.assetBorrowRequestType) === 'borrow'; } isAssetAvailableForBorrow(asset) { if (!asset) { return false; } const endingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance); if (endingBalance !== null) { return endingBalance > 0; } const status = String(asset?.Status || asset?.status || '').trim().toLowerCase(); return status !== 'exported'; } getAssetBorrowProductById(assetIdValue) { const assetId = Number(assetIdValue); if (!Number.isFinite(assetId) || assetId <= 0) { return null; } const asset = this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) || this.assets.find(item => Number(item?.AssetId) === assetId) || null; if (this.isBorrowAssetRequestMode() && !this.isAssetAvailableForBorrow(asset)) { return null; } return asset; } 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 borrowableOnly = this.isBorrowAssetRequestMode(); const appendRows = (rows = [], hasMore = false) => { const rowsArray = Array.isArray(rows) ? rows : []; const source = rowsArray .filter(asset => !borrowableOnly || this.isAssetAvailableForBorrow(asset)); 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 += rowsArray.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 (borrowableOnly && !this.isAssetAvailableForBorrow(asset)) { return false; } 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}&borrowableOnly=${borrowableOnly ? '1' : '0'}`, { 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 = ''; } } getUniqueAssetProjectNames() { const rows = Array.isArray(this.assetProjects) ? this.assetProjects : []; const seen = new Set(); return rows .map(item => String(item?.ProjectName || '').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' })); } refreshAssetProjectOptions(selectedValue = '') { const select = document.getElementById('assetProjectInput'); if (!select) { return; } const normalizedSelected = String(selectedValue || select.value || '').trim(); const projectNames = this.getUniqueAssetProjectNames(); select.innerHTML = ''; const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = '-- Chọn dự án --'; select.appendChild(emptyOption); let hasSelected = false; projectNames.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 = ''; } } refreshConsumableExportProjectOptions(selectedValue = '') { const select = document.getElementById('consumableExportProjectInput'); if (!select) { return; } const normalizedSelected = String(selectedValue || select.value || '').trim(); const projectNames = this.getUniqueAssetProjectNames(); select.innerHTML = ''; const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = '-- Chọn dự án --'; select.appendChild(emptyOption); let hasSelected = false; projectNames.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 = ''; } } setupConsumableExportTargetTypeListeners() { const targetTypeInput = document.getElementById('consumableExportTargetTypeInput'); const recipientInput = document.getElementById('consumableExportUserInput'); const recipientLabel = document.getElementById('consumableExportRecipientLabel'); const projectInput = document.getElementById('consumableExportProjectInput'); const projectField = document.getElementById('consumableExportProjectField'); const quantityLabel = document.getElementById('consumableExportQuantityLabel'); const noteLabel = document.getElementById('consumableExportNoteLabel'); const submitBtn = document.getElementById('consumableExportSubmitBtn'); if (!targetTypeInput || !recipientInput || !projectInput || !projectField) { return; } const syncTargetType = () => { const isProject = String(targetTypeInput.value || '').trim() === 'project'; projectField.classList.toggle('hidden', !isProject); if (quantityLabel) { quantityLabel.textContent = isProject ? 'Số lượng xuất' : 'Số lượng mượn'; } if (noteLabel) { noteLabel.textContent = isProject ? 'Ghi chú xuất' : 'Ghi chú mượn'; } if (submitBtn) { submitBtn.textContent = isProject ? 'Xác nhận xuất dự án' : 'Xác nhận mượn'; } if (isProject) { projectInput.required = true; projectInput.setAttribute('required', 'required'); recipientInput.required = false; recipientInput.removeAttribute('required'); recipientInput.setAttribute('aria-required', 'false'); if (recipientLabel) { recipientLabel.textContent = 'Người nhận (nếu có)'; } } else { projectInput.required = false; projectInput.removeAttribute('required'); recipientInput.required = true; recipientInput.setAttribute('required', 'required'); recipientInput.setAttribute('aria-required', 'true'); if (recipientLabel) { recipientLabel.textContent = 'Người nhận'; } projectInput.value = ''; } }; if (targetTypeInput.dataset.boundChange !== 'true') { targetTypeInput.addEventListener('change', syncTargetType); targetTypeInput.dataset.boundChange = 'true'; } syncTargetType(); } 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 fetchConsumables() { try { const res = await fetch(`${this.apiBase}/consumables`); const data = await res.json(); if (data.success) { this.consumables = Array.isArray(data.data) ? data.data : []; } else { console.error('Load consumables failed:', data.message); } } catch (err) { console.error('Fetch consumables error:', err); } } async fetchConsumableBorrowRequests() { try { const res = await fetch(`${this.apiBase}/consumable-borrows`, { headers: this.getAuthHeaders(false), cache: 'no-store' }); const data = await res.json(); if (res.ok && data.success) { this.consumableBorrowRequests = Array.isArray(data.data) ? data.data : []; this.updateConsumableBorrowRequestBadges(); } else { console.error('Load consumable borrow requests failed:', data.message); } } catch (err) { console.error('Fetch consumable borrow requests 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 fetchAssetProjects() { try { const res = await fetch(`${this.apiBase}/asset-projects`); const data = await res.json(); if (data.success) { this.assetProjects = Array.isArray(data.data) ? data.data : []; this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || ''); this.refreshConsumableExportProjectOptions(document.getElementById('consumableExportProjectInput')?.value || ''); } else { console.error('Load asset projects failed:', data.message); } } catch (err) { console.error('Fetch asset projects error:', err); } } async fetchAssetExportHistories(limit = 300) { try { const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300; const res = await fetch(`${this.apiBase}/asset-export-history?limit=${safeLimit}`, { headers: this.getAuthHeaders(false) }); const data = await res.json(); if (data.success) { this.assetExportHistories = Array.isArray(data.data) ? data.data : []; } else { console.error('Load asset export history failed:', data.message); } } catch (err) { console.error('Fetch asset export history error:', err); } } buildAssetExportHistoryRowsHtml(rows = []) { if (!Array.isArray(rows) || rows.length === 0) { return ` Chưa có dữ liệu lịch sử xuất. `; } return rows.map(item => { const assetLabel = [String(item?.AssetCode || '').trim(), String(item?.AssetName || '').trim()] .filter(Boolean) .join(' - ') || '-'; return ` ${this.formatDateTime(item?.ExportedDate || item?.CreatedDate)} ${this.escapeHtml(assetLabel)} ${Number(item?.ExportQuantity) || 0} ${this.escapeHtml(item?.ProjectName || '-')} ${this.escapeHtml(item?.CustodianName || '-')} ${this.escapeHtml(item?.ExportedByName || '-')} ${this.escapeHtml(item?.ExportNote || '-')} `; }).join(''); } renderAssetExportHistoryModal() { const tbody = document.getElementById('assetExportHistoryTableBody'); if (!tbody) { return; } tbody.innerHTML = this.buildAssetExportHistoryRowsHtml(this.assetExportHistories); } async openAssetExportHistoryModal() { if (!this.ensureAssetManagePermission('xem lich su xuat tai san')) { return; } const modal = document.getElementById('assetExportHistoryModal'); const tbody = document.getElementById('assetExportHistoryTableBody'); if (!modal || !tbody) { this.notifyFailure('Không tìm thấy biểu mẫu lịch sử xuất tài sản.'); return; } tbody.innerHTML = ` Đang tải lịch sử xuất... `; modal.classList.add('open'); await this.fetchAssetExportHistories(); this.renderAssetExportHistoryModal(); } async fetchConsumableExportHistories(limit = 300) { try { const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300; const res = await fetch(`${this.apiBase}/consumable-export-history?limit=${safeLimit}`, { headers: this.getAuthHeaders(false) }); const data = await res.json(); if (data.success) { this.consumableExportHistories = Array.isArray(data.data) ? data.data : []; } else { console.error('Load consumable export history failed:', data.message); } } catch (err) { console.error('Fetch consumable export history error:', err); } } buildConsumableExportHistoryRowsHtml(rows = []) { if (!Array.isArray(rows) || rows.length === 0) { return ` Chưa có dữ liệu lịch sử mượn / xuất vật tư. `; } return rows.map(item => { const consumableLabel = [String(item?.ConsumableCode || '').trim(), String(item?.ConsumableName || '').trim()] .filter(Boolean) .join(' - ') || '-'; const balanceLabel = `${Number(item?.PreviousEndingBalance) || 0} -> ${Number(item?.NextEndingBalance) || 0}`; const exportedQuantity = this.parseNonNegativeInteger(item?.ExportQuantity, 0); const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, 0); const statusMeta = this.getConsumableReturnStatusMeta(item); const returnAction = this.getConsumableReturnActionMeta(item); const returnRequestNotice = this.buildConsumableReturnRequestNoticeHtml(item); const destination = String(item?.ProjectName || '').trim() ? `Dự án: ${String(item.ProjectName).trim()}` : `Người mượn: ${String(item?.RecipientName || '-').trim() || '-'}`; const historyNote = [ item?.ExportNote ? `Xuất/mượn: ${item.ExportNote}` : '', item?.LastReturnedDate ? `Trả gần nhất ${this.formatDateTime(item.LastReturnedDate)}${item?.LastReturnedByName ? ` - ${item.LastReturnedByName}` : ''}${item?.LastReturnNote ? `: ${item.LastReturnNote}` : ''}` : '' ].filter(Boolean).join('\n') || '-'; return ` ${this.formatDateTime(item?.ExportedDate || item?.CreatedDate)} ${this.escapeHtml(consumableLabel)} ${this.escapeHtml(destination)} ${exportedQuantity} ${returnedQuantity} ${remainingQuantity} ${statusMeta.label}${returnRequestNotice} ${this.escapeHtml(item?.ExportedByName || '-')} ${this.escapeHtml(balanceLabel)} ${this.escapeHtml(historyNote)} ${returnAction ? ` ` : '-'} `; }).join(''); } renderConsumableExportHistoryModal() { const tbody = document.getElementById('consumableExportHistoryTableBody'); if (!tbody) { return; } tbody.innerHTML = this.buildConsumableExportHistoryRowsHtml(this.consumableExportHistories); this.setupConsumableReturnActionListeners(); } async openConsumableExportHistoryModal() { const modal = document.getElementById('consumableExportHistoryModal'); const tbody = document.getElementById('consumableExportHistoryTableBody'); if (!modal || !tbody) { this.notifyFailure('Không tìm thấy biểu mẫu lịch sử xuất vật tư.'); return; } tbody.innerHTML = ` Đang tải lịch sử mượn / xuất... `; modal.classList.add('open'); await Promise.all([ this.fetchConsumableExportHistories(), this.fetchConsumableBorrowRequests() ]); this.renderConsumableExportHistoryModal(); } normalizeAssetDamageType(value) { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'disposed' || normalized === 'thanh_ly' || normalized === 'thanh ly' ? 'disposed' : 'damaged'; } getAssetDamageTypeMeta(value) { const type = this.normalizeAssetDamageType(value); if (type === 'disposed') { return { value: 'disposed', label: 'Thanh lý', className: 'bg-slate-100 text-slate-700 border border-slate-200' }; } return { value: 'damaged', label: 'Hỏng', className: 'bg-red-100 text-red-700 border border-red-200' }; } async fetchAssetDamageHistories(limit = 300) { try { const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300; const res = await fetch(`${this.apiBase}/asset-damage-disposal-history?limit=${safeLimit}`, { headers: this.getAuthHeaders(false) }); const data = await res.json(); if (data.success) { this.assetDamageHistories = Array.isArray(data.data) ? data.data : []; } else { console.error('Load asset damage/disposal history failed:', data.message); } } catch (err) { console.error('Fetch asset damage/disposal history error:', err); } } buildAssetDamageHistoryRowsHtml(rows = []) { if (!Array.isArray(rows) || rows.length === 0) { return ` Chưa có dữ liệu tài sản hỏng/thanh lý. `; } return rows.map(item => { const typeMeta = this.getAssetDamageTypeMeta(item?.ActionType); const assetLabel = [String(item?.AssetCode || '').trim(), String(item?.AssetName || '').trim()] .filter(Boolean) .join(' - ') || '-'; const unit = String(item?.Unit || '').trim(); const quantityLabel = `${Number(item?.ActionQuantity) || 0}${unit ? ` ${unit}` : ''}`; return ` ${this.formatDateTime(item?.ActionDate || item?.CreatedDate)} ${typeMeta.label} ${this.escapeHtml(assetLabel)} ${this.escapeHtml(quantityLabel)} ${Number(item?.PreviousQuantity) || 0} -> ${Number(item?.NextQuantity) || 0} ${Number(item?.PreviousEndingBalance) || 0} -> ${Number(item?.NextEndingBalance) || 0} ${Number(item?.PreviousNewQuantity) || 0} -> ${Number(item?.NextNewQuantity) || 0} ${Number(item?.PreviousUsedQuantity) || 0} -> ${Number(item?.NextUsedQuantity) || 0} ${this.escapeHtml(item?.CreatedByName || '-')} ${this.escapeHtml(item?.ActionNote || '-')} `; }).join(''); } renderAssetDamageHistoryModal() { const tbody = document.getElementById('assetDamageHistoryTableBody'); if (!tbody) { return; } tbody.innerHTML = this.buildAssetDamageHistoryRowsHtml(this.assetDamageHistories); } async openAssetDamageHistoryModal() { if (!this.ensureAssetManagePermission('xem danh sach tai san hong/thanh ly')) { return; } const modal = document.getElementById('assetDamageHistoryModal'); const tbody = document.getElementById('assetDamageHistoryTableBody'); if (!modal || !tbody) { this.notifyFailure('Không tìm thấy bảng tài sản hỏng/thanh lý.'); return; } tbody.innerHTML = ` Đang tải dữ liệu tài sản hỏng/thanh lý... `; modal.classList.add('open'); await this.fetchAssetDamageHistories(); this.renderAssetDamageHistoryModal(); } 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', { cache: 'no-store' }); 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 || ''); this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || ''); this.refreshConsumableExportProjectOptions(document.getElementById('consumableExportProjectInput')?.value || ''); this.refreshConsumableExportUserOptions(document.getElementById('consumableExportUserInput')?.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 consumableSearch = document.getElementById('consumableSearch'); const consumableExportSearch = document.getElementById('consumableExportSearch'); const assetBorrowSearch = document.getElementById('assetBorrowSearch'); const myBorrowedAssetSearch = document.getElementById('myBorrowedAssetSearch'); const assetDepartmentSearch = document.getElementById('assetDepartmentSearch'); const assetProjectSearch = document.getElementById('assetProjectSearch'); 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 (consumableSearch && consumableSearch.dataset.focused === 'true') { const pos = consumableSearch.selectionStart || consumableSearch.value.length; consumableSearch.focus(); consumableSearch.setSelectionRange(pos, pos); } if (consumableExportSearch && consumableExportSearch.dataset.focused === 'true') { const pos = consumableExportSearch.selectionStart || consumableExportSearch.value.length; consumableExportSearch.focus(); consumableExportSearch.setSelectionRange(pos, pos); } if (assetBorrowSearch && assetBorrowSearch.dataset.focused === 'true') { const pos = assetBorrowSearch.selectionStart || assetBorrowSearch.value.length; assetBorrowSearch.focus(); assetBorrowSearch.setSelectionRange(pos, pos); } if (myBorrowedAssetSearch && myBorrowedAssetSearch.dataset.focused === 'true') { const pos = myBorrowedAssetSearch.selectionStart || myBorrowedAssetSearch.value.length; myBorrowedAssetSearch.focus(); myBorrowedAssetSearch.setSelectionRange(pos, pos); } if (assetDepartmentSearch && assetDepartmentSearch.dataset.focused === 'true') { const pos = assetDepartmentSearch.selectionStart || assetDepartmentSearch.value.length; assetDepartmentSearch.focus(); assetDepartmentSearch.setSelectionRange(pos, pos); } if (assetProjectSearch && assetProjectSearch.dataset.focused === 'true') { const pos = assetProjectSearch.selectionStart || assetProjectSearch.value.length; assetProjectSearch.focus(); assetProjectSearch.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.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); this.setupAssetStockListeners(); this.setupAssetFormValidationListeners(); } const consumableForm = document.getElementById('consumableForm'); if (consumableForm) { if (!consumableForm.dataset.boundSubmit) { consumableForm.addEventListener('submit', (e) => this.handleConsumableSubmit(e)); consumableForm.dataset.boundSubmit = 'true'; } this.setupConsumableStockListeners(); } const consumableExportForm = document.getElementById('consumableExportForm'); if (consumableExportForm) { if (!consumableExportForm.dataset.boundSubmit) { consumableExportForm.addEventListener('submit', (e) => this.handleConsumableExportSubmit(e)); consumableExportForm.dataset.boundSubmit = 'true'; } this.refreshConsumableExportUserOptions(document.getElementById('consumableExportUserInput')?.value || ''); this.refreshConsumableExportProjectOptions(document.getElementById('consumableExportProjectInput')?.value || ''); this.setupConsumableExportTargetTypeListeners(); } const consumableReturnForm = document.getElementById('consumableReturnForm'); if (consumableReturnForm && !consumableReturnForm.dataset.boundSubmit) { consumableReturnForm.addEventListener('submit', (e) => this.handleConsumableReturnSubmit(e)); consumableReturnForm.dataset.boundSubmit = 'true'; } const consumableBorrowRequestForm = document.getElementById('consumableBorrowRequestForm'); if (consumableBorrowRequestForm && !consumableBorrowRequestForm.dataset.boundSubmit) { consumableBorrowRequestForm.addEventListener('submit', (e) => this.handleConsumableBorrowRequestSubmit(e)); consumableBorrowRequestForm.dataset.boundSubmit = 'true'; } const consumableRequestRejectForm = document.getElementById('consumableRequestRejectForm'); if (consumableRequestRejectForm && !consumableRequestRejectForm.dataset.boundSubmit) { consumableRequestRejectForm.addEventListener('submit', (e) => this.handleConsumableRequestRejectSubmit(e)); consumableRequestRejectForm.dataset.boundSubmit = 'true'; } document.querySelectorAll('.confirm-delete-consumable').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => this.confirmDeleteConsumable()); btn.dataset.boundClick = 'true'; }); const borrowAssetForm = document.getElementById('borrowAssetForm'); if (borrowAssetForm) { if (!borrowAssetForm.dataset.boundSubmit) { borrowAssetForm.addEventListener('submit', (e) => this.handleBorrowAssetSubmit(e)); borrowAssetForm.dataset.boundSubmit = 'true'; } } const assetDamageForm = document.getElementById('assetDamageForm'); if (assetDamageForm) { if (!assetDamageForm.dataset.boundSubmit) { assetDamageForm.addEventListener('submit', (e) => this.handleAssetDamageSubmit(e)); assetDamageForm.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'; } } const confirmAssetRequestDeleteBtn = document.getElementById('confirmAssetRequestDeleteBtn'); if (confirmAssetRequestDeleteBtn && confirmAssetRequestDeleteBtn.dataset.boundClick !== 'true') { confirmAssetRequestDeleteBtn.addEventListener('click', () => this.resolveAssetRequestDeleteConfirm(true)); confirmAssetRequestDeleteBtn.dataset.boundClick = 'true'; } document.querySelectorAll('.cancel-asset-request-delete-confirm').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => this.resolveAssetRequestDeleteConfirm(false)); btn.dataset.boundClick = 'true'; }); const confirmBulkAssetDeleteBtn = document.getElementById('confirmBulkAssetDeleteBtn'); if (confirmBulkAssetDeleteBtn && confirmBulkAssetDeleteBtn.dataset.boundClick !== 'true') { confirmBulkAssetDeleteBtn.addEventListener('click', () => this.resolveBulkAssetDeleteConfirm(true)); confirmBulkAssetDeleteBtn.dataset.boundClick = 'true'; } document.querySelectorAll('.cancel-bulk-asset-delete-confirm').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => this.resolveBulkAssetDeleteConfirm(false)); btn.dataset.boundClick = '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'; }); const assetProjectForm = document.getElementById('assetProjectForm'); if (assetProjectForm) { if (!assetProjectForm.dataset.boundSubmit) { assetProjectForm.addEventListener('submit', (e) => this.handleAssetProjectSubmit(e)); assetProjectForm.dataset.boundSubmit = 'true'; } } document.querySelectorAll('.confirm-delete-asset-project').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => this.confirmDeleteAssetProject()); 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.NewQuantity, asset.UsedQuantity, 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)); }); } getConsumableMonthOptions() { const months = new Set(); (Array.isArray(this.consumables) ? this.consumables : []).forEach(item => { const value = String(item?.RequestMonth || '').trim(); if (value) { months.add(value); } }); return [...months].sort((a, b) => a.localeCompare(b, 'vi')); } getFilteredConsumables() { const monthFilter = String(this.consumableMonthFilter || '').trim().toLowerCase(); const statusFilter = String(this.consumableStatusFilter || '').trim().toLowerCase(); const search = String(this.consumableSearchTerm || '').trim().toLowerCase(); const rows = Array.isArray(this.consumables) ? this.consumables : []; return rows.filter(item => { const matchesMonth = !monthFilter || String(item?.RequestMonth || '').trim().toLowerCase() === monthFilter; if (!matchesMonth) { return false; } const statusMeta = this.getConsumableStockStatusMeta(item); if (statusFilter && statusMeta.key !== statusFilter) { return false; } if (!search) { return true; } const haystack = [ item.ConsumableCode, item.ConsumableName, item.Model, item.Unit, item.RequestMonth, item.OpeningBalance, item.ImportInPeriod, item.ExportInPeriod, item.ExportedSummary, item.RecipientSummary, item.ProjectSummary, item.EndingBalance, item.ExportReason ].map(value => String(value || '').toLowerCase()); return haystack.some(value => value.includes(search)); }); } getConsumableExportRecipientOptions() { const recipients = new Set(); (Array.isArray(this.consumableExportHistories) ? this.consumableExportHistories : []).forEach(item => { const value = String(item?.RecipientName || '').trim(); if (value) { recipients.add(value); } }); return [...recipients].sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' })); } getConsumableExportProjectOptions() { const projects = new Set(); (Array.isArray(this.consumableExportHistories) ? this.consumableExportHistories : []).forEach(item => { const value = String(item?.ProjectName || '').trim(); if (value) { projects.add(value); } }); return [...projects].sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' })); } getFilteredConsumableExportHistories() { const search = String(this.consumableExportSearchTerm || '').trim().toLowerCase(); const recipientFilter = String(this.consumableExportRecipientFilter || '').trim().toLowerCase(); const projectFilter = String(this.consumableExportProjectFilter || '').trim().toLowerCase(); const dateFilter = String(this.consumableExportDateFilter || '').trim(); const rows = Array.isArray(this.consumableExportHistories) ? this.consumableExportHistories : []; return rows.filter(item => { const recipientName = String(item?.RecipientName || '').trim(); if (recipientFilter && recipientName.toLowerCase() !== recipientFilter) { return false; } const projectName = String(item?.ProjectName || '').trim(); if (projectFilter && projectName.toLowerCase() !== projectFilter) { return false; } const exportedDateInput = this.toDateInputValue(item?.ExportedDate || item?.CreatedDate); if (dateFilter && exportedDateInput !== dateFilter) { return false; } if (!search) { return true; } const haystack = [ item.ExportHistoryId, item.ConsumableCode, item.ConsumableName, item.Unit, item.ExportQuantity, item.ReturnedQuantity, item.RemainingQuantity, this.getConsumableReturnStatusMeta(item).label, item.RecipientName, item.ProjectName, item.ExportedByName, item.ExportNote, item.PreviousEndingBalance, item.NextEndingBalance, item.ExportedDate, this.formatDateTime(item?.ExportedDate || item?.CreatedDate) ].map(value => String(value || '').toLowerCase()); return haystack.some(value => value.includes(search)); }); } getConsumableStockStatusMeta(consumable = {}) { const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0); if (endingBalance <= 0) { return { key: 'out_of_stock', label: 'Hết tồn', className: 'bg-red-50 text-red-700 border border-red-100' }; } return { key: 'in_stock', label: 'Còn tồn', className: 'bg-emerald-50 text-emerald-700 border border-emerald-100' }; } getConsumableReturnStatusMeta(history = {}) { const exportedQuantity = this.parseNonNegativeInteger(history?.ExportQuantity, 0); const returnedQuantity = this.parseNonNegativeInteger(history?.ReturnedQuantity, 0); const isProject = String(history?.TargetType || '').trim() === 'project' || Boolean(String(history?.ProjectName || '').trim()); if (exportedQuantity > 0 && returnedQuantity >= exportedQuantity) { return { key: 'returned', label: 'Đã trả kho', className: 'bg-emerald-50 text-emerald-700 border border-emerald-100' }; } if (returnedQuantity > 0) { return { key: 'partial', label: 'Trả một phần', className: 'bg-amber-50 text-amber-700 border border-amber-100' }; } return { key: 'active', label: isProject ? 'Đang ở dự án' : 'Đang mượn', className: 'bg-blue-50 text-blue-700 border border-blue-100' }; } getConsumableReturnActionMeta(history = {}) { const isProject = String(history?.TargetType || '').trim() === 'project' || Boolean(String(history?.ProjectName || '').trim()); const remainingQuantity = this.parseNonNegativeInteger(history?.RemainingQuantity, 0); const availableQuantity = this.parseNonNegativeInteger( history?.AvailableReturnRequestQuantity, remainingQuantity ); if (isProject || remainingQuantity <= 0 || availableQuantity <= 0) { return null; } if (this.canCurrentUserManageAssets()) { return { mode: 'direct', label: 'Trả kho', title: 'Ghi nhận trả vật tư về kho', availableQuantity }; } if (history?.IsReturnOwner === true || Number(history?.IsReturnOwner) === 1) { return { mode: 'request', label: 'Tạo đơn trả', title: 'Tạo đơn trả vật tư đang nhận', availableQuantity }; } return null; } getConsumableBorrowRequestStatusMeta(value) { const status = String(value || '').trim().toLowerCase(); if (status === 'approved') { return { key: 'approved', label: 'Đã duyệt / đã xuất', className: 'bg-emerald-50 text-emerald-700 border border-emerald-100' }; } if (status === 'rejected') { return { key: 'rejected', label: 'Đã từ chối', className: 'bg-red-50 text-red-700 border border-red-100' }; } return { key: 'pending', label: 'Chờ duyệt', className: 'bg-amber-50 text-amber-700 border border-amber-100' }; } getConsumableRequestTypeMeta(value) { const requestType = String(value || 'borrow').trim().toLowerCase() === 'return' ? 'return' : 'borrow'; return requestType === 'return' ? { key: 'return', label: 'Trả vật tư', className: 'bg-emerald-50 text-emerald-700 border border-emerald-100' } : { key: 'borrow', label: 'Mượn vật tư', className: 'bg-blue-50 text-blue-700 border border-blue-100' }; } getPendingConsumableBorrowRequestCount() { return (Array.isArray(this.consumableBorrowRequests) ? this.consumableBorrowRequests : []) .filter(item => this.getConsumableBorrowRequestStatusMeta(item?.RequestStatus).key === 'pending') .length; } getRejectedConsumableReturnRequests() { const seenExportIds = new Set(); return (Array.isArray(this.consumableBorrowRequests) ? this.consumableBorrowRequests : []) .filter(item => { if (this.getConsumableRequestTypeMeta(item?.RequestType).key !== 'return') { return false; } const exportHistoryId = Number(item?.ExportHistoryId); const key = Number.isInteger(exportHistoryId) && exportHistoryId > 0 ? `export:${exportHistoryId}` : `request:${Number(item?.BorrowRequestId) || 0}`; if (seenExportIds.has(key)) { return false; } seenExportIds.add(key); return this.getConsumableBorrowRequestStatusMeta(item?.RequestStatus).key === 'rejected'; }); } getLatestConsumableReturnRequest(exportHistoryId) { const targetId = Number(exportHistoryId); if (!Number.isInteger(targetId) || targetId <= 0) { return null; } return (Array.isArray(this.consumableBorrowRequests) ? this.consumableBorrowRequests : []) .find(item => Number(item?.ExportHistoryId) === targetId && this.getConsumableRequestTypeMeta(item?.RequestType).key === 'return') || null; } buildConsumableReturnRequestNoticeHtml(history = {}) { const latestRequest = this.getLatestConsumableReturnRequest(history?.ExportHistoryId); if (!latestRequest || this.getConsumableBorrowRequestStatusMeta(latestRequest?.RequestStatus).key !== 'rejected') { return ''; } const requestId = Number(latestRequest?.BorrowRequestId) || '-'; const rejectReason = String(latestRequest?.RejectReason || '').trim() || 'Chưa ghi nhận lý do từ chối'; return `
Đơn trả #${requestId} đã bị từ chối
Lý do: ${this.escapeHtml(rejectReason)}
`; } updateConsumableBorrowRequestBadges() { const pendingCount = this.getPendingConsumableBorrowRequestCount(); const count = this.canCurrentUserManageAssets() ? pendingCount : pendingCount + this.getRejectedConsumableReturnRequests().length; const badge = document.getElementById('consumableBorrowRequestsCountBadge'); if (badge) { badge.textContent = count > 99 ? '99+' : String(count); badge.classList.toggle('hidden', count <= 0); } } normalizeNameForMatching(value) { const normalized = String(value || '').trim().toLowerCase(); if (!normalized) { return ''; } return normalized .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' '); } getCurrentUserBorrowerNameKeys() { const candidates = [ this.getCurrentUserDisplayName(), this.currentUser?.FullName, this.currentUser?.fullname, this.currentUser?.user?.FullName, this.currentUser?.user?.fullname, this.currentUser?.Username, this.currentUser?.username, this.currentUser?.user?.Username, this.currentUser?.user?.username ]; const keys = new Set(); candidates.forEach(item => { const key = this.normalizeNameForMatching(item); if (key) { keys.add(key); } }); return [...keys]; } getCurrentUserBorrowedAssets() { const borrowerKeys = new Set(this.getCurrentUserBorrowerNameKeys()); if (!borrowerKeys.size) { return []; } return this.assets .map(asset => { const borrowerEntries = this.parseBorrowerEntries(asset?.Borrower); const matchedEntries = borrowerEntries.filter(entry => { const key = this.normalizeNameForMatching(entry?.name); return key && borrowerKeys.has(key); }); const borrowedQuantity = matchedEntries.reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); if (borrowedQuantity <= 0) { return null; } return { ...asset, BorrowedQuantityByCurrentUser: borrowedQuantity, BorrowedNamesByCurrentUser: matchedEntries .map(entry => this.formatBorrowerDisplay(entry?.name, entry?.quantity)) .filter(Boolean) .join('; ') }; }) .filter(Boolean); } getFilteredMyBorrowedAssets() { const search = String(this.myBorrowedAssetSearchTerm || '').toLowerCase(); const rows = this.getCurrentUserBorrowedAssets(); if (!search) { return rows; } return rows.filter(item => { const haystack = [ item.AssetCode, item.AssetName, item.Model, item.SerialNumber, item.Project, item.Department, item.Location, item.Unit, item.Status, item.BorrowedQuantityByCurrentUser, item.BorrowedNamesByCurrentUser, item.Notes ].map(value => String(value || '').toLowerCase()); return haystack.some(value => value.includes(search)); }); } getFilteredAssetBorrows() { const search = String(this.assetBorrowSearchTerm || '').toLowerCase(); const typeFilter = String(this.assetBorrowTypeFilter || '').trim().toLowerCase(); const rows = Array.isArray(this.assetBorrows) ? this.assetBorrows : []; return rows.filter(item => { const requestType = this.normalizeAssetRequestType(item.RequestType); const requestStatus = this.normalizeAssetRequestStatus(item.RequestStatus); const relatedReturnCount = this.parseNonNegativeInteger(item?.RelatedReturnCount, 0); if (requestType === 'borrow' && (requestStatus === 'returned' || relatedReturnCount > 0 || this.hasActiveReturnForBorrowRequest(item, rows))) { return false; } const matchesType = !typeFilter || requestType === typeFilter; if (!matchesType) { return false; } if (!search) { return true; } const haystack = [ item.BorrowerName, item.AssetCode, item.AssetName, this.getAssetRequestTypeMeta(item.RequestType).label, this.getAssetRequestStatusMeta(item.RequestStatus, item).label, item.Unit, item.BorrowQuantity, item.ReturnedQuantity, item.RemainingQuantity, item.BorrowDate, item.RequestNote, item.RejectReason ].map(value => String(value || '').toLowerCase()); return haystack.some(value => value.includes(search)); }); } hasActiveReturnForBorrowRequest(borrowRequest, rows = []) { const assetId = Number(borrowRequest?.AssetId); const borrowerName = String(borrowRequest?.BorrowerName || '').trim().toLowerCase(); const createdBy = Number(borrowRequest?.CreatedBy); const borrowDate = new Date(borrowRequest?.BorrowDate || 0); const borrowTime = Number.isNaN(borrowDate.getTime()) ? null : borrowDate.getTime(); if (!Number.isFinite(assetId) || assetId <= 0) { return false; } return (Array.isArray(rows) ? rows : []).some(candidate => { if (!candidate || candidate === borrowRequest) { return false; } if (this.normalizeAssetRequestType(candidate?.RequestType) !== 'return') { return false; } const status = this.normalizeAssetRequestStatus(candidate?.RequestStatus); if (status !== 'pending' && status !== 'approved') { return false; } if (Number(candidate?.AssetId) !== assetId) { return false; } const candidateCreatedBy = Number(candidate?.CreatedBy); const sameCreator = Number.isFinite(createdBy) && Number.isFinite(candidateCreatedBy) && createdBy === candidateCreatedBy; const sameBorrower = borrowerName && String(candidate?.BorrowerName || '').trim().toLowerCase() === borrowerName; if (!sameCreator && !sameBorrower) { return false; } if (borrowTime === null) { return true; } const returnDate = new Date(candidate?.BorrowDate || 0); const returnTime = Number.isNaN(returnDate.getTime()) ? null : returnDate.getTime(); return returnTime === null || returnTime >= borrowTime; }); } 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
${APP_DATE_FORMATTER.format(new Date())}
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 === 'exported') { return { label: 'Đã xuất', className: 'bg-rose-100 text-rose-700' }; } if (normalized === 'in_stock') { return { label: 'Trong kho', className: 'bg-emerald-100 text-emerald-700' }; } return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' }; } getAppTimeParts(value = new Date()) { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return null; return APP_TIME_PARTS_FORMATTER.formatToParts(date).reduce((parts, part) => { if (part.type !== 'literal') { parts[part.type] = part.value; } return parts; }, {}); } formatTimestampForCode(value = new Date(), includeMilliseconds = false) { const date = value instanceof Date ? value : new Date(value); const parts = this.getAppTimeParts(date); if (!parts) return ''; const timestamp = [ parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second ].join(''); return includeMilliseconds ? `${timestamp}${String(date.getMilliseconds()).padStart(3, '0')}` : timestamp; } formatDateOnly(value) { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return APP_DATE_FORMATTER.format(date); } toDateInputValue(value) { if (!value) return ''; const parts = this.getAppTimeParts(value); if (!parts) return ''; return `${parts.year}-${parts.month}-${parts.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; } parseOptionalNonNegativeInteger(value) { if (value === null || value === undefined || String(value).trim() === '') { return null; } const parsed = Number.parseInt(String(value).replace(/,/g, '').trim(), 10); if (!Number.isFinite(parsed) || parsed < 0) { return null; } 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 === 'returned') return 'returned'; 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, item = null) { const status = this.normalizeAssetRequestStatus(value); const requestType = item ? this.normalizeAssetRequestType(item?.RequestType) : ''; const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); if (status === 'returned' || (requestType === 'borrow' && status === 'approved' && borrowQuantity > 0 && returnedQuantity >= borrowQuantity)) { return { value: 'returned', label: 'Đã trả', className: 'bg-slate-100 text-slate-700 border border-slate-200' }; } if (status === 'approved') { return { value: 'approved', label: requestType === 'borrow' ? 'Đang mượn' : (requestType === 'return' ? 'Đã trả' : '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 storedExportInPeriod = this.parseOptionalNonNegativeInteger(asset?.ExportInPeriod ?? asset?.exportInPeriod); const storedEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance); const borrowerEntries = Array.isArray(borrowerEntriesOverride) ? this.parseBorrowerEntries(borrowerEntriesOverride) : this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower); const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); // Prefer stored stock numbers from DB/file to avoid overriding imported balances. const exportInPeriod = storedExportInPeriod !== null ? storedExportInPeriod : borrowerExportInPeriod; const endingBalance = storedEndingBalance !== null ? storedEndingBalance : Math.max(quantity + importInPeriod - exportInPeriod, 0); return { quantity, importInPeriod, exportInPeriod, endingBalance, borrowerEntries, borrowerExportInPeriod }; } computeAssetStatusCode(endingBalance, borrowingQuantity) { const ending = this.parseNonNegativeInteger(endingBalance, 0); const borrowing = this.parseNonNegativeInteger(borrowingQuantity, 0); if (ending <= 0) { return 'exported'; } if (borrowing > 0) { return 'in_use'; } return 'in_stock'; } normalizeAssetStockSplit(endingBalance, newQuantityValue, usedQuantityValue) { const ending = this.parseNonNegativeInteger(endingBalance, 0); let newQuantity = this.parseNonNegativeInteger(newQuantityValue, ending); let usedQuantity = this.parseNonNegativeInteger(usedQuantityValue, 0); const total = newQuantity + usedQuantity; if (total < ending) { newQuantity += (ending - total); } else if (total > ending) { let overflow = total - ending; const takeFromNew = Math.min(newQuantity, overflow); newQuantity -= takeFromNew; overflow -= takeFromNew; if (overflow > 0) { usedQuantity = Math.max(usedQuantity - overflow, 0); } } return { newQuantity: Math.max(newQuantity, 0), usedQuantity: Math.max(usedQuantity, 0) }; } normalizeAssetComputedFields(asset) { if (!asset || typeof asset !== 'object') { return asset; } const metrics = this.buildAssetQuantityMetrics(asset); const stockSplit = this.normalizeAssetStockSplit( metrics.endingBalance, asset?.NewQuantity ?? asset?.newQuantity, asset?.UsedQuantity ?? asset?.usedQuantity ); const status = this.computeAssetStatusCode(metrics.endingBalance, metrics.exportInPeriod); return { ...asset, Quantity: metrics.quantity, ImportInPeriod: metrics.importInPeriod, ExportInPeriod: metrics.exportInPeriod, EndingBalance: metrics.endingBalance, NewQuantity: stockSplit.newQuantity, UsedQuantity: stockSplit.usedQuantity, Status: status, 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'); const statusInput = document.getElementById('assetStatusInput'); if (!quantityInput || !importInput) { return; } const quantity = this.parseNonNegativeInteger(quantityInput.value, 0); const importInPeriod = this.parseNonNegativeInteger(importInput.value, 0); const exportInPeriod = this.parseNonNegativeInteger(exportInput?.value ?? 0, 0); const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); if (exportInput) { exportInput.value = String(exportInPeriod); } if (endingInput) { endingInput.value = String(endingBalance); } if (statusInput) { const statusCode = this.computeAssetStatusCode(endingBalance, exportInPeriod); const statusMeta = this.getAssetStatusMeta(statusCode); statusInput.value = statusMeta.label; statusInput.dataset.statusCode = statusCode; } } 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'); } } getFilteredAssetProjects() { const search = String(this.assetProjectSearchTerm || '').toLowerCase(); const source = Array.isArray(this.assetProjects) ? this.assetProjects : []; return source.filter(item => { if (!search) { return true; } const name = String(item?.ProjectName || '').toLowerCase(); return name.includes(search); }); } buildAssetProjectsRowsHtml(projects = []) { const canManageAssets = this.canCurrentUserManageAssets(); if (!projects.length) { return ` Chưa có dự án nào. `; } return projects.map((item, index) => { const projectId = Number(item?.ProjectId); const assetCount = Number(item?.AssetCount) || 0; const projectName = this.escapeHtml(item?.ProjectName || '-'); return ` ${index + 1} ${projectName} ${assetCount}
`; }).join(''); } getAssetProjectsContent() { const filteredProjects = this.getFilteredAssetProjects(); const canManageAssets = this.canCurrentUserManageAssets(); return `
Tìm kiếm
${this.buildAssetProjectsRowsHtml(filteredProjects)}
STT Dự án Sd tài sản Thao tác
Tổng dự án: ${filteredProjects.length}
`; } renderAssetProjectsTableBody() { const tbody = document.querySelector('.asset-projects-table-body'); if (!tbody) { return; } const filteredProjects = this.getFilteredAssetProjects(); tbody.innerHTML = this.buildAssetProjectsRowsHtml(filteredProjects); const countElement = document.getElementById('assetProjectCount'); if (countElement) { countElement.textContent = String(filteredProjects.length); } this.setupAssetProjectActionListeners(); } setupAssetProjectActionListeners() { document.querySelectorAll('.edit-asset-project').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const projectId = Number(btn.dataset.projectId); if (!Number.isFinite(projectId)) { return; } this.handleUpdateAssetProject(projectId); }); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.delete-asset-project').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const projectId = Number(btn.dataset.projectId); if (!Number.isFinite(projectId)) { return; } this.handleDeleteAssetProject(projectId); }); btn.dataset.boundClick = 'true'; }); } setupAssetProjectListeners() { const searchInput = document.getElementById('assetProjectSearch'); if (searchInput && searchInput.dataset.boundInput !== 'true') { searchInput.addEventListener('input', (event) => { this.assetProjectSearchTerm = String(event.target.value || '').trim(); this.renderAssetProjectsTableBody(); }); searchInput.addEventListener('focus', () => { searchInput.dataset.focused = 'true'; }); searchInput.addEventListener('blur', () => { searchInput.dataset.focused = 'false'; }); searchInput.dataset.boundInput = 'true'; } this.setupAssetProjectActionListeners(); } async refreshAssetProjectsUI() { await this.fetchAssetProjects(); if (this.currentPage === 'asset-projects') { this.renderAssetProjectsTableBody(); } } getAssetProjectById(projectId) { return this.assetProjects.find(item => Number(item?.ProjectId) === Number(projectId)) || null; } openAssetProjectModal(project = null) { const modal = document.getElementById('assetProjectModal'); const titleNode = document.getElementById('assetProjectModalTitle'); const nameInput = document.getElementById('assetProjectNameInput'); if (!modal || !nameInput) { this.notifyFailure('Không mở được biểu mẫu dự án'); return; } const editing = project && Number.isFinite(Number(project.ProjectId)); this.editingAssetProjectId = editing ? Number(project.ProjectId) : undefined; if (titleNode) { titleNode.textContent = editing ? 'Sửa dự án' : 'Thêm dự án'; } nameInput.value = editing ? String(project.ProjectName || '') : ''; modal.classList.add('open'); nameInput.focus(); nameInput.select(); } openDeleteAssetProjectModal(project) { const modal = document.getElementById('deleteAssetProjectModal'); const nameNode = document.getElementById('deleteAssetProjectName'); if (!modal) { this.notifyFailure('Không mở được hộp thoại xóa dự án'); return; } this.pendingDeleteAssetProjectId = Number(project?.ProjectId); if (nameNode) { nameNode.textContent = String(project?.ProjectName || '-'); } modal.classList.add('open'); } async handleCreateAssetProject() { if (!this.ensureAssetManagePermission('thêm dự án')) { return; } this.openAssetProjectModal(null); } async handleAssetProjectSubmit(event) { event.preventDefault(); if (!this.ensureAssetManagePermission('thêm hoặc sửa dự án')) { return; } const nameInput = document.getElementById('assetProjectNameInput'); const projectName = String(nameInput?.value || '').trim(); if (!projectName) { this.notifyWarning('Tên dự án là bắt buộc'); return; } const isEdit = Number.isFinite(Number(this.editingAssetProjectId)); const endpoint = isEdit ? `${this.apiBase}/asset-projects/${this.editingAssetProjectId}` : `${this.apiBase}/asset-projects`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(endpoint, { method, headers: this.getAuthHeaders(true), body: JSON.stringify({ projectName }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Lưu dự án thất bại'); return; } this.editingAssetProjectId = undefined; closeAssetProjectModal(); this.notifySuccess(isEdit ? 'Cập nhật dự án thành công' : 'Thêm dự án thành công'); await this.refreshAssetProjectsUI(); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Lưu dự án thất bại'); } } async handleUpdateAssetProject(projectId) { if (!this.ensureAssetManagePermission('sửa dự án')) { return; } const targetProject = this.getAssetProjectById(projectId); if (!targetProject) { this.notifyWarning('Không tìm thấy dự án'); return; } this.openAssetProjectModal(targetProject); } async handleDeleteAssetProject(projectId) { if (!this.ensureAssetManagePermission('xóa dự án')) { return; } const targetProject = this.getAssetProjectById(projectId); if (!targetProject) { this.notifyWarning('Không tìm thấy dự án'); return; } this.openDeleteAssetProjectModal(targetProject); } async confirmDeleteAssetProject() { if (!this.ensureAssetManagePermission('xóa dự án')) { return; } if (!Number.isFinite(Number(this.pendingDeleteAssetProjectId))) { return; } try { const response = await fetch(`${this.apiBase}/asset-projects/${this.pendingDeleteAssetProjectId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xóa dự án thất bại'); return; } this.pendingDeleteAssetProjectId = undefined; closeDeleteAssetProjectModal(); this.notifySuccess('Xóa dự án thành công'); await this.refreshAssetProjectsUI(); await this.refreshAssetsUI(); } catch (err) { console.error(err); this.notifyFailure('Xóa dự án 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, item); const note = String(item?.RequestNote || '').trim(); const rejectReason = String(item?.RejectReason || '').trim(); const canCancel = this.canCurrentUserCancelAssetRequest(item); const requestId = Number(item?.BorrowId) || 0; const requestType = this.normalizeAssetRequestType(item?.RequestType); const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0)); const canCreateReturn = this.canCreateAssetReturnRequestFromBorrow(item); const returnProgressHtml = requestType === 'borrow' && returnedQuantity > 0 && statusMeta.value !== 'returned' ? `
Đã trả ${returnedQuantity}/${borrowQuantity}, còn ${remainingQuantity}
` : ''; const detailActionHtml = ` `; const returnActionHtml = canCreateReturn ? `` : ''; const cancelActionHtml = canCancel ? `` : ''; const actionHtml = returnActionHtml || cancelActionHtml ? `
${returnActionHtml}${cancelActionHtml}
` : `-`; return ` ${rowNumber} ${this.escapeHtml(item.BorrowerName || '-')}
${this.escapeHtml(assetName)}
${assetCode} ${typeMeta.label} ${statusMeta.label} ${returnProgressHtml} ${this.escapeHtml(item.Unit || '-')} ${Number(item.BorrowQuantity) || 0} ${this.formatDateOnly(item.BorrowDate)} ${this.escapeHtml(note || '-')} ${this.escapeHtml(rejectReason || '-')} ${detailActionHtml} ${actionHtml} `; } canCreateAssetReturnRequestFromBorrow(item) { if (this.normalizeAssetRequestType(item?.RequestType) !== 'borrow') { return false; } if (this.normalizeAssetRequestStatus(item?.RequestStatus) !== 'approved') { return false; } const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0)); const relatedReturnCount = this.parseNonNegativeInteger(item?.RelatedReturnCount, 0); return borrowQuantity > 0 && remainingQuantity > 0 && relatedReturnCount <= 0; } canCurrentUserCancelAssetRequest(item) { const status = this.normalizeAssetRequestStatus(item?.RequestStatus); if (status !== 'pending') { return false; } if (this.canCurrentUserManageAssets()) { return true; } const currentUserId = Number(this.getUserId()); const createdBy = Number(item?.CreatedBy); return Number.isFinite(currentUserId) && currentUserId > 0 && Number.isFinite(createdBy) && createdBy === currentUserId; } 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}
`; } renderMyBorrowedAssetsPager(pageInfo) { const pager = document.getElementById('myBorrowedAssetsPager'); if (!pager) { return; } pager.innerHTML = ` Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } getMyBorrowedAssetsContent() { const filteredAssets = this.getFilteredMyBorrowedAssets(); const pageInfo = this.getPaged(filteredAssets, this.myBorrowedAssetPage, this.myBorrowedAssetPageSize); this.myBorrowedAssetPage = pageInfo.current; return `
Tìm kiếm
${pageInfo.data.length > 0 ? pageInfo.data.map((asset, index) => { const statusMeta = this.getAssetStatusMeta(asset.Status); return ` `; }).join('') : ` `}
STT Mã tài sản Tên tài sản Số lượng đang mượn Đơn vị Dự án Vị trí Trạng thái Ghi chú
${pageInfo.start + index} ${this.escapeHtml(asset.AssetCode || '-')} ${this.escapeHtml(asset.AssetName || '-')} ${Number(asset.BorrowedQuantityByCurrentUser) || 0} ${this.escapeHtml(asset.Unit || '-')} ${this.escapeHtml(asset.Project || '-')} ${this.escapeHtml(asset.Location || '-')} ${statusMeta.label} ${this.escapeHtml(asset.Notes || '-')}
Hiện tại bạn chưa mượn tài sản nào.
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } renderMyBorrowedAssetsTableBody() { const tbody = document.querySelector('.my-borrowed-assets-table-body'); if (!tbody) { return; } const pageInfo = this.getPaged(this.getFilteredMyBorrowedAssets(), this.myBorrowedAssetPage, this.myBorrowedAssetPageSize); this.myBorrowedAssetPage = pageInfo.current; if (!pageInfo.data.length) { tbody.innerHTML = ` Hiện tại bạn chưa mượn tài sản nào. `; } else { tbody.innerHTML = pageInfo.data.map((asset, index) => { const statusMeta = this.getAssetStatusMeta(asset.Status); return ` ${pageInfo.start + index} ${this.escapeHtml(asset.AssetCode || '-')} ${this.escapeHtml(asset.AssetName || '-')} ${Number(asset.BorrowedQuantityByCurrentUser) || 0} ${this.escapeHtml(asset.Unit || '-')} ${this.escapeHtml(asset.Project || '-')} ${this.escapeHtml(asset.Location || '-')} ${statusMeta.label} ${this.escapeHtml(asset.Notes || '-')} `; }).join(''); } this.renderMyBorrowedAssetsPager(pageInfo); this.setupMyBorrowedAssetsPagerListeners(); } setupMyBorrowedAssetsPagerListeners() { document.querySelectorAll('.my-borrowed-asset-page-btn').forEach(btn => { btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) { return; } this.myBorrowedAssetPage = targetPage; this.renderMyBorrowedAssetsTableBody(); }); }); } setupMyBorrowedAssetsListeners() { const searchInput = document.getElementById('myBorrowedAssetSearch'); if (searchInput && searchInput.dataset.boundInput !== 'true') { searchInput.addEventListener('input', event => { this.myBorrowedAssetSearchTerm = String(event.target.value || '').trim(); this.myBorrowedAssetPage = 1; this.renderMyBorrowedAssetsTableBody(); }); searchInput.addEventListener('focus', () => { searchInput.dataset.focused = 'true'; }); searchInput.addEventListener('blur', () => { searchInput.dataset.focused = 'false'; }); searchInput.dataset.boundInput = 'true'; } this.setupMyBorrowedAssetsPagerListeners(); } 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 `
Danh mục
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 Chi tiết Hành động
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(); } async openAssetBorrowDetailsModal(requestId) { try { const response = await fetch(`${this.apiBase}/asset-borrows/${requestId}/history`, { headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Không tải được chi tiết đơn.'); return; } this.showAssetBorrowDetailsModal(data.data || {}); } catch (err) { console.error(err); this.notifyFailure('Không tải được chi tiết đơn.'); } } buildAssetBorrowDetailCardHtml(item) { const typeMeta = this.getAssetRequestTypeMeta(item?.RequestType); const statusMeta = this.getAssetRequestStatusMeta(item?.RequestStatus, item); const requestType = this.normalizeAssetRequestType(item?.RequestType); const dateLabel = requestType === 'return' ? 'Ngày trả' : 'Ngày mượn'; const assetName = `${item?.AssetCode ? `${item.AssetCode} - ` : ''}${item?.AssetName || '-'}`; const note = String(item?.RequestNote || '').trim(); const rejectReason = String(item?.RejectReason || '').trim(); const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0)); const returnSummary = requestType === 'borrow' && returnedQuantity > 0 ? `
Đã trả: ${returnedQuantity}/${borrowQuantity}${remainingQuantity > 0 ? `, còn ${remainingQuantity}` : ''}
` : ''; return `
${typeMeta.label} ${statusMeta.label} #${Number(item?.BorrowId) || '-'}
Người tạo: ${this.escapeHtml(item?.BorrowerName || '-')}
${dateLabel}: ${this.formatDateOnly(item?.BorrowDate)}
Tài sản: ${this.escapeHtml(assetName)}
Số lượng: ${Number(item?.BorrowQuantity) || 0}
Đơn vị: ${this.escapeHtml(item?.Unit || '-')}
${returnSummary}
Ngày xử lý: ${this.formatDateTime(item?.ProcessedDate)}
Người xử lý: ${this.escapeHtml(item?.ProcessedByName || '-')}
Ghi chú: ${this.escapeHtml(note || '-')}
Lý do: ${this.escapeHtml(rejectReason || '-')}
`; } showAssetBorrowDetailsModal(history) { const selected = history?.request || {}; const borrowRequests = Array.isArray(history?.borrowRequests) ? history.borrowRequests : []; const returnRequests = Array.isArray(history?.returnRequests) ? history.returnRequests : []; const selectedTypeMeta = this.getAssetRequestTypeMeta(selected?.RequestType); const selectedStatusMeta = this.getAssetRequestStatusMeta(selected?.RequestStatus, selected); const assetName = `${selected?.AssetCode ? `${selected.AssetCode} - ` : ''}${selected?.AssetName || '-'}`; const renderList = (items, emptyText) => items.length ? items.map(item => this.buildAssetBorrowDetailCardHtml(item)).join('') : `
${emptyText}
`; let container = document.getElementById('assetBorrowDetailsModalContainer'); if (!container) { container = document.createElement('div'); container.id = 'assetBorrowDetailsModalContainer'; document.body.appendChild(container); } container.innerHTML = ` `; const modal = document.getElementById('assetBorrowDetailsModal'); if (modal) { modal.addEventListener('click', event => { if (event.target === modal) { closeAssetBorrowDetailsModal(); } }); } } 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 typeFilter = document.getElementById('assetBorrowTypeFilter'); if (typeFilter && typeFilter.dataset.boundChange !== 'true') { typeFilter.addEventListener('change', event => { const nextValue = String(event.target.value || '').trim().toLowerCase(); this.assetBorrowTypeFilter = (nextValue === 'borrow' || nextValue === 'return') ? nextValue : ''; this.assetBorrowPage = 1; this.renderAssetBorrowsTableBody(); }); typeFilter.dataset.boundChange = 'true'; } 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'; } const tableBody = document.querySelector('.asset-borrows-table-body'); if (tableBody && tableBody.dataset.boundActions !== 'true') { tableBody.addEventListener('click', (event) => { const detailButton = event.target.closest('.asset-borrow-detail-btn'); if (detailButton) { const requestId = Number(detailButton.dataset.requestId); if (Number.isFinite(requestId) && requestId > 0) { this.openAssetBorrowDetailsModal(requestId); } return; } const returnButton = event.target.closest('.asset-borrow-return-btn'); if (returnButton) { const requestId = Number(returnButton.dataset.requestId); if (Number.isFinite(requestId) && requestId > 0) { this.createAssetReturnRequestFromBorrow(requestId, returnButton); } return; } const cancelButton = event.target.closest('.asset-borrow-cancel-btn'); if (!cancelButton) { return; } const requestId = Number(cancelButton.dataset.requestId); if (!Number.isFinite(requestId) || requestId <= 0) { return; } this.deletePendingAssetBorrowRequest(requestId, { confirmMessage: `Bạn có chắc muốn hủy đơn #${requestId}?`, confirmButtonText: 'Hủy đơn', successMessage: 'Đã hủy đơn thành công', failureMessage: 'Hủy đơn thất bại' }); }); tableBody.dataset.boundActions = 'true'; } this.setupAssetBorrowPagerListeners(); } async createAssetReturnRequestFromBorrow(requestId, sourceButton = null) { const targetId = Number(requestId); if (!Number.isFinite(targetId) || targetId <= 0) { this.notifyWarning('Không xác định được đơn mượn cần trả.'); return; } if (sourceButton) { sourceButton.disabled = true; sourceButton.classList.add('opacity-60', 'cursor-not-allowed'); } try { const response = await fetch(`${this.apiBase}/asset-borrows/${targetId}/return`, { method: 'POST', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Tạo đơn trả tài sản thất bại'); return; } this.notifySuccess('Đã tạo đơn trả tài sản. Đơn đang chờ xử lý.'); await this.fetchAssetBorrows(); await this.fetchAssets(); if (this.currentPage === 'asset-borrows') { this.renderAssetBorrowsTableBody(); } if (this.currentPage === 'assets') { this.renderAssetsTableBody(); } if (this.currentPage === 'my-borrowed-assets') { this.renderMyBorrowedAssetsTableBody(); } const pendingModal = document.getElementById('assetPendingRequestsModal'); if (pendingModal?.classList.contains('open')) { this.renderPendingAssetRequestsModal(); } this.updatePendingAssetRequestsBadge(); } catch (err) { console.error(err); this.notifyFailure('Tạo đơn trả tài sản thất bại'); } finally { if (sourceButton && document.body.contains(sourceButton)) { sourceButton.disabled = false; sourceButton.classList.remove('opacity-60', 'cursor-not-allowed'); } } } 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(isReturnRequest ? 'Hiện chưa có tài sản để tạo đơn trả.' : 'Hiện chưa có tài sản còn tồn cuối kỳ để tạo đơn mượ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 selectedAsset = this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) || this.assets.find(item => Number(item?.AssetId) === assetId) || null; if (requestType === 'borrow' && selectedAsset && !this.isAssetAvailableForBorrow(selectedAsset)) { this.notifyWarning('Tài sản đã xuất hoặc hết tồn cuối kỳ, không thể tạo đơn mượn.'); await this.searchAssetBorrowProducts( document.getElementById('assetBorrowProductSearchInput')?.value || '', '', { reset: true } ); 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(); } resolveAssetRequestDeleteConfirm(confirmed) { const modal = document.getElementById('assetRequestDeleteConfirmModal'); if (modal) { modal.classList.remove('open'); } const resolver = this.pendingAssetRequestDeleteConfirmResolver; this.pendingAssetRequestDeleteConfirmResolver = undefined; if (typeof resolver === 'function') { resolver(Boolean(confirmed)); } } resolveBulkAssetDeleteConfirm(confirmed) { const modal = document.getElementById('bulkDeleteAssetsConfirmModal'); if (modal) { modal.classList.remove('open'); } const resolver = this.pendingBulkAssetDeleteConfirmResolver; this.pendingBulkAssetDeleteConfirmResolver = undefined; if (typeof resolver === 'function') { resolver(Boolean(confirmed)); } } async confirmBulkAssetDelete(selectedCount) { const modal = document.getElementById('bulkDeleteAssetsConfirmModal'); const messageNode = document.getElementById('bulkDeleteAssetsConfirmMessage'); const countNode = document.getElementById('bulkDeleteAssetsConfirmCount'); if (!modal || !messageNode || !countNode) { return window.confirm(`Bạn có chắc muốn xóa ${selectedCount} tài sản đã chọn?`); } countNode.textContent = String(selectedCount); messageNode.textContent = `Bạn có chắc muốn xóa ${selectedCount} tài sản đã chọn?`; if (this.pendingBulkAssetDeleteConfirmResolver) { this.resolveBulkAssetDeleteConfirm(false); } modal.classList.add('open'); return new Promise(resolve => { this.pendingBulkAssetDeleteConfirmResolver = resolve; }); } async confirmAssetRequestDelete(message, confirmButtonText = 'Xóa đơn') { const modal = document.getElementById('assetRequestDeleteConfirmModal'); const messageNode = document.getElementById('assetRequestDeleteConfirmMessage'); const confirmButton = document.getElementById('confirmAssetRequestDeleteBtn'); if (!modal || !messageNode || !confirmButton) { return window.confirm(message); } messageNode.textContent = String(message || 'Bạn có chắc muốn thực hiện thao tác này?'); confirmButton.textContent = String(confirmButtonText || 'Xóa đơn'); if (this.pendingAssetRequestDeleteConfirmResolver) { this.resolveAssetRequestDeleteConfirm(false); } modal.style.zIndex = '140'; modal.classList.add('open'); return new Promise(resolve => { this.pendingAssetRequestDeleteConfirmResolver = resolve; }); } 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 = await this.confirmAssetRequestDelete( `Đơn #${requestId} không còn hợp lệ. Bạn có muốn xóa đơn chờ này không?`, 'Xóa đơn' ); 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, options = {}) { 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 confirmMessage = String(options?.confirmMessage || `Bạn có chắc muốn xóa đơn chờ #${targetId}?`); const confirmButtonText = String(options?.confirmButtonText || 'Xóa đơn'); const successMessage = String(options?.successMessage || 'Đã xóa đơn chờ'); const failureMessage = String(options?.failureMessage || 'Xóa đơn chờ thất bại'); const confirmed = await this.confirmAssetRequestDelete(confirmMessage, confirmButtonText); 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) { const resolvedFailureMessage = data.message || failureMessage; if (response.status === 403) { this.notifyWarning(resolvedFailureMessage); } else { this.notifyFailure(resolvedFailureMessage); } return; } this.notifySuccess(data.message || successMessage); 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(failureMessage); } } buildConsumableTableRows(pageInfo) { const canManageAssets = this.canCurrentUserManageAssets(); return pageInfo.data.map((item, index) => { const statusMeta = this.getConsumableStockStatusMeta(item); const rowNumber = pageInfo.start + index; const reason = String(item.ExportReason || '').trim(); const exportedSummary = String(item.ExportedSummary || '').trim(); const recipientSummary = String(item.RecipientSummary || '').trim(); const projectSummary = String(item.ProjectSummary || '').trim(); return ` ${rowNumber} ${this.escapeHtml(item.RequestMonth || '-')} ${this.escapeHtml(item.ConsumableCode || '-')} ${this.escapeHtml(item.ConsumableName || '-')} ${this.escapeHtml(item.Model || '-')} ${this.escapeHtml(item.Unit || '-')} ${item.OpeningBalance ?? 0} ${item.ImportInPeriod ?? 0} ${item.ExportInPeriod ?? 0} ${this.escapeHtml(exportedSummary || '-')} ${this.escapeHtml(recipientSummary || '-')} ${this.escapeHtml(projectSummary || '-')} ${item.EndingBalance ?? 0} ${statusMeta.label} ${this.escapeHtml(reason || '-')} ${this.formatDateOnly(item.UpdatedDate || item.CreatedDate)}
`; }).join(''); } getConsumableMobileCardHtml(item, rowNumber, canManageAssets) { const statusMeta = this.getConsumableStockStatusMeta(item); const safe = value => this.escapeHtml(String(value ?? '').trim() || '-'); const number = value => Number.isFinite(Number(value)) ? Number(value) : 0; const unit = String(item?.Unit || '').trim(); const exportedSummary = String(item?.ExportedSummary || '').trim(); const recipientSummary = String(item?.RecipientSummary || '').trim(); const projectSummary = String(item?.ProjectSummary || '').trim(); const reason = String(item?.ExportReason || '').trim(); return `
#${rowNumber} · ${safe(item?.ConsumableCode)}

${safe(item?.ConsumableName)}

Tháng: ${safe(item?.RequestMonth)} Model: ${safe(item?.Model)} ĐVT: ${safe(unit)}
${statusMeta.label}
Tồn đầu ${number(item?.OpeningBalance)}
Nhập ${number(item?.ImportInPeriod)}
Xuất ${number(item?.ExportInPeriod)}
Tồn cuối ${number(item?.EndingBalance)}
${(exportedSummary || recipientSummary || projectSummary) ? `
${exportedSummary ? `
Đã xuất: ${safe(exportedSummary)}
` : ''} ${recipientSummary ? `
Người đang nhận: ${safe(recipientSummary)}
` : ''} ${projectSummary ? `
Dự án nhận: ${safe(projectSummary)}
` : ''}
` : ''}
${reason ? `
Lý do: ${safe(reason)}
` : ''}
Cập nhật: ${this.formatDateOnly(item?.UpdatedDate || item?.CreatedDate)}
`; } getConsumablesContent() { const canManageAssets = this.canCurrentUserManageAssets(); const filteredConsumables = this.getFilteredConsumables(); const pageInfo = this.getPaged(filteredConsumables, this.consumablePage, this.consumablePageSize); const monthOptions = this.getConsumableMonthOptions(); const pendingBorrowRequestCount = this.getPendingConsumableBorrowRequestCount(); const rejectedReturnRequests = canManageAssets ? [] : this.getRejectedConsumableReturnRequests(); const requestNotificationCount = canManageAssets ? pendingBorrowRequestCount : pendingBorrowRequestCount + rejectedReturnRequests.length; const latestRejectedReturn = rejectedReturnRequests[0]; const latestRejectedItemLabel = [latestRejectedReturn?.ConsumableCode, latestRejectedReturn?.ConsumableName] .filter(Boolean) .join(' - ') || 'Vật tư'; const latestRejectReason = String(latestRejectedReturn?.RejectReason || '').trim() || 'Chưa ghi nhận lý do từ chối'; this.consumablePage = pageInfo.current; return `
${latestRejectedReturn ? `
notification_important
${rejectedReturnRequests.length > 1 ? `Có ${rejectedReturnRequests.length} đơn trả bị từ chối` : `Đơn trả #${Number(latestRejectedReturn?.BorrowRequestId) || '-'} đã bị từ chối`}
${this.escapeHtml(latestRejectedItemLabel)} — Lý do: ${this.escapeHtml(latestRejectReason)}
` : ''}
Tháng
Trạng thái
Tìm kiếm
${pageInfo.data.length > 0 ? `
${this.buildConsumableTableRows(pageInfo)}
STT Tháng đề xuất Mã vật tư Tên linh kiện/sp Model ĐVT Tồn đầu Nhập Xuất Đã xuất Người đang nhận Dự án nhận Tồn cuối Trạng thái Lý do xuất Cập nhật Thao tác
${pageInfo.data.map((item, index) => this.getConsumableMobileCardHtml(item, pageInfo.start + index, canManageAssets)).join('')}
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
` : `

Chưa có dữ liệu vật tư tiêu hao.

`}
`; } renderConsumablesTableBody() { const tbody = document.querySelector('.consumables-table-body'); if (!tbody) { if (this.currentPage === 'consumables') { this.renderView('consumables'); } return; } const pageInfo = this.getPaged(this.getFilteredConsumables(), this.consumablePage, this.consumablePageSize); this.consumablePage = pageInfo.current; tbody.innerHTML = this.buildConsumableTableRows(pageInfo); const mobileList = document.querySelector('.consumables-mobile-list'); if (mobileList) { const canManageAssets = this.canCurrentUserManageAssets(); mobileList.innerHTML = pageInfo.data .map((item, index) => this.getConsumableMobileCardHtml(item, pageInfo.start + index, canManageAssets)) .join(''); } const pager = document.getElementById('consumablesPager'); if (pager) { pager.innerHTML = ` Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } this.setupConsumableRowListeners(); this.setupConsumablePagerListeners(); } setupConsumablePagerListeners() { document.querySelectorAll('.consumable-page-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { this.consumablePage = Number(btn.dataset.page) || 1; this.renderConsumablesTableBody(); }); btn.dataset.boundClick = 'true'; }); } getConsumableExportsContent() { const canManageAssets = this.canCurrentUserManageAssets(); const pageDescription = canManageAssets ? 'Theo dõi toàn bộ số lượng đang mượn, đã xuất cho dự án và đã hoàn trả về kho.' : 'Theo dõi vật tư đã xuất / cho mượn đối với tài khoản của bạn.'; const filteredRows = this.getFilteredConsumableExportHistories(); const pageInfo = this.getPaged(filteredRows, this.consumableExportPage, this.consumableExportPageSize); const recipientOptions = this.getConsumableExportRecipientOptions(); const projectOptions = this.getConsumableExportProjectOptions(); this.consumableExportPage = pageInfo.current; return `
Người nhận
Dự án
Ngày
Tìm kiếm
${this.buildConsumableExportHistoryPageRowsHtml(pageInfo)}
STT Ngày giờ Mã vật tư Tên vật tư Đã xuất Đã trả Còn lại ĐVT Người nhận Dự án nhận Trạng thái Người xuất Tồn trước/sau Ghi chú Thao tác
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } buildConsumableExportHistoryEmptyRowHtml() { return ` Chưa có dữ liệu lịch sử mượn / xuất vật tư. `; } buildConsumableExportHistoryPageRowHtml(item, rowNumber) { const exportedQuantity = this.parseNonNegativeInteger(item?.ExportQuantity, 0); const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, 0); const statusMeta = this.getConsumableReturnStatusMeta(item); const returnAction = this.getConsumableReturnActionMeta(item); const returnRequestNotice = this.buildConsumableReturnRequestNoticeHtml(item); const balanceLabel = `${Number(item?.PreviousEndingBalance) || 0} -> ${Number(item?.NextEndingBalance) || 0}`; const historyNote = [ item?.ExportNote ? `Xuất/mượn: ${item.ExportNote}` : '', item?.LastReturnedDate ? `Trả gần nhất ${this.formatDateTime(item.LastReturnedDate)}${item?.LastReturnedByName ? ` - ${item.LastReturnedByName}` : ''}${item?.LastReturnNote ? `: ${item.LastReturnNote}` : ''}` : '' ].filter(Boolean).join('\n') || '-'; return ` ${rowNumber} ${this.formatDateTime(item?.ExportedDate || item?.CreatedDate)} ${this.escapeHtml(item?.ConsumableCode || '-')} ${this.escapeHtml(item?.ConsumableName || '-')} ${exportedQuantity} ${returnedQuantity} ${remainingQuantity} ${this.escapeHtml(item?.Unit || '-')} ${this.escapeHtml(item?.RecipientName || '-')} ${this.escapeHtml(item?.ProjectName || '-')} ${statusMeta.label}${returnRequestNotice} ${this.escapeHtml(item?.ExportedByName || '-')} ${this.escapeHtml(balanceLabel)} ${this.escapeHtml(historyNote)} ${returnAction ? ` ` : '-'} `; } buildConsumableExportHistoryPageRowsHtml(pageInfo) { if (!pageInfo?.data?.length) { return this.buildConsumableExportHistoryEmptyRowHtml(); } return pageInfo.data .map((item, index) => this.buildConsumableExportHistoryPageRowHtml(item, pageInfo.start + index)) .join(''); } renderConsumableExportHistoryPageBody() { const tbody = document.getElementById('consumableExportHistoryPageTableBody'); if (!tbody) { return; } const pageInfo = this.getPaged( this.getFilteredConsumableExportHistories(), this.consumableExportPage, this.consumableExportPageSize ); this.consumableExportPage = pageInfo.current; tbody.innerHTML = this.buildConsumableExportHistoryPageRowsHtml(pageInfo); this.renderConsumableExportHistoryPager(pageInfo); this.setupConsumableExportHistoryPagerListeners(); this.setupConsumableReturnActionListeners(); } renderConsumableExportHistoryPager(pageInfo) { const pager = document.getElementById('consumableExportsPager'); if (!pager) { return; } pager.innerHTML = ` Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`; } async refreshConsumableExportsPage() { const tbody = document.getElementById('consumableExportHistoryPageTableBody'); if (tbody) { tbody.innerHTML = ` Đang tải lịch sử mượn / xuất / trả... `; } await Promise.all([ this.fetchConsumableExportHistories(2000), this.fetchConsumableBorrowRequests() ]); if (this.currentPage === 'consumable-exports') { const recipientFilter = document.getElementById('consumableExportRecipientFilter'); if (recipientFilter) { const recipientOptions = this.getConsumableExportRecipientOptions(); const currentValue = this.consumableExportRecipientFilter; recipientFilter.innerHTML = ` ${recipientOptions.map(name => ``).join('')} `; } const projectFilter = document.getElementById('consumableExportProjectFilter'); if (projectFilter) { const projectOptions = this.getConsumableExportProjectOptions(); const currentValue = this.consumableExportProjectFilter; projectFilter.innerHTML = ` ${projectOptions.map(name => ``).join('')} `; } this.renderConsumableExportHistoryPageBody(); } } setupConsumableExportHistoryPagerListeners() { document.querySelectorAll('.consumable-export-page-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { const targetPage = Number(btn.dataset.page); if (!targetPage || targetPage < 1) { return; } this.consumableExportPage = targetPage; this.renderConsumableExportHistoryPageBody(); }); btn.dataset.boundClick = 'true'; }); } setupConsumableExportHistoryListeners() { const recipientFilter = document.getElementById('consumableExportRecipientFilter'); if (recipientFilter && recipientFilter.dataset.boundChange !== 'true') { recipientFilter.addEventListener('change', event => { this.consumableExportRecipientFilter = String(event.target.value || '').trim(); this.consumableExportPage = 1; this.renderConsumableExportHistoryPageBody(); }); recipientFilter.dataset.boundChange = 'true'; } const projectFilter = document.getElementById('consumableExportProjectFilter'); if (projectFilter && projectFilter.dataset.boundChange !== 'true') { projectFilter.addEventListener('change', event => { this.consumableExportProjectFilter = String(event.target.value || '').trim(); this.consumableExportPage = 1; this.renderConsumableExportHistoryPageBody(); }); projectFilter.dataset.boundChange = 'true'; } const dateFilter = document.getElementById('consumableExportDateFilter'); if (dateFilter && dateFilter.dataset.boundChange !== 'true') { dateFilter.addEventListener('change', event => { this.consumableExportDateFilter = String(event.target.value || '').trim(); this.consumableExportPage = 1; this.renderConsumableExportHistoryPageBody(); }); dateFilter.dataset.boundChange = 'true'; } const searchInput = document.getElementById('consumableExportSearch'); if (searchInput && searchInput.dataset.boundInput !== 'true') { searchInput.addEventListener('input', event => { this.consumableExportSearchTerm = String(event.target.value || '').trim(); this.consumableExportPage = 1; this.renderConsumableExportHistoryPageBody(); }); searchInput.addEventListener('focus', () => { searchInput.dataset.focused = 'true'; }); searchInput.addEventListener('blur', () => { searchInput.dataset.focused = 'false'; }); searchInput.dataset.boundInput = 'true'; } const refreshBtn = document.getElementById('refreshConsumableExportHistoryPageBtn'); if (refreshBtn && refreshBtn.dataset.boundClick !== 'true') { refreshBtn.addEventListener('click', () => this.refreshConsumableExportsPage()); refreshBtn.dataset.boundClick = 'true'; } const exportBtn = document.getElementById('exportConsumableHistoryBtn'); if (exportBtn && exportBtn.dataset.boundClick !== 'true') { exportBtn.addEventListener('click', () => this.exportConsumableHistoryToExcel()); exportBtn.dataset.boundClick = 'true'; } this.setupConsumableExportHistoryPagerListeners(); } getAssetMobileCardHtml(asset, rowNumber, canManageAssets) { const statusMeta = this.getAssetStatusMeta(asset?.Status); const assetId = Number(asset?.AssetId); const isSelected = Number.isFinite(assetId) && this.selectedAssetIds.has(assetId); const unit = String(asset?.Unit || '').trim(); const endingBalance = Number(asset?.EndingBalance) || 0; const borrowedQuantity = Number(asset?.ExportInPeriod) || 0; const borrower = this.formatBorrowerSummaryText(asset?.Borrower); const safe = value => this.escapeHtml(String(value ?? '').trim() || '-'); return `
#${rowNumber} · ${safe(asset?.AssetCode)}

${safe(asset?.AssetName)}

${statusMeta.label}
Model${safe(asset?.Model)}
Serial${safe(asset?.SerialNumber)}
Tồn cuối kỳ${endingBalance}${unit ? ` ${safe(unit)}` : ''}
Đang mượn${borrowedQuantity}${unit ? ` ${safe(unit)}` : ''}
Phòng ban${safe(asset?.Department)}
Dự án${safe(asset?.Project)}
Vị trí${safe(asset?.Location)}
Phụ trách${safe(asset?.Custodian)}
${borrower ? `
Người mượn ${safe(borrower)}
` : ''}
`; } 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 SL hàng mới SL đã qua sử dụng SL đang mượn 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.NewQuantity ?? 0} ${asset.UsedQuantity ?? 0} ${asset.ExportInPeriod ?? 0} ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} ${this.formatBorrowerTableHtml(asset.Borrower)} ${asset.Notes || '-'} ${this.formatDateOnly(asset.CreatedDate)} ${asset.ExportedBy || '-'}
${pageInfo.data.map((asset, index) => this.getAssetMobileCardHtml(asset, pageInfo.start + index, canManageAssets)).join('')}
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.NewQuantity ?? 0} ${asset.UsedQuantity ?? 0} ${asset.ExportInPeriod ?? 0} ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} ${this.formatBorrowerTableHtml(asset.Borrower)} ${asset.Notes || '-'} ${this.formatDateOnly(asset.CreatedDate)} ${asset.ExportedBy || '-'}
`; }).join(''); const mobileList = document.querySelector('.assets-mobile-list'); if (mobileList) { mobileList.innerHTML = pageInfo.data .map((asset, index) => this.getAssetMobileCardHtml(asset, pageInfo.start + index, canManageAssets)) .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; } document.querySelectorAll(`.asset-row-checkbox[data-asset-id="${assetId}"]`).forEach(peer => { peer.checked = checkbox.checked; }); 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 assetSelectionById = new Map(); rowCheckboxes.forEach(checkbox => { const assetId = Number(checkbox.dataset.assetId); if (!Number.isFinite(assetId)) { return; } assetSelectionById.set(assetId, (assetSelectionById.get(assetId) || false) || checkbox.checked); }); const selectedOnPage = Array.from(assetSelectionById.values()).filter(Boolean).length; if (!canManageAssets) { this.selectedAssetIds.clear(); } rowCheckboxes.forEach(checkbox => { checkbox.disabled = !canManageAssets; }); const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox'); if (selectAllCheckbox) { const assetCountOnPage = assetSelectionById.size; const hasRows = assetCountOnPage > 0; selectAllCheckbox.checked = canManageAssets && hasRows && selectedOnPage === assetCountOnPage; selectAllCheckbox.indeterminate = canManageAssets && selectedOnPage > 0 && selectedOnPage < assetCountOnPage; 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); borrowAssetBtn.classList.toggle('hover:bg-primary/5', !disabled); } const damageAssetBtn = document.getElementById('damageAssetBtn'); if (damageAssetBtn) { const disabled = !canManageAssets || selectedCount !== 1; damageAssetBtn.disabled = disabled; damageAssetBtn.classList.toggle('opacity-50', disabled); damageAssetBtn.classList.toggle('cursor-not-allowed', disabled); damageAssetBtn.classList.toggle('hover:bg-red-50', !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 = await this.confirmBulkAssetDelete(selectedIds.length); 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], ['SL hàng mới', asset?.NewQuantity ?? 0], ['SL đã qua sử dụng', asset?.UsedQuantity ?? 0], ['SL đang mượn', asset?.ExportInPeriod ?? 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); const metrics = this.buildAssetQuantityMetrics(sourceAsset, borrowerEntries); const stockSplit = this.normalizeAssetStockSplit( metrics.endingBalance, sourceAsset?.NewQuantity ?? metrics.endingBalance, sourceAsset?.UsedQuantity ?? 0 ); const statusCode = sourceAsset?.AssetId ? this.computeAssetStatusCode(metrics.endingBalance, metrics.exportInPeriod) : 'in_stock'; const statusMeta = this.getAssetStatusMeta(statusCode); this.editingAssetBorrowerEntries = borrowerEntries; this.editingAssetStockSnapshot = { endingBalance: metrics.endingBalance, newQuantity: stockSplit.newQuantity, usedQuantity: stockSplit.usedQuantity }; this.clearAssetFormValidation(); document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || ''; document.getElementById('assetNameInput').value = sourceAsset?.AssetName || ''; const statusInput = document.getElementById('assetStatusInput'); if (statusInput) { statusInput.value = statusMeta.label; statusInput.dataset.statusCode = statusCode; } document.getElementById('assetModelInput').value = sourceAsset?.Model || ''; document.getElementById('assetSerialInput').value = sourceAsset?.SerialNumber || ''; document.getElementById('assetQuantityInput').value = metrics.quantity; document.getElementById('assetImportInPeriodInput').value = metrics.importInPeriod; document.getElementById('assetUnitInput').value = sourceAsset?.Unit || ''; this.refreshAssetDepartmentOptions(sourceAsset?.Department || ''); this.refreshAssetProjectOptions(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.editingAssetStockSnapshot = null; this.populateAssetForm(null); } this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.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'); bindInput('assetModelInput', 'assetModelError'); } 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'); this.clearAssetFieldValidation('assetModelInput', 'assetModelError'); } 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 timestamp = this.formatTimestampForCode(new Date(), true); 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 exportInPeriod = this.parseNonNegativeInteger(document.getElementById('assetExportInPeriodInput')?.value ?? 0, 0); const endingBalanceInput = this.parseOptionalNonNegativeInteger(document.getElementById('assetEndingBalanceInput')?.value ?? ''); const borrowerEntries = Array.isArray(this.editingAssetBorrowerEntries) ? this.editingAssetBorrowerEntries : []; const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); const endingBalance = endingBalanceInput !== null ? endingBalanceInput : computedEndingBalance; const status = this.computeAssetStatusCode(endingBalance, exportInPeriod); const previousSnapshot = this.editingAssetStockSnapshot; let nextNewQuantity = endingBalance; let nextUsedQuantity = 0; if (previousSnapshot) { const previousEnding = this.parseNonNegativeInteger(previousSnapshot.endingBalance, 0); const previousNew = this.parseNonNegativeInteger(previousSnapshot.newQuantity, previousEnding); const previousUsed = this.parseNonNegativeInteger(previousSnapshot.usedQuantity, 0); const deltaEnding = endingBalance - previousEnding; const tentativeNew = Math.max(previousNew + deltaEnding, 0); const normalized = this.normalizeAssetStockSplit(endingBalance, tentativeNew, previousUsed); nextNewQuantity = normalized.newQuantity; nextUsedQuantity = normalized.usedQuantity; } const purchasePrice = String(document.getElementById('assetPriceInput')?.value ?? '').trim(); return { assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '', assetName: document.getElementById('assetNameInput')?.value?.trim() || '', status, model: document.getElementById('assetModelInput')?.value?.trim() || '', serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '', quantity, importInPeriod, exportInPeriod, endingBalance, newQuantity: nextNewQuantity, usedQuantity: nextUsedQuantity, 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, fieldOverrides = {}) { if (!asset) { return null; } const quantity = this.parseNonNegativeInteger(asset?.Quantity, 0); const importInPeriod = this.parseNonNegativeInteger(asset?.ImportInPeriod, 0); const baseExportInPeriod = this.parseNonNegativeInteger(asset?.ExportInPeriod, 0); const baseEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance); const resolvedBaseEndingBalance = baseEndingBalance !== null ? baseEndingBalance : Math.max(quantity + importInPeriod - baseExportInPeriod, 0); const baseNewQuantity = this.parseOptionalNonNegativeInteger(asset?.NewQuantity); const baseUsedQuantity = this.parseOptionalNonNegativeInteger(asset?.UsedQuantity); const resolvedStockSplit = this.normalizeAssetStockSplit( resolvedBaseEndingBalance, baseNewQuantity !== null ? baseNewQuantity : resolvedBaseEndingBalance, baseUsedQuantity !== null ? baseUsedQuantity : 0 ); const borrowerEntries = Array.isArray(borrowerEntriesOverride) ? borrowerEntriesOverride : this.parseBorrowerEntries(asset?.Borrower); const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null; let exportInPeriod = baseExportInPeriod; let endingBalance = resolvedBaseEndingBalance; let newQuantity = resolvedStockSplit.newQuantity; let usedQuantity = resolvedStockSplit.usedQuantity; if (Array.isArray(borrowerEntriesOverride)) { const existingBorrowerEntries = this.parseBorrowerEntries(asset?.Borrower); const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); const nextBorrowerExport = this.parseBorrowerEntries(borrowerEntriesOverride).reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); const exportDelta = nextBorrowerExport - previousBorrowerExport; exportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0); endingBalance = Math.max(resolvedBaseEndingBalance - exportDelta, 0); const borrowFromNew = Math.min(newQuantity, Math.max(exportDelta, 0)); const borrowFromUsed = Math.max(Math.max(exportDelta, 0) - borrowFromNew, 0); newQuantity = Math.max(newQuantity - borrowFromNew, 0); usedQuantity = Math.max(usedQuantity - borrowFromUsed, 0); const normalizedSplit = this.normalizeAssetStockSplit(endingBalance, newQuantity, usedQuantity); newQuantity = normalizedSplit.newQuantity; usedQuantity = normalizedSplit.usedQuantity; } 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: this.computeAssetStatusCode(endingBalance, exportInPeriod), model: String(asset?.Model || '').trim(), serialNumber: String(asset?.SerialNumber || '').trim(), quantity, importInPeriod, exportInPeriod, endingBalance, newQuantity, usedQuantity, unit: String(asset?.Unit || '').trim(), department: String(asset?.Department || '').trim(), project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(), location: String(asset?.Location || '').trim(), custodian: String(fieldOverrides?.custodian ?? 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 để xuất.'); } return null; } if (selectedIds.length > 1) { if (showWarning) { this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần xuất.'); } 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; } getSingleSelectedAssetForDamage(showWarning = true) { const selectedIds = [...this.selectedAssetIds]; if (!selectedIds.length) { if (showWarning) { this.notifyWarning('Vui lòng chọn 1 tài sản để ghi nhận hỏng/thanh lý.'); } return null; } if (selectedIds.length > 1) { if (showWarning) { this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần ghi nhận hỏng/thanh lý.'); } 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 đã chọn.'); } return asset; } openAssetDamageModal() { if (!this.ensureAssetManagePermission('ghi nhan tai san hong/thanh ly')) { return; } const asset = this.getSingleSelectedAssetForDamage(true); if (!asset) { return; } 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ể ghi nhận hỏng/thanh lý thêm.'); return; } this.pendingAssetDamageId = Number(asset.AssetId); const modal = document.getElementById('assetDamageModal'); const assetIdInput = document.getElementById('assetDamageAssetIdInput'); const assetNameInput = document.getElementById('assetDamageAssetNameInput'); const typeInput = document.getElementById('assetDamageTypeInput'); const quantityInput = document.getElementById('assetDamageQuantityInput'); const currentQuantityInput = document.getElementById('assetDamageCurrentQuantityInput'); const currentEndingInput = document.getElementById('assetDamageCurrentEndingInput'); const currentNewInput = document.getElementById('assetDamageCurrentNewInput'); const currentUsedInput = document.getElementById('assetDamageCurrentUsedInput'); const actorInput = document.getElementById('assetDamageActorInput'); const noteInput = document.getElementById('assetDamageNoteInput'); if (!modal || !assetNameInput || !quantityInput || !currentQuantityInput || !currentEndingInput) { this.notifyFailure('Không tìm thấy biểu mẫu hỏng/thanh lý tài sản.'); return; } const stockSplit = this.normalizeAssetStockSplit( metrics.endingBalance, asset?.NewQuantity ?? metrics.endingBalance, asset?.UsedQuantity ?? 0 ); if (assetIdInput) { assetIdInput.value = String(asset.AssetId || ''); } assetNameInput.value = `${asset.AssetCode || ''} - ${asset.AssetName || ''}`.trim(); currentQuantityInput.value = String(metrics.quantity); currentEndingInput.value = String(metrics.endingBalance); if (currentNewInput) currentNewInput.value = String(stockSplit.newQuantity); if (currentUsedInput) currentUsedInput.value = String(stockSplit.usedQuantity); if (typeInput) typeInput.value = 'damaged'; quantityInput.value = '1'; quantityInput.min = '1'; quantityInput.max = String(metrics.endingBalance); if (actorInput) actorInput.value = this.getCurrentUserDisplayName(); if (noteInput) noteInput.value = ''; modal.classList.add('open'); } async handleAssetDamageSubmit(e) { e.preventDefault(); if (!this.ensureAssetManagePermission('ghi nhan tai san hong/thanh ly')) { return; } const assetIdInput = document.getElementById('assetDamageAssetIdInput'); const typeInput = document.getElementById('assetDamageTypeInput'); const quantityInput = document.getElementById('assetDamageQuantityInput'); const noteInput = document.getElementById('assetDamageNoteInput'); const selectedAssetId = Number(assetIdInput?.value || this.pendingAssetDamageId); if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) { this.notifyFailure('Không xác định được tài sản cần ghi nhậ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 ghi nhận.'); return; } const actionType = this.normalizeAssetDamageType(typeInput?.value || 'damaged'); const actionMeta = this.getAssetDamageTypeMeta(actionType); const actionQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); const currentMetrics = this.buildAssetQuantityMetrics(asset); if (actionQuantity <= 0) { this.notifyWarning('Số lượng phải lớn hơn 0.'); return; } if (actionQuantity > currentMetrics.endingBalance) { this.notifyWarning(`Số lượng ${actionMeta.label.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`); return; } try { const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/damage-disposal`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ actionType, quantity: actionQuantity, note: String(noteInput?.value || '').trim() }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Ghi nhận hỏng/thanh lý thất bại'); return; } this.pendingAssetDamageId = undefined; document.getElementById('assetDamageModal')?.classList.remove('open'); this.notifySuccess(data.message || `Đã ghi nhận tài sản ${actionMeta.label.toLowerCase()}`); await this.refreshAssetsUI(); const historyModal = document.getElementById('assetDamageHistoryModal'); if (historyModal?.classList.contains('open')) { await this.fetchAssetDamageHistories(); this.renderAssetDamageHistoryModal(); } } catch (err) { console.error(err); this.notifyFailure('Ghi nhận hỏng/thanh lý thất bại'); } } async openBorrowAssetModal() { if (!this.ensureAssetManagePermission('xuat tai san')) { return; } const asset = this.getSingleSelectedAssetForBorrowing(true); if (!asset) { return; } if (!this.users.length) { await this.fetchUsers(); } if (!this.assetProjects.length) { await this.fetchAssetProjects(); } 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ể xuất 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 projectInput = document.getElementById('borrowAssetProjectInput'); const noteInput = document.getElementById('borrowAssetNoteInput'); const borrowByInput = document.getElementById('borrowByInput'); const borrowRoleInput = document.getElementById('borrowRoleInput'); const modal = document.getElementById('borrowAssetModal'); if (!modal || !assetNameInput || !endingInput || !quantityInput || !projectInput || !noteInput) { this.notifyFailure('Không tìm thấy biểu mẫu xuất 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(''); this.refreshBorrowAssetProjectOptions(String(asset?.Project || '').trim()); noteInput.value = ''; 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('xuat tai san')) { return; } const assetIdInput = document.getElementById('borrowAssetIdInput'); const borrowerInput = document.getElementById('borrowAssetUserInput'); const projectInput = document.getElementById('borrowAssetProjectInput'); const quantityInput = document.getElementById('borrowQuantityInput'); const noteInput = document.getElementById('borrowAssetNoteInput'); 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 xuất.'); 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 xuất.'); return; } const borrowerName = String(borrowerInput?.value || '').trim(); if (!borrowerName) { this.notifyWarning('Vui lòng chọn người mượn.'); return; } const projectName = String(projectInput?.value || '').trim(); if (!projectName) { this.notifyWarning('Vui lòng chọn dự án cần xuất.'); return; } const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); if (borrowQuantity <= 0) { this.notifyWarning('Số lượng xuất phải lớn hơn 0.'); return; } const exportNote = String(noteInput?.value || '').trim(); 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ể xuất thêm.'); return; } if (borrowQuantity > currentMetrics.endingBalance) { this.notifyWarning(`Số lượng xuất (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`); return; } try { const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/export`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ quantity: borrowQuantity, borrowerName, custodianName: borrowerName, projectName, note: exportNote }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xuất tài sản thất bại'); return; } this.pendingBorrowAssetId = undefined; this.notifySuccess('Xuất tài sản thành công'); this.closeModals(); await this.refreshAssetsUI(); const exportHistoryModal = document.getElementById('assetExportHistoryModal'); if (exportHistoryModal?.classList.contains('open')) { await this.fetchAssetExportHistories(); this.renderAssetExportHistoryModal(); } } catch (err) { console.error(err); this.notifyFailure('Xuất 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.model) { this.setAssetFieldValidationError('assetModelInput', 'assetModelError', 'Vui lòng nhập model.'); this.notifyWarning('Vui lòng nhập đầy đủ các trường bắt buộc.'); document.getElementById('assetModelInput')?.focus(); return; } if (!payload.assetName) { payload.assetName = payload.model; } 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.editingAssetStockSnapshot = null; 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'); } } setupConsumableStockListeners() { const openingInput = document.getElementById('consumableOpeningBalanceInput'); const importInput = document.getElementById('consumableImportInPeriodInput'); const exportInput = document.getElementById('consumableExportInPeriodInput'); const endingInput = document.getElementById('consumableEndingBalanceInput'); if (!openingInput || !importInput || !exportInput || !endingInput) { return; } const updateEnding = () => { const openingBalance = this.parseNonNegativeInteger(openingInput.value, 0); const importInPeriod = this.parseNonNegativeInteger(importInput.value, 0); const exportInPeriod = this.parseNonNegativeInteger(exportInput.value, 0); endingInput.value = String(Math.max(openingBalance + importInPeriod - exportInPeriod, 0)); }; [openingInput, importInput, exportInput].forEach(input => { if (input.dataset.boundConsumableStock === 'true') { return; } input.addEventListener('input', updateEnding); input.dataset.boundConsumableStock = 'true'; }); updateEnding(); } collectConsumableFormPayload() { const openingBalance = this.parseNonNegativeInteger(document.getElementById('consumableOpeningBalanceInput')?.value, 0); const importInPeriod = this.parseNonNegativeInteger(document.getElementById('consumableImportInPeriodInput')?.value, 0); const exportInPeriod = this.parseNonNegativeInteger(document.getElementById('consumableExportInPeriodInput')?.value, 0); return { requestMonth: String(document.getElementById('consumableRequestMonthInput')?.value || '').trim(), consumableCode: String(document.getElementById('consumableCodeInput')?.value || '').trim(), consumableName: String(document.getElementById('consumableNameInput')?.value || '').trim(), model: String(document.getElementById('consumableModelInput')?.value || '').trim(), unit: String(document.getElementById('consumableUnitInput')?.value || '').trim(), openingBalance, importInPeriod, exportInPeriod, endingBalance: Math.max(openingBalance + importInPeriod - exportInPeriod, 0), exportReason: String(document.getElementById('consumableExportReasonInput')?.value || '').trim() }; } populateConsumableForm(consumable = null) { const isEdit = Boolean(consumable); const title = document.getElementById('consumableModalTitle'); if (title) { title.textContent = isEdit ? 'Sửa vật tư tiêu hao' : 'Thêm vật tư tiêu hao'; } const setValue = (id, value) => { const input = document.getElementById(id); if (input) { input.value = value ?? ''; } }; const requestMonth = String(consumable?.RequestMonth || '').trim(); const requestMonthInput = document.getElementById('consumableRequestMonthInput'); if ( requestMonthInput?.tagName === 'SELECT' && requestMonth && !Array.from(requestMonthInput.options).some(option => option.value === requestMonth) ) { const legacyOption = document.createElement('option'); legacyOption.value = requestMonth; legacyOption.textContent = requestMonth; requestMonthInput.appendChild(legacyOption); } setValue('consumableRequestMonthInput', requestMonth); setValue('consumableCodeInput', consumable?.ConsumableCode || ''); setValue('consumableNameInput', consumable?.ConsumableName || ''); setValue('consumableModelInput', consumable?.Model || ''); setValue('consumableUnitInput', consumable?.Unit || ''); setValue('consumableOpeningBalanceInput', consumable?.OpeningBalance ?? 0); setValue('consumableImportInPeriodInput', consumable?.ImportInPeriod ?? 0); setValue('consumableExportInPeriodInput', consumable?.ExportInPeriod ?? 0); setValue('consumableEndingBalanceInput', consumable?.EndingBalance ?? 0); setValue('consumableExportReasonInput', consumable?.ExportReason || ''); this.setupConsumableStockListeners(); } openConsumableModal() { if (!this.ensureAssetManagePermission('quan ly vat tu tieu hao')) { return; } if (this.editingConsumableId === undefined) { this.populateConsumableForm(null); } document.getElementById('consumableModal')?.classList.add('open'); } openConsumableExportModal(consumable) { if (!this.ensureAssetManagePermission('xuat vat tu tieu hao')) { return; } if (!consumable) { this.notifyWarning('Vui lòng chọn vật tư cần xuất.'); return; } const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0); if (endingBalance <= 0) { this.notifyWarning('Vật tư đã hết tồn cuối kỳ, không thể xuất thêm.'); return; } this.pendingConsumableExportId = Number(consumable.ConsumableId); const idInput = document.getElementById('consumableExportConsumableIdInput'); const nameInput = document.getElementById('consumableExportConsumableNameInput'); const endingInput = document.getElementById('consumableExportCurrentEndingInput'); const quantityInput = document.getElementById('consumableExportQuantityInput'); const noteInput = document.getElementById('consumableExportNoteInput'); const actorInput = document.getElementById('consumableExportActorInput'); const roleInput = document.getElementById('consumableExportRoleInput'); const targetTypeInput = document.getElementById('consumableExportTargetTypeInput'); const projectInput = document.getElementById('consumableExportProjectInput'); const modal = document.getElementById('consumableExportModal'); if (!modal || !nameInput || !endingInput || !quantityInput || !noteInput) { this.notifyFailure('Không tìm thấy biểu mẫu xuất vật tư.'); return; } if (idInput) { idInput.value = String(consumable.ConsumableId || ''); } nameInput.value = `${consumable.ConsumableCode || ''} - ${consumable.ConsumableName || ''}`.trim(); endingInput.value = String(endingBalance); quantityInput.value = '1'; quantityInput.min = '1'; quantityInput.max = String(endingBalance); noteInput.value = ''; if (targetTypeInput) { targetTypeInput.value = 'user'; } if (projectInput) { projectInput.value = ''; } this.refreshConsumableExportUserOptions(''); this.refreshConsumableExportProjectOptions(''); this.setupConsumableExportTargetTypeListeners(); if (actorInput) { actorInput.value = this.getCurrentUserDisplayName(); } if (roleInput) { roleInput.value = String(this.getCurrentUserRoleRaw() || '').trim() || '-'; } modal.classList.add('open'); } getAvailableConsumableBorrowProducts() { return (Array.isArray(this.consumables) ? this.consumables : []) .filter(item => this.parseNonNegativeInteger(item?.EndingBalance, 0) > 0); } getFilteredConsumableBorrowProducts(queryValue = this.consumableBorrowProductQuery) { const query = String(queryValue || '').trim().toLowerCase(); return this.getAvailableConsumableBorrowProducts().filter(item => { if (!query) { return true; } return [item?.ConsumableCode, item?.ConsumableName, item?.Model, item?.Unit] .map(value => String(value || '').toLowerCase()) .some(value => value.includes(query)); }); } getConsumableBorrowProductDisplayName(consumable) { if (!consumable) { return '-- Chọn vật tư --'; } const label = [consumable.ConsumableCode, consumable.ConsumableName] .map(value => String(value || '').trim()) .filter(Boolean) .join(' - '); const stock = this.parseNonNegativeInteger(consumable?.EndingBalance, 0); const unit = String(consumable?.Unit || '').trim(); return `${label} (còn ${stock}${unit ? ` ${unit}` : ''})`; } updateConsumableBorrowProductDisplay(consumableIdValue) { const hiddenInput = document.getElementById('consumableBorrowProductInput'); const displayNode = document.getElementById('consumableBorrowProductDisplayText'); const consumableId = Number(consumableIdValue); const consumable = this.getAvailableConsumableBorrowProducts() .find(item => Number(item?.ConsumableId) === consumableId) || null; if (hiddenInput) { hiddenInput.value = consumable ? String(consumable.ConsumableId) : ''; } if (displayNode) { displayNode.textContent = this.getConsumableBorrowProductDisplayName(consumable); displayNode.classList.toggle('text-slate-600', !consumable); displayNode.classList.toggle('text-slate-700', Boolean(consumable)); } this.syncConsumableBorrowRequestSelection(); this.renderConsumableBorrowProductList(); } renderConsumableBorrowProductList() { const listNode = document.getElementById('consumableBorrowProductList'); const hiddenInput = document.getElementById('consumableBorrowProductInput'); if (!listNode) { return; } const selectedId = Number(hiddenInput?.value || 0); const rows = this.getFilteredConsumableBorrowProducts(); if (!rows.length) { listNode.innerHTML = '
Không tìm thấy vật tư phù hợp.
'; return; } listNode.innerHTML = rows.map(item => { const consumableId = Number(item?.ConsumableId); const selected = selectedId === consumableId; return ` `; }).join(''); listNode.querySelectorAll('.consumable-borrow-product-option').forEach(button => { button.addEventListener('click', () => { this.updateConsumableBorrowProductDisplay(Number(button.dataset.consumableId)); this.closeConsumableBorrowProductDropdown(); }); }); } openConsumableBorrowProductDropdown() { const dropdown = document.getElementById('consumableBorrowProductDropdown'); const searchInput = document.getElementById('consumableBorrowProductSearchInput'); dropdown?.classList.remove('hidden'); this.renderConsumableBorrowProductList(); if (searchInput) { searchInput.focus(); searchInput.select(); } } closeConsumableBorrowProductDropdown() { document.getElementById('consumableBorrowProductDropdown')?.classList.add('hidden'); } setupConsumableBorrowProductPickerListeners() { const picker = document.getElementById('consumableBorrowProductPicker'); const displayBtn = document.getElementById('consumableBorrowProductDisplayBtn'); const dropdown = document.getElementById('consumableBorrowProductDropdown'); const searchInput = document.getElementById('consumableBorrowProductSearchInput'); if (displayBtn && displayBtn.dataset.boundClick !== 'true') { displayBtn.addEventListener('click', () => { if (dropdown && !dropdown.classList.contains('hidden')) { this.closeConsumableBorrowProductDropdown(); } else { this.openConsumableBorrowProductDropdown(); } }); displayBtn.dataset.boundClick = 'true'; } if (searchInput && searchInput.dataset.boundInput !== 'true') { searchInput.addEventListener('input', () => { this.consumableBorrowProductQuery = searchInput.value; this.renderConsumableBorrowProductList(); }); searchInput.dataset.boundInput = 'true'; } if (picker && picker.dataset.boundOutsideClick !== 'true') { document.addEventListener('click', event => { if (!picker.contains(event.target)) { this.closeConsumableBorrowProductDropdown(); } }); picker.dataset.boundOutsideClick = 'true'; } } syncConsumableBorrowRequestSelection() { const productInput = document.getElementById('consumableBorrowProductInput'); const stockInput = document.getElementById('consumableBorrowCurrentStockInput'); const unitInput = document.getElementById('consumableBorrowUnitInput'); const quantityInput = document.getElementById('consumableBorrowQuantityInput'); const consumableId = Number(productInput?.value || 0); const consumable = this.consumables.find(item => Number(item?.ConsumableId) === consumableId); const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0); if (stockInput) { stockInput.value = consumable ? String(endingBalance) : ''; } if (unitInput) { unitInput.value = consumable ? String(consumable.Unit || '') : ''; } if (quantityInput) { quantityInput.max = consumable ? String(endingBalance) : ''; if (this.parseNonNegativeInteger(quantityInput.value, 0) <= 0 || Number(quantityInput.value) > endingBalance) { quantityInput.value = consumable ? '1' : ''; } } } async openConsumableBorrowRequestModal() { if (!Array.isArray(this.consumables) || !this.consumables.length) { await this.fetchConsumables(); } const modal = document.getElementById('consumableBorrowRequestModal'); const requesterInput = document.getElementById('consumableBorrowRequesterInput'); const productInput = document.getElementById('consumableBorrowProductInput'); const quantityInput = document.getElementById('consumableBorrowQuantityInput'); const dateInput = document.getElementById('consumableBorrowDateInput'); const noteInput = document.getElementById('consumableBorrowNoteInput'); if (!modal || !requesterInput || !productInput || !quantityInput || !dateInput) { this.notifyFailure('Không tìm thấy biểu mẫu tạo đơn mượn vật tư.'); return; } const availableConsumables = this.getAvailableConsumableBorrowProducts(); if (!availableConsumables.length) { this.notifyWarning('Hiện không có vật tư còn tồn để tạo đơn mượn.'); return; } requesterInput.value = this.getCurrentUserDisplayName(); this.consumableBorrowProductQuery = ''; const searchInput = document.getElementById('consumableBorrowProductSearchInput'); if (searchInput) { searchInput.value = ''; } productInput.value = ''; quantityInput.value = '1'; quantityInput.min = '1'; dateInput.value = this.toDateInputValue(new Date()); if (noteInput) { noteInput.value = ''; } this.setupConsumableBorrowProductPickerListeners(); this.updateConsumableBorrowProductDisplay(''); this.closeConsumableBorrowProductDropdown(); modal.classList.add('open'); } async handleConsumableBorrowRequestSubmit(event) { event.preventDefault(); const productInput = document.getElementById('consumableBorrowProductInput'); const quantityInput = document.getElementById('consumableBorrowQuantityInput'); const unitInput = document.getElementById('consumableBorrowUnitInput'); const dateInput = document.getElementById('consumableBorrowDateInput'); const requesterInput = document.getElementById('consumableBorrowRequesterInput'); const noteInput = document.getElementById('consumableBorrowNoteInput'); const consumableId = Number(productInput?.value || 0); const consumable = this.consumables.find(item => Number(item?.ConsumableId) === consumableId); if (!Number.isInteger(consumableId) || consumableId <= 0 || !consumable) { this.notifyWarning('Vui lòng chọn vật tư cần mượn.'); return; } const quantity = this.parseNonNegativeInteger(quantityInput?.value, 0); const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0); if (quantity <= 0) { this.notifyWarning('Số lượng mượn phải lớn hơn 0.'); return; } if (quantity > endingBalance) { this.notifyWarning(`Số lượng mượn (${quantity}) vượt quá tồn hiện tại (${endingBalance}).`); return; } try { const response = await fetch(`${this.apiBase}/consumable-borrows`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ consumableId, quantity, unit: String(unitInput?.value || '').trim(), borrowDate: String(dateInput?.value || '').trim(), borrowerName: String(requesterInput?.value || this.getCurrentUserDisplayName()).trim(), note: String(noteInput?.value || '').trim() }) }); const rawResponse = await response.text(); let data; try { data = rawResponse ? JSON.parse(rawResponse) : {}; } catch (_parseErr) { data = { success: false, message: `Máy chủ phản hồi lỗi HTTP ${response.status}` }; } if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Tạo đơn mượn vật tư thất bại'); return; } document.getElementById('consumableBorrowRequestModal')?.classList.remove('open'); this.notifySuccess(data.message || 'Tạo đơn mượn vật tư thành công'); await this.fetchConsumableBorrowRequests(); await this.openConsumableBorrowRequestsModal(); } catch (err) { console.error(err); this.notifyFailure('Tạo đơn mượn vật tư thất bại'); } } buildConsumableBorrowRequestRowsHtml() { const rows = Array.isArray(this.consumableBorrowRequests) ? this.consumableBorrowRequests : []; const canManageAssets = this.canCurrentUserManageAssets(); if (!rows.length) { return `Chưa có đơn mượn/trả vật tư.`; } return rows.map(item => { const requestId = Number(item?.BorrowRequestId) || 0; const typeMeta = this.getConsumableRequestTypeMeta(item?.RequestType); const statusMeta = this.getConsumableBorrowRequestStatusMeta(item?.RequestStatus); if (typeMeta.key === 'return' && statusMeta.key === 'approved') { statusMeta.label = 'Đã duyệt / đã trả'; } const isPending = statusMeta.key === 'pending'; const itemLabel = [item?.ConsumableCode, item?.ConsumableName].filter(Boolean).join(' - ') || '-'; const rejectReason = String(item?.RejectReason || '').trim(); const rejectReasonHtml = statusMeta.key === 'rejected' ? `
info ${this.escapeHtml(rejectReason || 'Chưa ghi nhận lý do từ chối')}
` : '-'; return ` #${requestId || '-'} ${this.escapeHtml(item?.BorrowerName || '-')} ${this.escapeHtml(itemLabel)} ${typeMeta.label} ${Number(item?.BorrowQuantity) || 0} ${this.escapeHtml(item?.Unit || '')} ${this.formatDateOnly(item?.BorrowDate)} ${statusMeta.label} ${this.escapeHtml(item?.RequestNote || '-')} ${this.escapeHtml(item?.ProcessedByName || '-')} ${rejectReasonHtml} ${canManageAssets && isPending ? ` ` : ''} ${isPending ? `` : '-'} `; }).join(''); } renderConsumableBorrowRequestsModal() { const tbody = document.getElementById('consumableBorrowRequestsTableBody'); const title = document.getElementById('consumableBorrowRequestsModalTitle'); const subtitle = document.getElementById('consumableBorrowRequestsModalSubtitle'); if (!tbody) { return; } if (title) { title.textContent = this.canCurrentUserManageAssets() ? 'Đơn mượn/trả vật tư / chờ duyệt' : 'Đơn mượn/trả vật tư của tôi'; } if (subtitle) { subtitle.textContent = this.canCurrentUserManageAssets() ? 'Duyệt đơn mượn sẽ trừ tồn; duyệt đơn trả sẽ cộng vật tư về kho.' : 'Theo dõi trạng thái đơn; lý do từ chối được hiển thị trực tiếp trong danh sách.'; } tbody.innerHTML = this.buildConsumableBorrowRequestRowsHtml(); this.bindConsumableBorrowRequestActions(); this.updateConsumableBorrowRequestBadges(); } bindConsumableBorrowRequestActions() { document.querySelectorAll('.consumable-borrow-approve-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') return; btn.addEventListener('click', () => this.processConsumableBorrowRequest(Number(btn.dataset.requestId), 'approved')); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.consumable-borrow-reject-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') return; btn.addEventListener('click', () => this.openConsumableRequestRejectModal(Number(btn.dataset.requestId))); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.consumable-borrow-cancel-btn').forEach(btn => { if (btn.dataset.boundClick === 'true') return; btn.addEventListener('click', () => this.confirmCancelConsumableBorrowRequest(Number(btn.dataset.requestId))); btn.dataset.boundClick = 'true'; }); } openConsumableRequestRejectModal(requestId) { if (!this.canCurrentUserManageAssets()) { return; } const targetId = Number(requestId); const modal = document.getElementById('consumableRequestRejectModal'); const idInput = document.getElementById('consumableRequestRejectIdInput'); const reasonInput = document.getElementById('consumableRequestRejectReasonInput'); const title = document.getElementById('consumableRequestRejectModalTitle'); if (!Number.isInteger(targetId) || targetId <= 0 || !modal || !idInput || !reasonInput) { this.notifyFailure('Không tìm thấy biểu mẫu từ chối đơn vật tư.'); return; } this.pendingConsumableRequestRejectId = targetId; idInput.value = String(targetId); reasonInput.value = ''; const request = this.consumableBorrowRequests.find(item => Number(item?.BorrowRequestId) === targetId); const requestType = this.getConsumableRequestTypeMeta(request?.RequestType); if (title) { title.textContent = `Từ chối đơn ${requestType.label.toLowerCase()} #${targetId}`; } modal.classList.add('open'); reasonInput.focus(); } async handleConsumableRequestRejectSubmit(event) { event.preventDefault(); const requestId = Number( document.getElementById('consumableRequestRejectIdInput')?.value || this.pendingConsumableRequestRejectId ); const rejectReason = String(document.getElementById('consumableRequestRejectReasonInput')?.value || '').trim(); if (!Number.isInteger(requestId) || requestId <= 0) { this.notifyWarning('Không xác định được đơn cần từ chối.'); return; } if (!rejectReason) { this.notifyWarning('Vui lòng nhập lý do từ chối để người tạo đơn được biết.'); document.getElementById('consumableRequestRejectReasonInput')?.focus(); return; } await this.processConsumableBorrowRequest(requestId, 'rejected', rejectReason); } async confirmCancelConsumableBorrowRequest(requestId) { const targetId = Number(requestId); if (!Number.isInteger(targetId) || targetId <= 0) { return; } if (!document.getElementById('assetRequestDeleteConfirmModal')) { this.notifyFailure('Không tìm thấy hộp thoại xác nhận hủy đơn.'); return; } const request = this.consumableBorrowRequests.find(item => Number(item?.BorrowRequestId) === targetId); const requestType = this.getConsumableRequestTypeMeta(request?.RequestType).label.toLowerCase(); const confirmed = await this.confirmAssetRequestDelete( `Bạn có chắc muốn hủy đơn ${requestType} #${targetId}? Thao tác này không thể hoàn tác.`, 'Hủy đơn' ); if (confirmed) { await this.cancelConsumableBorrowRequest(targetId); } } async openConsumableBorrowRequestsModal() { const modal = document.getElementById('consumableBorrowRequestsModal'); const tbody = document.getElementById('consumableBorrowRequestsTableBody'); if (!modal || !tbody) { this.notifyFailure('Không tìm thấy danh sách đơn mượn vật tư.'); return; } tbody.innerHTML = `Đang tải đơn mượn/trả...`; modal.classList.add('open'); await this.fetchConsumableBorrowRequests(); this.renderConsumableBorrowRequestsModal(); } async processConsumableBorrowRequest(requestId, action, rejectReason = '') { if (!this.ensureAssetManagePermission('xu ly don muon vat tu tieu hao')) { return; } if (!Number.isInteger(requestId) || requestId <= 0) { return; } try { const response = await fetch(`${this.apiBase}/consumable-borrows/${requestId}/process`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ action, rejectReason }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xử lý đơn mượn thất bại'); return; } if (action === 'rejected') { this.pendingConsumableRequestRejectId = undefined; closeConsumableRequestRejectModal(); } this.notifySuccess(data.message || (action === 'approved' ? 'Đã duyệt đơn' : 'Đã từ chối đơn')); await Promise.all([ this.fetchConsumableBorrowRequests(), this.fetchConsumables(), this.fetchConsumableExportHistories(300) ]); this.renderConsumableBorrowRequestsModal(); if (this.currentPage === 'consumables') { this.renderConsumablesTableBody(); } } catch (err) { console.error(err); this.notifyFailure('Xử lý đơn mượn thất bại'); } } async cancelConsumableBorrowRequest(requestId) { if (!Number.isInteger(requestId) || requestId <= 0) { return; } try { const response = await fetch(`${this.apiBase}/consumable-borrows/${requestId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Hủy đơn mượn thất bại'); return; } this.notifySuccess(data.message || 'Đã hủy đơn mượn'); await this.fetchConsumableBorrowRequests(); this.renderConsumableBorrowRequestsModal(); } catch (err) { console.error(err); this.notifyFailure('Hủy đơn mượn thất bại'); } } setupConsumableReturnActionListeners() { document.querySelectorAll('.return-consumable-export').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { if (btn.disabled) { return; } const exportHistoryId = Number(btn.dataset.exportHistoryId); const history = this.consumableExportHistories.find(item => ( Number(item?.ExportHistoryId) === exportHistoryId )); if (!this.getConsumableReturnActionMeta(history)) { this.notifyWarning('Vật tư này không có hành động trả phù hợp.'); return; } this.openConsumableReturnModal(history); }); btn.dataset.boundClick = 'true'; }); } openConsumableReturnModal(history) { if (!history) { this.notifyWarning('Không tìm thấy phiếu xuất cần trả.'); return; } const remainingQuantity = this.parseNonNegativeInteger(history?.RemainingQuantity, 0); const returnAction = this.getConsumableReturnActionMeta(history); if (!returnAction) { this.notifyWarning(String(history?.ProjectName || '').trim() ? 'Vật tư xuất cho dự án không có hành động trả.' : 'Vật tư này đã hết số lượng có thể tạo đơn trả.'); return; } const modal = document.getElementById('consumableReturnModal'); const historyIdInput = document.getElementById('consumableReturnExportHistoryIdInput'); const consumableInput = document.getElementById('consumableReturnConsumableInput'); const destinationInput = document.getElementById('consumableReturnDestinationInput'); const remainingInput = document.getElementById('consumableReturnRemainingInput'); const quantityInput = document.getElementById('consumableReturnQuantityInput'); const actorInput = document.getElementById('consumableReturnActorInput'); const noteInput = document.getElementById('consumableReturnNoteInput'); const title = document.getElementById('consumableReturnModalTitle'); const quantityLabel = document.getElementById('consumableReturnQuantityLabel'); const actorLabel = document.getElementById('consumableReturnActorLabel'); const helpText = document.getElementById('consumableReturnHelpText'); const submitBtn = document.getElementById('consumableReturnSubmitBtn'); if (!modal || !historyIdInput || !consumableInput || !destinationInput || !remainingInput || !quantityInput) { this.notifyFailure('Không tìm thấy biểu mẫu hoàn trả vật tư.'); return; } this.pendingConsumableReturnHistoryId = Number(history.ExportHistoryId); historyIdInput.value = String(history.ExportHistoryId || ''); consumableInput.value = [history.ConsumableCode, history.ConsumableName].filter(Boolean).join(' - '); destinationInput.value = String(history.ProjectName || '').trim() ? `Dự án: ${String(history.ProjectName).trim()}` : `Người mượn: ${String(history.RecipientName || '-').trim() || '-'}`; remainingInput.value = String(remainingQuantity); quantityInput.value = String(returnAction.availableQuantity); quantityInput.min = '1'; quantityInput.max = String(returnAction.availableQuantity); if (actorInput) { actorInput.value = this.getCurrentUserDisplayName(); } if (noteInput) { noteInput.value = ''; } const isReturnRequest = returnAction.mode === 'request'; if (title) title.textContent = isReturnRequest ? 'Tạo đơn trả vật tư' : 'Hoàn trả vật tư về kho'; if (quantityLabel) quantityLabel.textContent = isReturnRequest ? 'Số lượng muốn trả' : 'Số lượng trả kho'; if (actorLabel) actorLabel.textContent = isReturnRequest ? 'Người tạo đơn' : 'Người nhận lại kho'; if (helpText) { helpText.textContent = isReturnRequest ? 'Vật tư chỉ được cộng lại vào tồn kho sau khi Asset/Admin duyệt đơn trả.' : 'Khi xác nhận, số lượng này sẽ được cộng lại vào tồn kho ngay.'; } if (submitBtn) submitBtn.textContent = isReturnRequest ? 'Gửi đơn trả' : 'Xác nhận trả kho'; modal.classList.add('open'); quantityInput.focus(); quantityInput.select(); } async handleConsumableReturnSubmit(e) { e.preventDefault(); const historyIdInput = document.getElementById('consumableReturnExportHistoryIdInput'); const quantityInput = document.getElementById('consumableReturnQuantityInput'); const noteInput = document.getElementById('consumableReturnNoteInput'); const exportHistoryId = Number(historyIdInput?.value || this.pendingConsumableReturnHistoryId); const history = this.consumableExportHistories.find(item => ( Number(item?.ExportHistoryId) === exportHistoryId )); if (!Number.isInteger(exportHistoryId) || exportHistoryId <= 0 || !history) { this.notifyFailure('Không xác định được phiếu xuất cần trả.'); return; } const returnAction = this.getConsumableReturnActionMeta(history); if (!returnAction) { this.notifyWarning('Vật tư này không còn số lượng có thể trả.'); return; } const returnQuantity = this.parseNonNegativeInteger(quantityInput?.value, 0); if (returnQuantity <= 0) { this.notifyWarning('Số lượng trả phải lớn hơn 0.'); return; } if (returnQuantity > returnAction.availableQuantity) { this.notifyWarning(`Số lượng trả (${returnQuantity}) vượt quá số lượng có thể trả (${returnAction.availableQuantity}).`); return; } try { const isReturnRequest = returnAction.mode === 'request'; const endpoint = isReturnRequest ? `${this.apiBase}/consumable-exports/${exportHistoryId}/return-request` : `${this.apiBase}/consumable-exports/${exportHistoryId}/return`; const response = await fetch(endpoint, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ quantity: returnQuantity, note: String(noteInput?.value || '').trim() }) }); const rawResponse = await response.text(); let data; try { data = rawResponse ? JSON.parse(rawResponse) : {}; } catch (_parseErr) { data = { success: false, message: `Máy chủ phản hồi lỗi HTTP ${response.status}` }; } if (!response.ok || !data.success) { this.notifyFailure(data.message || (isReturnRequest ? 'Tạo đơn trả vật tư thất bại' : 'Hoàn trả vật tư thất bại')); return; } this.pendingConsumableReturnHistoryId = undefined; document.getElementById('consumableReturnModal')?.classList.remove('open'); this.notifySuccess(data.message || (isReturnRequest ? 'Tạo đơn trả vật tư thành công' : 'Hoàn trả vật tư về kho thành công')); await Promise.all([ this.fetchConsumables(), this.fetchConsumableExportHistories(this.currentPage === 'consumable-exports' ? 2000 : 300), this.fetchConsumableBorrowRequests() ]); if (isReturnRequest) { document.getElementById('consumableExportHistoryModal')?.classList.remove('open'); await this.openConsumableBorrowRequestsModal(); return; } if (this.currentPage === 'consumables') { this.renderView('consumables'); } else if (this.currentPage === 'consumable-exports') { this.renderConsumableExportHistoryPageBody(); } if (document.getElementById('consumableExportHistoryModal')?.classList.contains('open')) { this.renderConsumableExportHistoryModal(); } } catch (err) { console.error(err); this.notifyFailure('Hoàn trả vật tư thất bại'); } } async handleConsumableExportSubmit(e) { e.preventDefault(); if (!this.ensureAssetManagePermission('xuat vat tu tieu hao')) { return; } const consumableIdInput = document.getElementById('consumableExportConsumableIdInput'); const targetTypeInput = document.getElementById('consumableExportTargetTypeInput'); const recipientInput = document.getElementById('consumableExportUserInput'); const projectInput = document.getElementById('consumableExportProjectInput'); const quantityInput = document.getElementById('consumableExportQuantityInput'); const noteInput = document.getElementById('consumableExportNoteInput'); const selectedConsumableId = Number(consumableIdInput?.value || this.pendingConsumableExportId); const targetType = String(targetTypeInput?.value || 'user').trim() === 'project' ? 'project' : 'user'; if (!Number.isFinite(selectedConsumableId) || selectedConsumableId <= 0) { this.notifyFailure('Không xác định được vật tư cần xuất.'); return; } const consumable = this.consumables.find(item => Number(item?.ConsumableId) === selectedConsumableId); if (!consumable) { this.notifyFailure('Không tìm thấy vật tư cần xuất.'); return; } const recipientUserId = Number(recipientInput?.value || 0); const recipientName = String( recipientInput?.selectedOptions?.[0]?.dataset?.userName || recipientInput?.selectedOptions?.[0]?.textContent || '' ).trim(); const projectName = String(projectInput?.value || '').trim(); if (targetType === 'user' && (!Number.isInteger(recipientUserId) || recipientUserId <= 0 || !recipientName)) { this.notifyWarning('Vui lòng chọn người nhận.'); return; } if (targetType === 'project' && !projectName) { this.notifyWarning('Vui lòng chọn dự án nhận.'); return; } const exportQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); if (exportQuantity <= 0) { this.notifyWarning('Số lượng xuất phải lớn hơn 0.'); return; } const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0); if (endingBalance <= 0) { this.notifyWarning('Vật tư đã hết tồn cuối kỳ, không thể xuất thêm.'); return; } if (exportQuantity > endingBalance) { this.notifyWarning(`Số lượng xuất (${exportQuantity}) vượt quá tồn cuối kỳ (${endingBalance}).`); return; } try { const response = await fetch(`${this.apiBase}/consumables/${selectedConsumableId}/export`, { method: 'POST', headers: this.getAuthHeaders(true), body: JSON.stringify({ quantity: exportQuantity, targetType, recipientUserId: targetType === 'user' ? recipientUserId : null, recipientName, projectName, note: String(noteInput?.value || '').trim() }) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xuất vật tư tiêu hao thất bại'); return; } this.pendingConsumableExportId = undefined; this.notifySuccess(targetType === 'project' ? 'Xuất vật tư cho dự án thành công' : 'Ghi nhận mượn vật tư thành công'); this.closeModals(); await this.refreshConsumablesUI(); const exportHistoryModal = document.getElementById('consumableExportHistoryModal'); if (exportHistoryModal?.classList.contains('open')) { await this.fetchConsumableExportHistories(); this.renderConsumableExportHistoryModal(); } } catch (err) { console.error(err); this.notifyFailure('Xuất vật tư tiêu hao thất bại'); } } async handleConsumableSubmit(e) { e.preventDefault(); if (!this.ensureAssetManagePermission('them hoac sua vat tu tieu hao')) { return; } const isEdit = this.editingConsumableId !== undefined; const payload = this.collectConsumableFormPayload(); if (!payload.consumableName) { this.notifyWarning('Vui lòng nhập tên vật tư.'); document.getElementById('consumableNameInput')?.focus(); return; } const url = isEdit ? `${this.apiBase}/consumables/${this.editingConsumableId}` : `${this.apiBase}/consumables`; 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 vật tư tiêu hao thất bại'); return; } this.editingConsumableId = undefined; this.notifySuccess(isEdit ? 'Cập nhật vật tư thành công' : 'Thêm vật tư thành công'); this.closeModals(); await this.refreshConsumablesUI(); } catch (err) { console.error(err); this.notifyFailure('Lưu vật tư tiêu hao thất bại'); } } async refreshConsumablesUI() { await this.fetchConsumables(); if (this.currentPage === 'consumables') { this.renderView('consumables'); } } setupConsumableRowListeners() { const canManageAssets = this.canCurrentUserManageAssets(); document.querySelectorAll('.edit-consumable').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { if (!this.ensureAssetManagePermission('sua vat tu tieu hao')) { return; } const consumableId = Number(btn.dataset.consumableId); const consumable = this.consumables.find(item => Number(item.ConsumableId) === consumableId); this.editingConsumableId = consumable?.ConsumableId; this.populateConsumableForm(consumable); this.closeModals(); this.openConsumableModal(); }); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.export-consumable').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { if (!canManageAssets || !this.ensureAssetManagePermission('xuat vat tu tieu hao')) { return; } const consumableId = Number(btn.dataset.consumableId); const consumable = this.consumables.find(item => Number(item.ConsumableId) === consumableId); this.openConsumableExportModal(consumable); }); btn.dataset.boundClick = 'true'; }); document.querySelectorAll('.delete-consumable').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { if (!canManageAssets || !this.ensureAssetManagePermission('xoa vat tu tieu hao')) { return; } const consumableId = Number(btn.dataset.consumableId); const consumable = this.consumables.find(item => Number(item.ConsumableId) === consumableId); this.pendingDeleteConsumableId = consumableId; const label = consumable?.ConsumableName || consumable?.ConsumableCode || '-'; document.getElementById('deleteConsumableName').textContent = label; document.getElementById('deleteConsumableModal')?.classList.add('open'); }); btn.dataset.boundClick = 'true'; }); } async confirmDeleteConsumable() { if (!this.ensureAssetManagePermission('xoa vat tu tieu hao')) { return; } const consumableId = Number(this.pendingDeleteConsumableId); if (!Number.isFinite(consumableId) || consumableId <= 0) { this.notifyFailure('Không xác định được vật tư cần xóa'); return; } try { const response = await fetch(`${this.apiBase}/consumables/${consumableId}`, { method: 'DELETE', headers: this.getAuthHeaders(false) }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Xóa vật tư thất bại'); return; } this.pendingDeleteConsumableId = undefined; this.notifySuccess('Xóa vật tư thành công'); this.closeModals(); await this.refreshConsumablesUI(); } catch (err) { console.error(err); this.notifyFailure('Xóa vật tư thất bại'); } } async processConsumableImportFile(event) { if (!this.ensureAssetManagePermission('nhap du lieu vat tu tieu hao')) { event.target.value = ''; return; } const file = event.target.files?.[0]; event.target.value = ''; if (!file) { return; } try { const formData = new FormData(); formData.append('file', file); const response = await fetch(`${this.apiBase}/consumables/import`, { method: 'POST', headers: this.getAuthHeaders(false), body: formData }); const data = await response.json(); if (!response.ok || !data.success) { this.notifyFailure(data.message || 'Import vật tư tiêu hao thất bại'); return; } const inserted = data.data?.inserted ?? 0; const updated = data.data?.updated ?? 0; this.notifySuccess(`Import VTTH xong: thêm ${inserted}, cập nhật ${updated}`); await this.refreshConsumablesUI(); } catch (err) { console.error(err); this.notifyFailure('Import vật tư tiêu hao thất bại'); } } exportConsumablesToExcel() { if (!window.XLSX) { this.notifyFailure('Không tìm thấy thư viện xuất Excel'); return; } try { const exportRows = this.getFilteredConsumables().map(item => ({ 'STT': '', 'Tháng đề xuất': item.RequestMonth || '', 'Mã vật tư': item.ConsumableCode || '', 'Tên linh kiện/sp': item.ConsumableName || '', 'Model': item.Model || '', 'ĐVT': item.Unit || '', 'Tồn đầu kỳ': item.OpeningBalance ?? 0, 'Nhập trong kì': item.ImportInPeriod ?? 0, 'Xuất trong kì': item.ExportInPeriod ?? 0, 'Đã xuất': item.ExportedSummary || '', 'Người đang nhận': item.RecipientSummary || '', 'Dự án nhận': item.ProjectSummary || '', 'Tồn cuối kì': item.EndingBalance ?? 0, 'Lí do xuất': item.ExportReason || '' })).map((row, index) => ({ ...row, 'STT': index + 1 })); const worksheet = window.XLSX.utils.json_to_sheet(exportRows); const workbook = window.XLSX.utils.book_new(); window.XLSX.utils.book_append_sheet(workbook, worksheet, 'VTTH'); const timestamp = this.formatTimestampForCode(new Date()).slice(0, 8); window.XLSX.writeFile(workbook, `vat-tu-tieu-hao-${timestamp}.xlsx`); } catch (err) { console.error(err); this.notifyFailure('Xuất vật tư tiêu hao thất bại'); } } exportConsumableHistoryToExcel() { if (!window.XLSX) { this.notifyFailure('Không tìm thấy thư viện xuất Excel'); return; } try { const rows = this.getFilteredConsumableExportHistories().map((item, index) => ({ 'STT': index + 1, 'Ngày giờ': this.formatDateTime(item?.ExportedDate || item?.CreatedDate), 'Mã vật tư': item?.ConsumableCode || '', 'Tên vật tư': item?.ConsumableName || '', 'Số lượng xuất': Number(item?.ExportQuantity) || 0, 'Đã trả kho': Number(item?.ReturnedQuantity) || 0, 'Còn phải trả': Number(item?.RemainingQuantity) || 0, 'ĐVT': item?.Unit || '', 'Người nhận': item?.RecipientName || '', 'Dự án nhận': item?.ProjectName || '', 'Trạng thái': this.getConsumableReturnStatusMeta(item).label, 'Người xuất': item?.ExportedByName || '', 'Lần trả gần nhất': this.formatDateTime(item?.LastReturnedDate), 'Người nhận lại gần nhất': item?.LastReturnedByName || '', 'Ghi chú trả gần nhất': item?.LastReturnNote || '', 'Tồn trước': Number(item?.PreviousEndingBalance) || 0, 'Tồn sau': Number(item?.NextEndingBalance) || 0, 'Ghi chú': item?.ExportNote || '' })); const worksheet = window.XLSX.utils.json_to_sheet(rows); const workbook = window.XLSX.utils.book_new(); window.XLSX.utils.book_append_sheet(workbook, worksheet, 'LichSuXuatVTTH'); const timestamp = this.formatTimestampForCode(new Date()).slice(0, 8); window.XLSX.writeFile(workbook, `lich-su-xuat-vtth-${timestamp}.xlsx`); } catch (err) { console.error(err); this.notifyFailure('Xuất lịch sử vật tư tiêu hao thất bại'); } } async refreshAssetsUI() { await this.fetchAssets(); await this.fetchAssetDepartments(); await this.fetchAssetProjects(); if (this.currentPage === 'assets' || this.currentPage === 'my-borrowed-assets') { this.renderView(this.currentPage); } } 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 timestamp = this.formatTimestampForCode(new Date()).slice(0, 8); 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(); }); }); document.querySelectorAll('#addConsumableBtn').forEach(btn => { if (btn.dataset.boundClick === 'true') { return; } btn.addEventListener('click', () => { this.editingConsumableId = undefined; this.openConsumableModal(); }); btn.dataset.boundClick = 'true'; }); const createConsumableBorrowRequestBtn = document.getElementById('createConsumableBorrowRequestBtn'); if (createConsumableBorrowRequestBtn && !createConsumableBorrowRequestBtn.dataset.boundClick) { createConsumableBorrowRequestBtn.addEventListener('click', () => this.openConsumableBorrowRequestModal()); createConsumableBorrowRequestBtn.dataset.boundClick = 'true'; } const openConsumableBorrowRequestsBtn = document.getElementById('openConsumableBorrowRequestsBtn'); if (openConsumableBorrowRequestsBtn && !openConsumableBorrowRequestsBtn.dataset.boundClick) { openConsumableBorrowRequestsBtn.addEventListener('click', () => this.openConsumableBorrowRequestsModal()); openConsumableBorrowRequestsBtn.dataset.boundClick = 'true'; } const openRejectedConsumableReturnRequestsBtn = document.getElementById('openRejectedConsumableReturnRequestsBtn'); if (openRejectedConsumableReturnRequestsBtn && !openRejectedConsumableReturnRequestsBtn.dataset.boundClick) { openRejectedConsumableReturnRequestsBtn.addEventListener('click', () => this.openConsumableBorrowRequestsModal()); openRejectedConsumableReturnRequestsBtn.dataset.boundClick = 'true'; } const addAssetDepartmentBtn = document.getElementById('addAssetDepartmentBtn'); if (addAssetDepartmentBtn && !addAssetDepartmentBtn.dataset.boundClick) { addAssetDepartmentBtn.addEventListener('click', () => this.handleCreateAssetDepartment()); addAssetDepartmentBtn.dataset.boundClick = 'true'; } const addAssetProjectBtn = document.getElementById('addAssetProjectBtn'); if (addAssetProjectBtn && !addAssetProjectBtn.dataset.boundClick) { addAssetProjectBtn.addEventListener('click', () => this.handleCreateAssetProject()); addAssetProjectBtn.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 damageAssetBtn = document.getElementById('damageAssetBtn'); if (damageAssetBtn && !damageAssetBtn.dataset.boundClick) { damageAssetBtn.addEventListener('click', () => this.openAssetDamageModal()); damageAssetBtn.dataset.boundClick = 'true'; } const importAssetBtn = document.getElementById('importAssetBtn'); const assetImportInput = document.getElementById('assetImportInput'); const exportAssetBtn = document.getElementById('exportAssetBtn'); const importConsumableBtn = document.getElementById('importConsumableBtn'); const consumableImportInput = document.getElementById('consumableImportInput'); const exportConsumableBtn = document.getElementById('exportConsumableBtn'); const openConsumableExportHistoryBtn = document.getElementById('openConsumableExportHistoryBtn'); const refreshConsumableExportHistoryPageBtn = document.getElementById('refreshConsumableExportHistoryPageBtn'); const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn'); const openAssetDamageHistoryBtn = document.getElementById('openAssetDamageHistoryBtn'); 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'; } if (importConsumableBtn && consumableImportInput && !importConsumableBtn.dataset.boundClick) { importConsumableBtn.addEventListener('click', () => { if (!this.ensureAssetManagePermission('nhap du lieu vat tu tieu hao')) { return; } consumableImportInput.click(); }); importConsumableBtn.dataset.boundClick = 'true'; } if (consumableImportInput && !consumableImportInput.dataset.boundChange) { consumableImportInput.addEventListener('change', (event) => this.processConsumableImportFile(event)); consumableImportInput.dataset.boundChange = 'true'; } if (exportConsumableBtn && !exportConsumableBtn.dataset.boundClick) { exportConsumableBtn.addEventListener('click', () => this.exportConsumablesToExcel()); exportConsumableBtn.dataset.boundClick = 'true'; } if (openConsumableExportHistoryBtn && !openConsumableExportHistoryBtn.dataset.boundClick) { openConsumableExportHistoryBtn.addEventListener('click', () => this.openConsumableExportHistoryModal()); openConsumableExportHistoryBtn.dataset.boundClick = 'true'; } if (refreshConsumableExportHistoryPageBtn && !refreshConsumableExportHistoryPageBtn.dataset.boundClick) { refreshConsumableExportHistoryPageBtn.addEventListener('click', () => this.refreshConsumableExportsPage()); refreshConsumableExportHistoryPageBtn.dataset.boundClick = 'true'; } if (openAssetExportHistoryBtn && !openAssetExportHistoryBtn.dataset.boundClick) { openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal()); openAssetExportHistoryBtn.dataset.boundClick = 'true'; } if (openAssetDamageHistoryBtn && !openAssetDamageHistoryBtn.dataset.boundClick) { openAssetDamageHistoryBtn.addEventListener('click', () => this.openAssetDamageHistoryModal()); openAssetDamageHistoryBtn.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'; }); } const consumableMonthFilter = document.getElementById('consumableMonthFilter'); if (consumableMonthFilter) { consumableMonthFilter.value = this.consumableMonthFilter || ''; consumableMonthFilter.addEventListener('change', (e) => { this.consumableMonthFilter = String(e.target.value || ''); this.consumablePage = 1; this.renderConsumablesTableBody(); }); } const consumableStatusFilter = document.getElementById('consumableStatusFilter'); if (consumableStatusFilter) { consumableStatusFilter.value = this.consumableStatusFilter || ''; consumableStatusFilter.addEventListener('change', (e) => { this.consumableStatusFilter = String(e.target.value || ''); this.consumablePage = 1; this.renderConsumablesTableBody(); }); } const consumableSearch = document.getElementById('consumableSearch'); if (consumableSearch) { consumableSearch.value = this.consumableSearchTerm; const handleConsumableSearch = event => { this.consumableSearchTerm = event.target.value.toLowerCase(); this.consumablePage = 1; this.renderConsumablesTableBody(); }; consumableSearch.addEventListener('input', handleConsumableSearch); consumableSearch.addEventListener('focus', () => { consumableSearch.dataset.focused = 'true'; }); consumableSearch.addEventListener('blur', () => { consumableSearch.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() { if (this.pendingAssetRequestDeleteConfirmResolver) { this.resolveAssetRequestDeleteConfirm(false); } if (this.pendingBulkAssetDeleteConfirmResolver) { this.resolveBulkAssetDeleteConfirm(false); } document.querySelectorAll('.modal-backdrop').forEach(modal => { modal.classList.remove('open'); }); this.pendingConsumableRequestRejectId = undefined; } 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 APP_DATE_TIME_FORMATTER.format(date); } // ========== 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() { if (app?.pendingAssetRequestDeleteConfirmResolver) { app.resolveAssetRequestDeleteConfirm(false); } if (app?.pendingBulkAssetDeleteConfirmResolver) { app.resolveBulkAssetDeleteConfirm(false); } document.querySelectorAll('.modal-backdrop').forEach(modal => { modal.classList.remove('open'); }); if (app) { app.pendingConsumableRequestRejectId = undefined; } } 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 closeConsumableModal() { document.getElementById('consumableModal').classList.remove('open'); } function closeDeleteConsumableModal() { document.getElementById('deleteConsumableModal').classList.remove('open'); } function closeConsumableExportModal() { const modal = document.getElementById('consumableExportModal'); if (modal) { modal.classList.remove('open'); } } function closeConsumableBorrowRequestModal() { const modal = document.getElementById('consumableBorrowRequestModal'); if (modal) { modal.classList.remove('open'); } if (typeof app !== 'undefined') { app.closeConsumableBorrowProductDropdown(); } } function closeConsumableBorrowRequestsModal() { const modal = document.getElementById('consumableBorrowRequestsModal'); if (modal) { modal.classList.remove('open'); } closeConsumableRequestRejectModal(); } function closeConsumableRequestRejectModal() { const modal = document.getElementById('consumableRequestRejectModal'); const idInput = document.getElementById('consumableRequestRejectIdInput'); const reasonInput = document.getElementById('consumableRequestRejectReasonInput'); if (modal) { modal.classList.remove('open'); } if (idInput) { idInput.value = ''; } if (reasonInput) { reasonInput.value = ''; } if (app) { app.pendingConsumableRequestRejectId = undefined; } } function closeConsumableReturnModal() { const modal = document.getElementById('consumableReturnModal'); if (modal) { modal.classList.remove('open'); } if (app) { app.pendingConsumableReturnHistoryId = undefined; } } function closeConsumableExportHistoryModal() { const modal = document.getElementById('consumableExportHistoryModal'); if (modal) { modal.classList.remove('open'); } } function closeViewAssetModal() { document.getElementById('viewAssetModal').classList.remove('open'); } function closeDeleteAssetModal() { document.getElementById('deleteAssetModal').classList.remove('open'); } function closeBulkDeleteAssetsConfirmModal() { if (app?.pendingBulkAssetDeleteConfirmResolver) { app.resolveBulkAssetDeleteConfirm(false); return; } const modal = document.getElementById('bulkDeleteAssetsConfirmModal'); if (modal) { modal.classList.remove('open'); } } function closeBorrowAssetModal() { const modal = document.getElementById('borrowAssetModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetDamageModal() { const modal = document.getElementById('assetDamageModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetExportHistoryModal() { const modal = document.getElementById('assetExportHistoryModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetDamageHistoryModal() { const modal = document.getElementById('assetDamageHistoryModal'); 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'); } closeAssetRequestDeleteConfirmModal(); const rejectModal = document.getElementById('assetRequestRejectModal'); if (rejectModal) { rejectModal.classList.remove('open'); } } function closeAssetBorrowDetailsModal() { const modal = document.getElementById('assetBorrowDetailsModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetRequestRejectModal() { const modal = document.getElementById('assetRequestRejectModal'); if (modal) { modal.classList.remove('open'); } } function closeAssetRequestDeleteConfirmModal() { if (app?.pendingAssetRequestDeleteConfirmResolver) { app.resolveAssetRequestDeleteConfirm(false); return; } const modal = document.getElementById('assetRequestDeleteConfirmModal'); 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 closeAssetProjectModal() { const modal = document.getElementById('assetProjectModal'); if (modal) { modal.classList.remove('open'); } } function closeDeleteAssetProjectModal() { const modal = document.getElementById('deleteAssetProjectModal'); 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(); });