diff --git a/backend/server.js b/backend/server.js index 1dcb301..c442192 100644 --- a/backend/server.js +++ b/backend/server.js @@ -692,6 +692,19 @@ function normalizeImportToken(value) { .replace(/[^a-z0-9]/g, ''); } +function normalizeAssetDamageType(value) { + const normalized = normalizeImportToken(value); + if (['disposed', 'dispose', 'disposal', 'thanhly', 'liquidated', 'liquidation'].includes(normalized)) { + return 'disposed'; + } + + return 'damaged'; +} + +function getAssetDamageTypeLabel(value) { + return normalizeAssetDamageType(value) === 'disposed' ? 'Thanh lý' : 'Hỏng'; +} + function isHeaderLikeAssetImportRow(row = {}) { const headerTokens = new Set([ 'stt', @@ -1841,6 +1854,41 @@ async function createTables() { FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE ) END`, + + // Asset Damage/Disposal History Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory') + BEGIN + CREATE TABLE AssetDamageDisposalHistory ( + DamageHistoryId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + AssetCode NVARCHAR(100) NOT NULL, + AssetName NVARCHAR(255) NOT NULL, + ActionType NVARCHAR(20) NOT NULL, + ActionLabel NVARCHAR(50) NOT NULL, + ActionQuantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50) NULL, + PreviousQuantity INT NOT NULL DEFAULT 0, + NextQuantity INT NOT NULL DEFAULT 0, + PreviousImportInPeriod INT NOT NULL DEFAULT 0, + NextImportInPeriod INT NOT NULL DEFAULT 0, + PreviousExportInPeriod INT NOT NULL DEFAULT 0, + NextExportInPeriod INT NOT NULL DEFAULT 0, + PreviousEndingBalance INT NOT NULL DEFAULT 0, + NextEndingBalance INT NOT NULL DEFAULT 0, + PreviousNewQuantity INT NOT NULL DEFAULT 0, + NextNewQuantity INT NOT NULL DEFAULT 0, + PreviousUsedQuantity INT NOT NULL DEFAULT 0, + NextUsedQuantity INT NOT NULL DEFAULT 0, + ActionNote NVARCHAR(1000) NULL, + CreatedBy INT NULL, + CreatedByName NVARCHAR(100) NULL, + ActionDate 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, + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ) + END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') @@ -1911,6 +1959,15 @@ async function createTables() { console.error('AssetExportHistory index creation error:', err.message); } + // Ensure AssetDamageDisposalHistory indexes exist + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate') CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType') CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType);`); + } catch (err) { + console.error('AssetDamageDisposalHistory index creation error:', err.message); + } + // Ensure new columns exist on Applications for migrations try { await pool.request().query(`IF EXISTS ( @@ -4979,6 +5036,291 @@ app.get('/api/asset-export-history', requireAssetOrAdmin, async (req, res) => { } }); +app.get('/api/asset-damage-disposal-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) + DamageHistoryId, + AssetId, + AssetCode, + AssetName, + ActionType, + ActionLabel, + ActionQuantity, + Unit, + PreviousQuantity, + NextQuantity, + PreviousImportInPeriod, + NextImportInPeriod, + PreviousExportInPeriod, + NextExportInPeriod, + PreviousEndingBalance, + NextEndingBalance, + PreviousNewQuantity, + NextNewQuantity, + PreviousUsedQuantity, + NextUsedQuantity, + ActionNote, + CreatedBy, + CreatedByName, + ActionDate, + CreatedDate, + UpdatedDate + FROM AssetDamageDisposalHistory + ORDER BY ActionDate DESC, DamageHistoryId 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/damage-disposal', requireAssetOrAdmin, async (req, res) => { + let transaction; + + try { + const assetId = Number(req.params.id); + const actionType = normalizeAssetDamageType(req.body?.actionType || req.body?.reason); + const actionLabel = getAssetDamageTypeLabel(actionType); + const actionQuantity = parseNonNegativeInteger(req.body?.quantity, 0); + const actionNote = String(req.body?.note || '').trim() || null; + const createdBy = getUserIdFromRequest(req); + const createdByName = await getUserDisplayNameById(createdBy); + const actionDate = new Date(); + + if (!Number.isInteger(assetId) || assetId <= 0) { + return res.status(400).json({ success: false, message: 'Asset id is invalid' }); + } + + if (actionQuantity <= 0) { + return res.status(400).json({ success: false, message: 'Số lượng phải lớn hơn 0' }); + } + + 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, + NewQuantity, + UsedQuantity, + Unit + 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 previousQuantity = parseNonNegativeInteger(asset.Quantity, 0); + const previousImportInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0); + const previousExportInPeriod = parseNonNegativeInteger(asset.ExportInPeriod, 0); + const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance); + const previousEndingBalance = storedEndingBalance !== null + ? storedEndingBalance + : Math.max(previousQuantity + previousImportInPeriod - previousExportInPeriod, 0); + const previousStockBuckets = normalizeAssetStockBuckets( + previousEndingBalance, + parseOptionalNonNegativeInteger(asset.NewQuantity) ?? previousEndingBalance, + parseOptionalNonNegativeInteger(asset.UsedQuantity) ?? 0 + ); + + if (previousEndingBalance <= 0) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Tài sản đã hết tồn cuối kỳ' }); + } + + if (actionQuantity > previousEndingBalance) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Số lượng ${actionLabel.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${previousEndingBalance})` + }); + } + + let remainingSourceReduction = actionQuantity; + const reduceFromQuantity = Math.min(previousQuantity, remainingSourceReduction); + const nextQuantity = Math.max(previousQuantity - reduceFromQuantity, 0); + remainingSourceReduction -= reduceFromQuantity; + const reduceFromImport = Math.min(previousImportInPeriod, remainingSourceReduction); + const nextImportInPeriod = Math.max(previousImportInPeriod - reduceFromImport, 0); + + const nextExportInPeriod = previousExportInPeriod; + const nextEndingBalance = Math.max(nextQuantity + nextImportInPeriod - nextExportInPeriod, 0); + + let nextUsedQuantity = previousStockBuckets.usedQuantity; + let nextNewQuantity = previousStockBuckets.newQuantity; + let remainingStockReduction = actionQuantity; + const reduceFromUsed = Math.min(nextUsedQuantity, remainingStockReduction); + nextUsedQuantity -= reduceFromUsed; + remainingStockReduction -= reduceFromUsed; + const reduceFromNew = Math.min(nextNewQuantity, remainingStockReduction); + nextNewQuantity -= reduceFromNew; + + const nextStockBuckets = normalizeAssetStockBuckets(nextEndingBalance, nextNewQuantity, nextUsedQuantity); + const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, nextExportInPeriod); + + await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .input('quantity', sql.Int, nextQuantity) + .input('importInPeriod', sql.Int, nextImportInPeriod) + .input('endingBalance', sql.Int, nextEndingBalance) + .input('newQuantity', sql.Int, nextStockBuckets.newQuantity) + .input('usedQuantity', sql.Int, nextStockBuckets.usedQuantity) + .input('status', sql.NVarChar, nextStatus) + .query(` + UPDATE AssetInventory + SET Quantity = @quantity, + ImportInPeriod = @importInPeriod, + EndingBalance = @endingBalance, + NewQuantity = @newQuantity, + UsedQuantity = @usedQuantity, + Status = @status, + UpdatedDate = GETDATE() + WHERE AssetId = @assetId + `); + + 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('actionType', sql.NVarChar, actionType) + .input('actionLabel', sql.NVarChar, actionLabel) + .input('actionQuantity', sql.Int, actionQuantity) + .input('unit', sql.NVarChar, String(asset.Unit || '').trim() || null) + .input('previousQuantity', sql.Int, previousQuantity) + .input('nextQuantity', sql.Int, nextQuantity) + .input('previousImportInPeriod', sql.Int, previousImportInPeriod) + .input('nextImportInPeriod', sql.Int, nextImportInPeriod) + .input('previousExportInPeriod', sql.Int, previousExportInPeriod) + .input('nextExportInPeriod', sql.Int, nextExportInPeriod) + .input('previousEndingBalance', sql.Int, previousEndingBalance) + .input('nextEndingBalance', sql.Int, nextEndingBalance) + .input('previousNewQuantity', sql.Int, previousStockBuckets.newQuantity) + .input('nextNewQuantity', sql.Int, nextStockBuckets.newQuantity) + .input('previousUsedQuantity', sql.Int, previousStockBuckets.usedQuantity) + .input('nextUsedQuantity', sql.Int, nextStockBuckets.usedQuantity) + .input('actionNote', sql.NVarChar, actionNote) + .input('createdBy', sql.Int, createdBy) + .input('createdByName', sql.NVarChar, createdByName) + .input('actionDate', sql.DateTime, actionDate) + .query(` + INSERT INTO AssetDamageDisposalHistory ( + AssetId, + AssetCode, + AssetName, + ActionType, + ActionLabel, + ActionQuantity, + Unit, + PreviousQuantity, + NextQuantity, + PreviousImportInPeriod, + NextImportInPeriod, + PreviousExportInPeriod, + NextExportInPeriod, + PreviousEndingBalance, + NextEndingBalance, + PreviousNewQuantity, + NextNewQuantity, + PreviousUsedQuantity, + NextUsedQuantity, + ActionNote, + CreatedBy, + CreatedByName, + ActionDate + ) + OUTPUT + INSERTED.DamageHistoryId, + INSERTED.AssetId, + INSERTED.AssetCode, + INSERTED.AssetName, + INSERTED.ActionType, + INSERTED.ActionLabel, + INSERTED.ActionQuantity, + INSERTED.Unit, + INSERTED.PreviousQuantity, + INSERTED.NextQuantity, + INSERTED.PreviousImportInPeriod, + INSERTED.NextImportInPeriod, + INSERTED.PreviousExportInPeriod, + INSERTED.NextExportInPeriod, + INSERTED.PreviousEndingBalance, + INSERTED.NextEndingBalance, + INSERTED.PreviousNewQuantity, + INSERTED.NextNewQuantity, + INSERTED.PreviousUsedQuantity, + INSERTED.NextUsedQuantity, + INSERTED.ActionNote, + INSERTED.CreatedBy, + INSERTED.CreatedByName, + INSERTED.ActionDate, + INSERTED.CreatedDate, + INSERTED.UpdatedDate + VALUES ( + @assetId, + @assetCode, + @assetName, + @actionType, + @actionLabel, + @actionQuantity, + @unit, + @previousQuantity, + @nextQuantity, + @previousImportInPeriod, + @nextImportInPeriod, + @previousExportInPeriod, + @nextExportInPeriod, + @previousEndingBalance, + @nextEndingBalance, + @previousNewQuantity, + @nextNewQuantity, + @previousUsedQuantity, + @nextUsedQuantity, + @actionNote, + @createdBy, + @createdByName, + @actionDate + ) + `); + + await transaction.commit(); + + res.json({ + success: true, + message: `Đã ghi nhận tài sản ${actionLabel.toLowerCase()}`, + 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/:id/export', requireAssetOrAdmin, async (req, res) => { let transaction; try { diff --git a/database/setup.sql b/database/setup.sql index a4f6e9f..e68ef25 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -484,7 +484,45 @@ BEGIN END -- =========================================== --- 9. CREATE AUDIT LOG TABLE +-- 9. CREATE ASSET DAMAGE/DISPOSAL HISTORY TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory') +BEGIN + CREATE TABLE AssetDamageDisposalHistory ( + DamageHistoryId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + AssetCode NVARCHAR(100) NOT NULL, + AssetName NVARCHAR(255) NOT NULL, + ActionType NVARCHAR(20) NOT NULL, + ActionLabel NVARCHAR(50) NOT NULL, + ActionQuantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50) NULL, + PreviousQuantity INT NOT NULL DEFAULT 0, + NextQuantity INT NOT NULL DEFAULT 0, + PreviousImportInPeriod INT NOT NULL DEFAULT 0, + NextImportInPeriod INT NOT NULL DEFAULT 0, + PreviousExportInPeriod INT NOT NULL DEFAULT 0, + NextExportInPeriod INT NOT NULL DEFAULT 0, + PreviousEndingBalance INT NOT NULL DEFAULT 0, + NextEndingBalance INT NOT NULL DEFAULT 0, + PreviousNewQuantity INT NOT NULL DEFAULT 0, + NextNewQuantity INT NOT NULL DEFAULT 0, + PreviousUsedQuantity INT NOT NULL DEFAULT 0, + NextUsedQuantity INT NOT NULL DEFAULT 0, + ActionNote NVARCHAR(1000) NULL, + CreatedBy INT NULL, + CreatedByName NVARCHAR(100) NULL, + ActionDate 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, + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ); + PRINT 'Table AssetDamageDisposalHistory created successfully.'; +END + +-- =========================================== +-- 10. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -503,7 +541,7 @@ BEGIN END -- =========================================== --- 10. CREATE INDEXES +-- 11. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -590,10 +628,25 @@ BEGIN CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') +BEGIN + CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate') +BEGIN + CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType') +BEGIN + CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType); +END + PRINT 'Indexes created successfully.'; -- =========================================== --- 11. INSERT INITIAL DATA +-- 12. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists diff --git a/public/js/app.js b/public/js/app.js index 7662b1b..fa8f752 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -53,6 +53,7 @@ class AccountManager { this.assetProjects = []; this.assetProjectSearchTerm = ''; this.assetExportHistories = []; + this.assetDamageHistories = []; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; @@ -71,6 +72,7 @@ class AccountManager { this.assetBorrowAutoRefreshTimer = undefined; this.pendingAssetRequestDeleteConfirmResolver = undefined; this.pendingBulkAssetDeleteConfirmResolver = undefined; + this.pendingAssetDamageId = undefined; } configureNotifications() { @@ -1072,6 +1074,114 @@ class AccountManager { this.renderAssetExportHistoryModal(); } + 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`); @@ -1244,6 +1354,14 @@ class AccountManager { } } + 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) { @@ -4278,14 +4396,22 @@ class AccountManager { history Lịch sử xuất + - + @@ -4609,6 +4735,16 @@ class AccountManager { 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); } } @@ -5051,6 +5187,161 @@ class AccountManager { 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; @@ -6616,10 +6907,17 @@ class AccountManager { 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 openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn'); + const openAssetDamageHistoryBtn = document.getElementById('openAssetDamageHistoryBtn'); if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) { importAssetBtn.addEventListener('click', () => { @@ -6645,6 +6943,11 @@ class AccountManager { openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal()); openAssetExportHistoryBtn.dataset.boundClick = 'true'; } + + if (openAssetDamageHistoryBtn && !openAssetDamageHistoryBtn.dataset.boundClick) { + openAssetDamageHistoryBtn.addEventListener('click', () => this.openAssetDamageHistoryModal()); + openAssetDamageHistoryBtn.dataset.boundClick = 'true'; + } } setupFilters() { @@ -7856,6 +8159,13 @@ function closeBorrowAssetModal() { } } +function closeAssetDamageModal() { + const modal = document.getElementById('assetDamageModal'); + if (modal) { + modal.classList.remove('open'); + } +} + function closeAssetExportHistoryModal() { const modal = document.getElementById('assetExportHistoryModal'); if (modal) { @@ -7863,6 +8173,13 @@ function closeAssetExportHistoryModal() { } } +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'); diff --git a/public/modals.html b/public/modals.html index 609c1b7..4ae5eb8 100644 --- a/public/modals.html +++ b/public/modals.html @@ -357,6 +357,66 @@ + + + + + +