diff --git a/backend/server.js b/backend/server.js index b766f89..6ea0036 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1724,6 +1724,27 @@ async function createTables() { FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL ) END`, + + // Asset Export History Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory') + BEGIN + CREATE TABLE AssetExportHistory ( + ExportHistoryId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + AssetCode NVARCHAR(100) NOT NULL, + AssetName NVARCHAR(255) NOT NULL, + ExportQuantity INT NOT NULL DEFAULT 1, + ProjectName NVARCHAR(150) NULL, + CustodianName NVARCHAR(100) NOT NULL, + ExportedByName NVARCHAR(100) NOT NULL, + ExportNote NVARCHAR(1000) NULL, + CreatedBy INT NULL, + ExportedDate DATETIME NOT NULL DEFAULT GETDATE(), + CreatedDate DATETIME NOT NULL DEFAULT GETDATE(), + UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(), + FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE + ) + END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') @@ -1783,6 +1804,14 @@ async function createTables() { console.error('AssetBorrowRequests index creation error:', err.message); } + // Ensure AssetExportHistory indexes exist + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId') CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_ExportedDate') CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC);`); + } catch (err) { + console.error('AssetExportHistory index creation error:', err.message); + } + // Ensure new columns exist on Applications for migrations try { await pool.request().query(`IF EXISTS ( @@ -1820,6 +1849,34 @@ async function createTables() { FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL; `); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE());`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','AssetCode') IS NULL ALTER TABLE AssetExportHistory ADD AssetCode NVARCHAR(100) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','AssetName') IS NULL ALTER TABLE AssetExportHistory ADD AssetName NVARCHAR(255) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportQuantity') IS NULL ALTER TABLE AssetExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_AssetExportHistory_ExportQuantity DEFAULT(1);`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ProjectName') IS NULL ALTER TABLE AssetExportHistory ADD ProjectName NVARCHAR(150) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CustodianName') IS NULL ALTER TABLE AssetExportHistory ADD CustodianName NVARCHAR(100) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportedByName') IS NULL ALTER TABLE AssetExportHistory ADD ExportedByName NVARCHAR(100) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportNote') IS NULL ALTER TABLE AssetExportHistory ADD ExportNote NVARCHAR(1000) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CreatedBy') IS NULL ALTER TABLE AssetExportHistory ADD CreatedBy INT NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportedDate') IS NULL ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(GETDATE());`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CreatedDate') IS NULL ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(GETDATE());`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','UpdatedDate') IS NULL ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(GETDATE());`); + await pool.request().query(` + IF NOT EXISTS ( + SELECT 1 + FROM sys.foreign_key_columns fkc + INNER JOIN sys.columns c + ON c.object_id = fkc.parent_object_id + AND c.column_id = fkc.parent_column_id + WHERE fkc.parent_object_id = OBJECT_ID('dbo.AssetExportHistory') + AND c.name = 'CreatedBy' + ) + AND COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NOT NULL + BEGIN + ALTER TABLE AssetExportHistory + ADD CONSTRAINT FK_AssetExportHistory_CreatedBy + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL; + END + `); await pool.request().query(`UPDATE AssetBorrowRequests SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow');`); await pool.request().query(`UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`); await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`); @@ -3711,10 +3768,19 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) await new sql.Request(transaction) .input('assetId', sql.Int, targetRequest.AssetId) .input('borrower', sql.NVarChar, mergedBorrowerSummary) + .input('exportInPeriod', sql.Int, currentBorrowed + requestQuantity) + .input('endingBalance', sql.Int, Math.max( + parseNonNegativeInteger(targetRequest.Quantity, 0) + + parseNonNegativeInteger(targetRequest.ImportInPeriod, 0) + - (currentBorrowed + requestQuantity), + 0 + )) .input('exportedBy', sql.NVarChar, processorName || null) .query(` UPDATE AssetInventory SET Borrower = @borrower, + ExportInPeriod = @exportInPeriod, + EndingBalance = @endingBalance, ExportedBy = @exportedBy, UpdatedDate = GETDATE() WHERE AssetId = @assetId @@ -3735,13 +3801,22 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) } const borrowerSummary = decreased.summary || null; + const remainingBorrowed = decreased.entries.reduce((sum, entry) => ( + sum + parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + const quantity = parseNonNegativeInteger(targetRequest.Quantity, 0); + const importInPeriod = parseNonNegativeInteger(targetRequest.ImportInPeriod, 0); await new sql.Request(transaction) .input('assetId', sql.Int, targetRequest.AssetId) .input('borrower', sql.NVarChar, borrowerSummary) + .input('exportInPeriod', sql.Int, remainingBorrowed) + .input('endingBalance', sql.Int, Math.max(quantity + importInPeriod - remainingBorrowed, 0)) .input('exportedBy', sql.NVarChar, processorName || null) .query(` UPDATE AssetInventory SET Borrower = @borrower, + ExportInPeriod = @exportInPeriod, + EndingBalance = @endingBalance, ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END, UpdatedDate = GETDATE() WHERE AssetId = @assetId @@ -3957,6 +4032,215 @@ app.get('/api/assets/:id', async (req, res) => { } }); +app.get('/api/asset-export-history', requireAssetOrAdmin, async (req, res) => { + try { + const limit = Math.min(parsePositiveInteger(req.query.limit, 300), 2000); + const result = await pool.request() + .input('limit', sql.Int, limit) + .query(` + SELECT TOP (@limit) + ExportHistoryId, + AssetId, + AssetCode, + AssetName, + ExportQuantity, + ProjectName, + CustodianName, + ExportedByName, + ExportNote, + CreatedBy, + ExportedDate, + CreatedDate, + UpdatedDate + FROM AssetExportHistory + ORDER BY ExportedDate DESC, ExportHistoryId DESC + `); + + res.json({ + success: true, + data: Array.isArray(result.recordset) ? result.recordset : [] + }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => { + let transaction; + try { + const assetId = Number(req.params.id); + const exportQuantity = parseNonNegativeInteger(req.body?.quantity, 0); + const borrowerName = String(req.body?.borrowerName || req.body?.custodianName || '').trim(); + const projectName = String(req.body?.projectName || '').trim() || null; + const exportNote = String(req.body?.note || '').trim() || null; + const createdBy = getUserIdFromRequest(req); + const exportedByName = await getUserDisplayNameById(createdBy) || String(req.headers['x-user-role'] || '').trim() || 'Unknown'; + const exportedDate = new Date(); + + if (!Number.isInteger(assetId) || assetId <= 0) { + return res.status(400).json({ success: false, message: 'Asset id is invalid' }); + } + if (exportQuantity <= 0) { + return res.status(400).json({ success: false, message: 'So luong xuat phai lon hon 0' }); + } + if (!borrowerName) { + return res.status(400).json({ success: false, message: 'Nguoi muon la bat buoc' }); + } + if (!projectName) { + return res.status(400).json({ success: false, message: 'Du an xuat la bat buoc' }); + } + + transaction = new sql.Transaction(pool); + await transaction.begin(); + + const assetResult = await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .query(` + SELECT TOP 1 + AssetId, + AssetCode, + AssetName, + Quantity, + ImportInPeriod, + ExportInPeriod, + EndingBalance, + Custodian, + Borrower + FROM AssetInventory WITH (UPDLOCK, ROWLOCK) + WHERE AssetId = @assetId + `); + + const asset = assetResult.recordset?.[0]; + if (!asset) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Asset not found' }); + } + + const quantity = parseNonNegativeInteger(asset.Quantity, 0); + const importInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0); + const existingBorrowerEntries = parseBorrowerEntries(asset.Borrower); + const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => ( + sum + parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + + const storedExportInPeriod = parseOptionalNonNegativeInteger(asset.ExportInPeriod); + const baseExportInPeriod = storedExportInPeriod !== null ? storedExportInPeriod : previousBorrowerExport; + const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance); + const baseEndingBalance = storedEndingBalance !== null + ? storedEndingBalance + : Math.max(quantity + importInPeriod - baseExportInPeriod, 0); + + if (baseEndingBalance <= 0) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky, khong the xuat them' }); + } + if (exportQuantity > baseEndingBalance) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `So luong xuat (${exportQuantity}) vuot qua ton cuoi ky (${baseEndingBalance})` + }); + } + + const borrowerSummary = mergeBorrowerEntries(asset.Borrower, borrowerName, exportQuantity); + const nextBorrowerEntries = parseBorrowerEntries(borrowerSummary); + const nextBorrowerExport = nextBorrowerEntries.reduce((sum, entry) => ( + sum + parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + const exportDelta = nextBorrowerExport - previousBorrowerExport; + const nextExportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0); + const nextEndingBalance = Math.max(baseEndingBalance - exportDelta, 0); + + await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .input('project', sql.NVarChar, projectName) + .input('borrower', sql.NVarChar, borrowerSummary) + .input('exportInPeriod', sql.Int, nextExportInPeriod) + .input('endingBalance', sql.Int, nextEndingBalance) + .input('exportedBy', sql.NVarChar, exportedByName) + .query(` + UPDATE AssetInventory + SET Project = @project, + Borrower = @borrower, + ExportInPeriod = @exportInPeriod, + EndingBalance = @endingBalance, + ExportedBy = @exportedBy, + UpdatedDate = GETDATE() + WHERE AssetId = @assetId + `); + + const historyCustodianName = String(asset.Custodian || '').trim() || '-'; + + const historyResult = await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .input('assetCode', sql.NVarChar, String(asset.AssetCode || '').trim()) + .input('assetName', sql.NVarChar, String(asset.AssetName || '').trim()) + .input('exportQuantity', sql.Int, exportQuantity) + .input('projectName', sql.NVarChar, projectName) + .input('custodianName', sql.NVarChar, historyCustodianName) + .input('exportedByName', sql.NVarChar, exportedByName) + .input('exportNote', sql.NVarChar, exportNote) + .input('createdBy', sql.Int, createdBy) + .input('exportedDate', sql.DateTime, exportedDate) + .query(` + INSERT INTO AssetExportHistory ( + AssetId, + AssetCode, + AssetName, + ExportQuantity, + ProjectName, + CustodianName, + ExportedByName, + ExportNote, + CreatedBy, + ExportedDate + ) + OUTPUT + INSERTED.ExportHistoryId, + INSERTED.AssetId, + INSERTED.AssetCode, + INSERTED.AssetName, + INSERTED.ExportQuantity, + INSERTED.ProjectName, + INSERTED.CustodianName, + INSERTED.ExportedByName, + INSERTED.ExportNote, + INSERTED.CreatedBy, + INSERTED.ExportedDate, + INSERTED.CreatedDate, + INSERTED.UpdatedDate + VALUES ( + @assetId, + @assetCode, + @assetName, + @exportQuantity, + @projectName, + @custodianName, + @exportedByName, + @exportNote, + @createdBy, + @exportedDate + ) + `); + + await transaction.commit(); + res.json({ + success: true, + message: 'Xuat tai san thanh cong', + data: historyResult.recordset?.[0] || null + }); + } catch (err) { + if (transaction) { + try { + await transaction.rollback(); + } catch (_rollbackErr) { + // Ignore rollback error, respond original error below. + } + } + res.status(500).json({ success: false, message: err.message }); + } +}); + app.post('/api/assets', requireAssetOrAdmin, async (req, res) => { try { const payload = normalizeAssetPayload(req.body); diff --git a/database/setup.sql b/database/setup.sql index cb8a1ce..18a6ae3 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -260,7 +260,105 @@ UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved'); -- =========================================== --- 8. CREATE AUDIT LOG TABLE +-- 8. CREATE ASSET EXPORT HISTORY TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory') +BEGIN + CREATE TABLE AssetExportHistory ( + ExportHistoryId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + AssetCode NVARCHAR(100) NOT NULL, + AssetName NVARCHAR(255) NOT NULL, + ExportQuantity INT NOT NULL DEFAULT 1, + ProjectName NVARCHAR(150) NULL, + CustodianName NVARCHAR(100) NOT NULL, + ExportedByName NVARCHAR(100) NOT NULL, + ExportNote NVARCHAR(1000) NULL, + CreatedBy INT NULL, + ExportedDate DATETIME NOT NULL DEFAULT GETDATE(), + CreatedDate DATETIME NOT NULL DEFAULT GETDATE(), + UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(), + FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE + ); + PRINT 'Table AssetExportHistory created successfully.'; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'AssetCode') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD AssetCode NVARCHAR(100) NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'AssetName') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD AssetName NVARCHAR(255) NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'ExportQuantity') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_AssetExportHistory_ExportQuantity DEFAULT(1); +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'ProjectName') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD ProjectName NVARCHAR(150) NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'CustodianName') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD CustodianName NVARCHAR(100) NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'ExportedByName') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD ExportedByName NVARCHAR(100) NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'ExportNote') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD ExportNote NVARCHAR(1000) NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD CreatedBy INT NULL; +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'ExportedDate') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(GETDATE()); +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'CreatedDate') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(GETDATE()); +END + +IF COL_LENGTH('dbo.AssetExportHistory', 'UpdatedDate') IS NULL +BEGIN + ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(GETDATE()); +END + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_AssetExportHistory_CreatedBy') + AND COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NOT NULL +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM sys.foreign_key_columns fkc + INNER JOIN sys.columns c + ON c.object_id = fkc.parent_object_id + AND c.column_id = fkc.parent_column_id + WHERE fkc.parent_object_id = OBJECT_ID('dbo.AssetExportHistory') + AND c.name = 'CreatedBy' + ) + BEGIN + ALTER TABLE AssetExportHistory + ADD CONSTRAINT FK_AssetExportHistory_CreatedBy + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL; + END +END + +-- =========================================== +-- 9. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -279,7 +377,7 @@ BEGIN END -- =========================================== --- 9. CREATE INDEXES +-- 10. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -341,10 +439,20 @@ BEGIN CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId') +BEGIN + CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_ExportedDate') +BEGIN + CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC); +END + PRINT 'Indexes created successfully.'; -- =========================================== --- 10. INSERT INITIAL DATA +-- 11. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists @@ -368,7 +476,7 @@ BEGIN END -- =========================================== --- 11. DISPLAY DATABASE INFORMATION +-- 12. DISPLAY DATABASE INFORMATION -- =========================================== PRINT ''; PRINT '========================================'; diff --git a/public/js/app.js b/public/js/app.js index 0ca036f..9e2af35 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -48,6 +48,7 @@ class AccountManager { this.assetDepartmentSearchTerm = ''; this.assetProjects = []; this.assetProjectSearchTerm = ''; + this.assetExportHistories = []; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; @@ -467,6 +468,44 @@ class AccountManager { }); } + 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 --'; @@ -907,6 +946,7 @@ class AccountManager { if (data.success) { this.assetProjects = Array.isArray(data.data) ? data.data : []; this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); + this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || ''); } else { console.error('Load asset projects failed:', data.message); } @@ -915,6 +955,82 @@ class AccountManager { } } + 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 ` +