diff --git a/backend/server.js b/backend/server.js index b69b70d..e27940c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -209,6 +209,54 @@ async function getUserDisplayNameById(userId) { } } +function normalizeDepartmentName(value) { + return String(value || '').trim(); +} + +async function syncAssetDepartmentsFromInventory() { + if (!pool) { + return; + } + + await pool.request().query(` + WITH SourceDepartments AS ( + SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName + FROM AssetInventory + WHERE Department IS NOT NULL + AND LTRIM(RTRIM(Department)) <> '' + ) + INSERT INTO AssetDepartments (DepartmentName) + SELECT source.DepartmentName + FROM SourceDepartments source + WHERE NOT EXISTS ( + SELECT 1 + FROM AssetDepartments target + WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName) + ); + `); +} + +async function ensureDepartmentExists(departmentName) { + const normalized = normalizeDepartmentName(departmentName); + if (!normalized || !pool) { + return; + } + + await pool.request() + .input('departmentName', sql.NVarChar, normalized) + .query(` + IF NOT EXISTS ( + SELECT 1 + FROM AssetDepartments + WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName) + ) + BEGIN + INSERT INTO AssetDepartments (DepartmentName) + VALUES (@departmentName); + END + `); +} + function parsePositiveInteger(value, fallback = 1) { const parsed = Number(value); if (Number.isInteger(parsed) && parsed > 0) { @@ -678,6 +726,44 @@ function generateImportAssetCodeFromRow(mapped, rowNumber = 0) { return `IMP-${base}-${suffix}`; } +function generateManualAssetCodeFromPayload(payload = {}) { + const fromModel = sanitizeAssetCodeToken(payload.model); + const fromSerial = sanitizeAssetCodeToken(payload.serialNumber); + const fromName = sanitizeAssetCodeToken(payload.assetName); + const base = (fromModel || fromSerial || fromName || 'ASSET').slice(0, 32); + const now = new Date(); + const timestamp = [ + String(now.getFullYear()), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0'), + String(now.getMilliseconds()).padStart(3, '0') + ].join(''); + const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0'); + return `AST-${base}-${timestamp}${randomSuffix}`; +} + +async function generateUniqueManualAssetCode(payload = {}, maxAttempts = 8) { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidate = generateManualAssetCodeFromPayload(payload); + const existed = await pool.request() + .input('assetCode', sql.NVarChar, candidate) + .query(` + SELECT TOP 1 AssetId + FROM AssetInventory + WHERE AssetCode = @assetCode + `); + + if (existed.recordset.length === 0) { + return candidate; + } + } + + throw new Error('Cannot generate unique asset code'); +} + function finalizeImportedAssetPayload(mapped, rowNumber = 0) { const result = { ...mapped }; if (!result.assetName) { @@ -1246,6 +1332,17 @@ async function createTables() { FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL ) END`, + + // Asset Departments Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments') + BEGIN + CREATE TABLE AssetDepartments ( + DepartmentId INT PRIMARY KEY IDENTITY(1,1), + DepartmentName NVARCHAR(100) NOT NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE() + ) + END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') @@ -1276,10 +1373,18 @@ async function createTables() { try { await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);`); } catch (err) { console.error('AssetInventory index creation error:', err.message); } + // Ensure AssetDepartments indexes exist + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`); + } catch (err) { + console.error('AssetDepartments index creation error:', err.message); + } + // Ensure new columns exist on Applications for migrations try { await pool.request().query(`IF EXISTS ( @@ -1324,6 +1429,13 @@ async function createTables() { console.error('Column addition error (Applications):', err.message); } + // Sync legacy departments from AssetInventory to AssetDepartments + try { + await syncAssetDepartmentsFromInventory(); + } catch (err) { + console.error('AssetDepartments sync error:', err.message); + } + // Insert initial admin user try { const adminPasswordHash = await hashPassword('admin'); @@ -2341,6 +2453,224 @@ app.delete('/api/accounts/:id', async (req, res) => { } }); +// ========================================== +// API ROUTES - Asset Departments +// ========================================== + +app.get('/api/asset-departments', async (req, res) => { + try { + await syncAssetDepartmentsFromInventory(); + + const result = await pool.request().query(` + SELECT + d.DepartmentId, + d.DepartmentName, + d.CreatedDate, + d.UpdatedDate, + COUNT(ai.AssetId) AS AssetCount + FROM AssetDepartments d + LEFT JOIN AssetInventory ai + ON LOWER(LTRIM(RTRIM(ai.Department))) = LOWER(LTRIM(RTRIM(d.DepartmentName))) + GROUP BY d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate + ORDER BY d.DepartmentName ASC + `); + + res.json({ success: true, data: result.recordset }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => { + try { + const departmentName = normalizeDepartmentName(req.body?.departmentName); + if (!departmentName) { + return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' }); + } + + await syncAssetDepartmentsFromInventory(); + + const existed = await pool.request() + .input('departmentName', sql.NVarChar, departmentName) + .query(` + SELECT TOP 1 DepartmentId + FROM AssetDepartments + WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName) + `); + + if (existed.recordset.length > 0) { + return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + } + + const inserted = await pool.request() + .input('departmentName', sql.NVarChar, departmentName) + .query(` + INSERT INTO AssetDepartments (DepartmentName) + VALUES (@departmentName); + SELECT SCOPE_IDENTITY() AS DepartmentId; + `); + + res.json({ + success: true, + message: 'Đã thêm phòng ban', + departmentId: inserted.recordset[0]?.DepartmentId + }); + } catch (err) { + if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) { + return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + } + + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { + try { + const departmentId = Number(req.params.id); + if (!Number.isInteger(departmentId) || departmentId <= 0) { + return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' }); + } + + const departmentName = normalizeDepartmentName(req.body?.departmentName); + if (!departmentName) { + return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' }); + } + + await syncAssetDepartmentsFromInventory(); + + const currentResult = await pool.request() + .input('departmentId', sql.Int, departmentId) + .query(` + SELECT DepartmentId, DepartmentName + FROM AssetDepartments + WHERE DepartmentId = @departmentId + `); + + if (currentResult.recordset.length === 0) { + return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' }); + } + + const currentDepartment = currentResult.recordset[0]; + const currentName = String(currentDepartment.DepartmentName || '').trim(); + + if (currentName.toLowerCase() === departmentName.toLowerCase()) { + return res.json({ success: true, message: 'Đã cập nhật phòng ban' }); + } + + const duplicated = await pool.request() + .input('departmentName', sql.NVarChar, departmentName) + .input('departmentId', sql.Int, departmentId) + .query(` + SELECT TOP 1 DepartmentId + FROM AssetDepartments + WHERE DepartmentId <> @departmentId + AND LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName) + `); + + if (duplicated.recordset.length > 0) { + return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + } + + const transaction = new sql.Transaction(pool); + await transaction.begin(); + + try { + await new sql.Request(transaction) + .input('departmentId', sql.Int, departmentId) + .input('departmentName', sql.NVarChar, departmentName) + .query(` + UPDATE AssetDepartments + SET DepartmentName = @departmentName, + UpdatedDate = GETDATE() + WHERE DepartmentId = @departmentId + `); + + await new sql.Request(transaction) + .input('oldDepartmentName', sql.NVarChar, currentName) + .input('newDepartmentName', sql.NVarChar, departmentName) + .query(` + UPDATE AssetInventory + SET Department = @newDepartmentName, + UpdatedDate = GETDATE() + WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@oldDepartmentName) + `); + + await transaction.commit(); + res.json({ success: true, message: 'Đã cập nhật phòng ban' }); + } catch (transactionErr) { + try { + await transaction.rollback(); + } catch (rollbackErr) { + // Ignore rollback errors if transaction already ended. + } + throw transactionErr; + } + } catch (err) { + if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) { + return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + } + + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { + try { + const departmentId = Number(req.params.id); + if (!Number.isInteger(departmentId) || departmentId <= 0) { + return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' }); + } + + await syncAssetDepartmentsFromInventory(); + + const currentResult = await pool.request() + .input('departmentId', sql.Int, departmentId) + .query(` + SELECT DepartmentId, DepartmentName + FROM AssetDepartments + WHERE DepartmentId = @departmentId + `); + + if (currentResult.recordset.length === 0) { + return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' }); + } + + const departmentName = String(currentResult.recordset[0].DepartmentName || '').trim(); + const transaction = new sql.Transaction(pool); + await transaction.begin(); + + try { + await new sql.Request(transaction) + .input('departmentName', sql.NVarChar, departmentName) + .query(` + UPDATE AssetInventory + SET Department = NULL, + UpdatedDate = GETDATE() + WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@departmentName) + `); + + await new sql.Request(transaction) + .input('departmentId', sql.Int, departmentId) + .query(` + DELETE FROM AssetDepartments + WHERE DepartmentId = @departmentId + `); + + await transaction.commit(); + res.json({ success: true, message: 'Đã xóa phòng ban' }); + } catch (transactionErr) { + try { + await transaction.rollback(); + } catch (rollbackErr) { + // Ignore rollback errors if transaction already ended. + } + throw transactionErr; + } + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + // ========================================== // API ROUTES - Asset Inventory // ========================================== @@ -2391,10 +2721,16 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => { const createdBy = getUserIdFromRequest(req); const exportedBy = await getUserDisplayNameById(createdBy); - if (!payload.assetCode || !payload.assetName) { - return res.status(400).json({ success: false, message: 'Asset code and asset name are required' }); + if (!payload.assetName) { + return res.status(400).json({ success: false, message: 'Asset name is required' }); } + if (!payload.assetCode) { + payload.assetCode = await generateUniqueManualAssetCode(payload); + } + + await ensureDepartmentExists(payload.department); + const result = await pool.request() .input('assetCode', sql.NVarChar, payload.assetCode) .input('assetName', sql.NVarChar, payload.assetName) @@ -2451,6 +2787,8 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => { return res.status(400).json({ success: false, message: 'Asset code and asset name are required' }); } + await ensureDepartmentExists(payload.department); + await pool.request() .input('assetId', sql.Int, req.params.id) .input('assetCode', sql.NVarChar, payload.assetCode) @@ -2561,7 +2899,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async const createdBy = getUserIdFromRequest(req); const exportedBy = await getUserDisplayNameById(createdBy); const normalizedRows = incomingRows - .map(row => normalizeAssetPayload(row)) + .map((row, rowIndex) => { + const normalized = normalizeAssetPayload(row); + if (!normalized.assetCode && normalized.assetName) { + normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1); + } + return normalized; + }) .filter(row => !isHeaderLikeAssetImportRow(row)) .filter(row => row.assetCode && row.assetName); @@ -2648,6 +2992,7 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async } await transaction.commit(); + await syncAssetDepartmentsFromInventory(); res.json({ success: true, diff --git a/database/setup.sql b/database/setup.sql index a992592..f8992a2 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -125,7 +125,36 @@ BEGIN END -- =========================================== --- 5. CREATE AUDIT LOG TABLE +-- 5. CREATE ASSET DEPARTMENTS TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments') +BEGIN + CREATE TABLE AssetDepartments ( + DepartmentId INT PRIMARY KEY IDENTITY(1,1), + DepartmentName NVARCHAR(100) NOT NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE() + ); + PRINT 'Table AssetDepartments created successfully.'; +END + +;WITH SourceDepartments AS ( + SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName + FROM AssetInventory + WHERE Department IS NOT NULL + AND LTRIM(RTRIM(Department)) <> '' +) +INSERT INTO AssetDepartments (DepartmentName) +SELECT source.DepartmentName +FROM SourceDepartments source +WHERE NOT EXISTS ( + SELECT 1 + FROM AssetDepartments target + WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName) +); + +-- =========================================== +-- 6. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -144,7 +173,7 @@ BEGIN END -- =========================================== --- 6. CREATE INDEXES +-- 7. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -171,10 +200,20 @@ BEGIN CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') +BEGIN + CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') +BEGIN + CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName); +END + PRINT 'Indexes created successfully.'; -- =========================================== --- 7. INSERT INITIAL DATA +-- 8. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists @@ -198,7 +237,7 @@ BEGIN END -- =========================================== --- 8. DISPLAY DATABASE INFORMATION +-- 9. DISPLAY DATABASE INFORMATION -- =========================================== PRINT ''; PRINT '========================================'; diff --git a/public/js/app.js b/public/js/app.js index efa9bee..b284418 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -33,6 +33,8 @@ class AccountManager { this.userRoleFilter = ''; this.assetSearchTerm = ''; this.assetStatusFilter = ''; + this.assetDepartments = []; + this.assetDepartmentSearchTerm = ''; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; @@ -41,6 +43,8 @@ class AccountManager { this.pendingAccountAppId = undefined; this.editingAssetBorrowerEntries = []; this.pendingBorrowAssetId = undefined; + this.editingAssetDepartmentId = undefined; + this.pendingDeleteAssetDepartmentId = undefined; } configureNotifications() { @@ -127,12 +131,12 @@ class AccountManager { return role === 'admin' || role === 'asset'; } - ensureAssetManagePermission(actionLabel = 'thuc hien thao tac nay') { + ensureAssetManagePermission(actionLabel = 'thực hiện thao tác này') { if (this.canCurrentUserManageAssets()) { return true; } - this.notifyWarning(`Ban chi co quyen xem tai san. Chi role Asset/Admin moi duoc ${actionLabel}.`); + this.notifyWarning(`Bạn chỉ có quyền xem tài sản. Chỉ role Asset/Admin mới được ${actionLabel}.`); return false; } @@ -153,6 +157,7 @@ class AccountManager { await this.fetchApplications(); await this.fetchAccounts(); await this.fetchAssets(); + await this.fetchAssetDepartments(); if (this.canCurrentUserManageAssets()) { await this.fetchUsers(); @@ -201,6 +206,10 @@ class AccountManager { this.setupAddButtonListeners(); this.setupFilters(); this.setupAssetPagerListeners(); + } else if (page === 'asset-departments') { + mainContent.innerHTML = this.getAssetDepartmentsContent(); + this.setupAssetDepartmentListeners(); + this.setupAddButtonListeners(); } else if (page === 'accounts') { mainContent.innerHTML = this.getAccountsContent(); this.setupAccountRowListeners(); @@ -422,6 +431,59 @@ class AccountManager { }); } + getUniqueAssetDepartmentNames() { + const rows = Array.isArray(this.assetDepartments) ? this.assetDepartments : []; + const seen = new Set(); + return rows + .map(item => String(item?.DepartmentName || '').trim()) + .filter(name => { + if (!name) return false; + const key = name.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' })); + } + + refreshAssetDepartmentOptions(selectedValue = '') { + const select = document.getElementById('assetDepartmentInput'); + if (!select) { + return; + } + + const normalizedSelected = String(selectedValue || select.value || '').trim(); + const departmentNames = this.getUniqueAssetDepartmentNames(); + select.innerHTML = ''; + + const emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = '-- Chọn phòng ban --'; + select.appendChild(emptyOption); + + let hasSelected = false; + departmentNames.forEach(name => { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + if (normalizedSelected && name === normalizedSelected) { + option.selected = true; + hasSelected = true; + } + select.appendChild(option); + }); + + if (normalizedSelected && !hasSelected) { + const legacyOption = document.createElement('option'); + legacyOption.value = normalizedSelected; + legacyOption.textContent = normalizedSelected; + legacyOption.selected = true; + select.appendChild(legacyOption); + } else if (!normalizedSelected) { + select.value = ''; + } + } + async fetchAssets() { try { const res = await fetch(`${this.apiBase}/assets`); @@ -437,6 +499,21 @@ class AccountManager { } } + async fetchAssetDepartments() { + try { + const res = await fetch(`${this.apiBase}/asset-departments`); + const data = await res.json(); + if (data.success) { + this.assetDepartments = Array.isArray(data.data) ? data.data : []; + this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); + } else { + console.error('Load asset departments failed:', data.message); + } + } catch (err) { + console.error('Fetch asset departments error:', err); + } + } + async fetchRoles() { try { const res = await fetch(`${this.apiBase}/roles`); @@ -472,6 +549,7 @@ class AccountManager { this.setupAccountRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); + this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); } catch (error) { console.error('Lỗi load modals:', error); } @@ -481,6 +559,7 @@ class AccountManager { const accountSearch = document.getElementById('accountSearch'); const appSearch = document.getElementById('appSearch'); const assetSearch = document.getElementById('assetSearch'); + const assetDepartmentSearch = document.getElementById('assetDepartmentSearch'); if (accountSearch && accountSearch.dataset.focused === 'true') { const pos = accountSearch.selectionStart || accountSearch.value.length; @@ -499,6 +578,12 @@ class AccountManager { assetSearch.focus(); assetSearch.setSelectionRange(pos, pos); } + + if (assetDepartmentSearch && assetDepartmentSearch.dataset.focused === 'true') { + const pos = assetDepartmentSearch.selectionStart || assetDepartmentSearch.value.length; + assetDepartmentSearch.focus(); + assetDepartmentSearch.setSelectionRange(pos, pos); + } } setupEventListeners() { @@ -559,7 +644,9 @@ class AccountManager { assetForm.addEventListener('submit', (e) => this.handleAssetSubmit(e)); assetForm.dataset.boundSubmit = 'true'; } + this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); this.setupAssetStockListeners(); + this.setupAssetFormValidationListeners(); } const borrowAssetForm = document.getElementById('borrowAssetForm'); @@ -570,6 +657,23 @@ class AccountManager { } } + const assetDepartmentForm = document.getElementById('assetDepartmentForm'); + if (assetDepartmentForm) { + if (!assetDepartmentForm.dataset.boundSubmit) { + assetDepartmentForm.addEventListener('submit', (e) => this.handleAssetDepartmentSubmit(e)); + assetDepartmentForm.dataset.boundSubmit = 'true'; + } + } + + document.querySelectorAll('.confirm-delete-asset-department').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => this.confirmDeleteAssetDepartment()); + btn.dataset.boundClick = 'true'; + }); + // Close when clicking backdrop outside modal content document.querySelectorAll('.modal-backdrop').forEach(backdrop => { backdrop.addEventListener('click', (evt) => { @@ -905,36 +1009,7 @@ class AccountManager { - -
Active
-${this.applications.filter(a => a.status === 'online').length}
-Total
-${this.applications.length}
-Health
-99.9%
-Thêm, sửa, xóa danh mục phòng ban sử dụng trong tài sản.
+| STT | +Phòng ban | +Số tài sản | +Thao tác | +
|---|
| @@ -1349,7 +1769,7 @@ class AccountManager { | Ghi chú | Ngày tạo | Người xuất | -Thao tác | +Thao tác | ${asset.Notes || '-'} | ${this.formatDateOnly(asset.CreatedDate)} | ${asset.ExportedBy || '-'} | -+ |
|
|---|