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 ` + + 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 fetchRoles() { try { const res = await fetch(`${this.apiBase}/roles`); @@ -952,6 +1068,7 @@ class AccountManager { this.setupFilters(); this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); + this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || ''); } catch (error) { console.error('Lỗi load modals:', error); } @@ -1835,7 +1952,7 @@ class AccountManager { return entries .map(entry => this.formatBorrowerDisplay(entry.name, entry.quantity)) .filter(Boolean) - .map(item => `
${this.escapeHtml(item)}
`) + .map(item => `
${this.escapeHtml(item)}
`) .join(''); } @@ -1866,15 +1983,10 @@ class AccountManager { const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => ( sum + this.parseNonNegativeInteger(entry?.quantity, 0) ), 0); - const storedExportInPeriod = this.parseOptionalNonNegativeInteger(asset?.ExportInPeriod ?? asset?.exportInPeriod); - const exportInPeriod = storedExportInPeriod !== null - ? storedExportInPeriod - : borrowerExportInPeriod; - const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); - const storedEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance); - const endingBalance = storedEndingBalance !== null - ? storedEndingBalance - : computedEndingBalance; + // Borrower entries are the source of truth for exported quantity. + // This keeps UI consistent even when legacy rows have stale stored balances. + const exportInPeriod = borrowerExportInPeriod; + const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0); return { quantity, @@ -3375,13 +3487,17 @@ class AccountManager { download Xuất Excel + @@ -3433,7 +3549,7 @@ class AccountManager { Trạng thái Vị trí Ngày mua - Người mượn + Người mượn Ghi chú Ngày tạo Người xuất @@ -3469,7 +3585,7 @@ class AccountManager { ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} - ${this.formatBorrowerTableHtml(asset.Borrower)} + ${this.formatBorrowerTableHtml(asset.Borrower)} ${asset.Notes || '-'} ${this.formatDateOnly(asset.CreatedDate)} ${asset.ExportedBy || '-'} @@ -3553,7 +3669,7 @@ class AccountManager { ${asset.Location || '-'} ${this.formatDateOnly(asset.PurchaseDate)} - ${this.formatBorrowerTableHtml(asset.Borrower)} + ${this.formatBorrowerTableHtml(asset.Borrower)} ${asset.Notes || '-'} ${this.formatDateOnly(asset.CreatedDate)} ${asset.ExportedBy || '-'} @@ -3996,7 +4112,7 @@ class AccountManager { }; } - buildAssetPayloadFromAsset(asset, borrowerEntriesOverride = null) { + buildAssetPayloadFromAsset(asset, borrowerEntriesOverride = null, fieldOverrides = {}) { if (!asset) { return null; } @@ -4045,9 +4161,9 @@ class AccountManager { endingBalance, unit: String(asset?.Unit || '').trim(), department: String(asset?.Department || '').trim(), - project: String(asset?.Project || '').trim(), + project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(), location: String(asset?.Location || '').trim(), - custodian: String(asset?.Custodian || '').trim(), + custodian: String(fieldOverrides?.custodian ?? asset?.Custodian ?? '').trim(), borrower, purchaseDate: this.toDateInputValue(asset?.PurchaseDate) || null, purchasePrice: normalizedPrice, @@ -4059,14 +4175,14 @@ class AccountManager { const selectedIds = [...this.selectedAssetIds]; if (!selectedIds.length) { if (showWarning) { - this.notifyWarning('Vui lòng chọn 1 tài sản để mượn.'); + 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 mượn.'); + this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần xuất.'); } return null; } @@ -4080,7 +4196,7 @@ class AccountManager { } async openBorrowAssetModal() { - if (!this.ensureAssetManagePermission('muon tai san')) { + if (!this.ensureAssetManagePermission('xuat tai san')) { return; } @@ -4092,10 +4208,13 @@ class AccountManager { 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ể mượn thêm.'); + this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể xuất thêm.'); return; } @@ -4104,12 +4223,14 @@ class AccountManager { 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) { - this.notifyFailure('Không tìm thấy biểu mẫu mượn tài sản.'); + 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; } @@ -4124,6 +4245,8 @@ class AccountManager { quantityInput.max = String(metrics.endingBalance); this.refreshBorrowAssetUserOptions(''); + this.refreshBorrowAssetProjectOptions(String(asset?.Project || '').trim()); + noteInput.value = ''; if (borrowByInput) { borrowByInput.value = this.getCurrentUserDisplayName(); @@ -4139,23 +4262,25 @@ class AccountManager { async handleBorrowAssetSubmit(e) { e.preventDefault(); - if (!this.ensureAssetManagePermission('muon tai san')) { + 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 mượn.'); + 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 mượn.'); + this.notifyFailure('Không tìm thấy tài sản cần xuất.'); return; } @@ -4165,50 +4290,61 @@ class AccountManager { return; } - const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); - if (borrowQuantity <= 0) { - this.notifyWarning('Số lượng mượn phải lớn hơn 0.'); + 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ể mượn thêm.'); + 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 mượn (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`); - return; - } - - const updatedEntries = this.mergeBorrowerEntries(currentMetrics.borrowerEntries, borrowerName, borrowQuantity); - const payload = this.buildAssetPayloadFromAsset(asset, updatedEntries); - if (!payload) { - this.notifyFailure('Không tạo được dữ liệu mượn tài sản.'); + 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}`, { - method: 'PUT', + const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/export`, { + method: 'POST', headers: this.getAuthHeaders(true), - body: JSON.stringify(payload) + 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 || 'Mượn tài sản thất bại'); + this.notifyFailure(data.message || 'Xuất tài sản thất bại'); return; } this.pendingBorrowAssetId = undefined; - this.notifySuccess('Mượn tài sản thành công'); + this.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('Mượn tài sản thất bại'); + this.notifyFailure('Xuất tài sản thất bại'); } } @@ -5622,6 +5758,7 @@ class AccountManager { const importAssetBtn = document.getElementById('importAssetBtn'); const assetImportInput = document.getElementById('assetImportInput'); const exportAssetBtn = document.getElementById('exportAssetBtn'); + const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn'); if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) { importAssetBtn.addEventListener('click', () => { @@ -5642,6 +5779,11 @@ class AccountManager { exportAssetBtn.addEventListener('click', () => this.exportAssetsToExcel()); exportAssetBtn.dataset.boundClick = 'true'; } + + if (openAssetExportHistoryBtn && !openAssetExportHistoryBtn.dataset.boundClick) { + openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal()); + openAssetExportHistoryBtn.dataset.boundClick = 'true'; + } } setupFilters() { @@ -6835,6 +6977,13 @@ function closeBorrowAssetModal() { } } +function closeAssetExportHistoryModal() { + const modal = document.getElementById('assetExportHistoryModal'); + if (modal) { + modal.classList.remove('open'); + } +} + function closeAssetBorrowRequestModal() { const modal = document.getElementById('assetBorrowRequestModal'); const dropdown = document.getElementById('assetBorrowProductDropdown'); diff --git a/public/modals.html b/public/modals.html index 85451dc..2469cc7 100644 --- a/public/modals.html +++ b/public/modals.html @@ -304,11 +304,11 @@ - + + + +