diff --git a/backend/server.js b/backend/server.js index 1ada4a3..500e641 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4196,6 +4196,167 @@ app.post('/api/asset-borrows', async (req, res) => { } }); +app.post('/api/asset-borrows/:id/return', async (req, res) => { + const transaction = new sql.Transaction(pool); + + try { + const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole); + const requesterId = getUserIdFromRequest(req); + const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset'; + const borrowId = Number(req.params.id); + + if (!Number.isInteger(borrowId) || borrowId <= 0) { + return res.status(400).json({ success: false, message: 'Ma don muon khong hop le' }); + } + + if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) { + return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' }); + } + + await transaction.begin(); + + const targetResult = await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowId) + .input('requesterId', sql.Int, requesterId || -1) + .query(` + SELECT TOP 1 + br.BorrowId, + br.AssetId, + br.RequestType, + br.RequestStatus, + br.BorrowerName, + br.BorrowQuantity, + ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity, + ISNULL(activeReturns.ActiveReturnQuantity, 0) AS ActiveReturnQuantity, + COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit, + br.CreatedBy, + ai.Borrower + FROM AssetBorrowRequests br WITH (UPDLOCK, HOLDLOCK) + INNER JOIN AssetInventory ai WITH (UPDLOCK, HOLDLOCK) ON ai.AssetId = br.AssetId + OUTER APPLY ( + SELECT SUM(ISNULL(links.Quantity, 0)) AS ActiveReturnQuantity + FROM AssetBorrowRequestLinks links + INNER JOIN AssetBorrowRequests returnRows ON returnRows.BorrowId = links.ReturnId + WHERE links.BorrowId = br.BorrowId + AND LOWER(LTRIM(RTRIM(ISNULL(returnRows.RequestStatus, '')))) IN ('pending', 'approved') + ) activeReturns + WHERE br.BorrowId = @borrowId + ${canManageRequests ? '' : 'AND br.CreatedBy = @requesterId'} + `); + + const targetRequest = targetResult.recordset?.[0]; + if (!targetRequest) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Khong tim thay don muon can tra' }); + } + + if (normalizeAssetRequestType(targetRequest.RequestType) !== 'borrow') { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Chi co the tao don tra tu don muon' }); + } + + if (normalizeAssetRequestStatus(targetRequest.RequestStatus) !== 'approved') { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Chi co the tra tai san khi don dang o trang thai dang muon' }); + } + + const originalQuantity = parseNonNegativeInteger(targetRequest.BorrowQuantity, 0); + const returnedQuantity = parseNonNegativeInteger(targetRequest.ReturnedQuantity, 0); + const activeReturnQuantity = parseNonNegativeInteger(targetRequest.ActiveReturnQuantity, 0); + const availableReturnQuantity = Math.max(originalQuantity - Math.max(returnedQuantity, activeReturnQuantity), 0); + + if (availableReturnQuantity <= 0) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Don muon nay da co don tra hoac da tra het' }); + } + + const borrowerName = String(targetRequest.BorrowerName || '').trim(); + const borrowedEntry = parseBorrowerEntries(targetRequest.Borrower) + .find(entry => entry.name.toLowerCase() === borrowerName.toLowerCase()); + const currentBorrowedQuantity = parseNonNegativeInteger(borrowedEntry?.quantity, 0); + const returnQuantity = availableReturnQuantity; + + if (currentBorrowedQuantity < returnQuantity) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Khong con so luong dang muon de tao don tra' }); + } + + const originalCreatedBy = Number(targetRequest.CreatedBy); + const createdBy = Number.isInteger(originalCreatedBy) && originalCreatedBy > 0 + ? originalCreatedBy + : requesterId; + + const insertResult = await new sql.Request(transaction) + .input('assetId', sql.Int, targetRequest.AssetId) + .input('requestType', sql.NVarChar, 'return') + .input('requestStatus', sql.NVarChar, 'pending') + .input('borrowerName', sql.NVarChar, borrowerName) + .input('borrowQuantity', sql.Int, returnQuantity) + .input('unit', sql.NVarChar, String(targetRequest.Unit || '').trim() || null) + .input('borrowDate', sql.Date, new Date()) + .input('requestNote', sql.NVarChar, `Tu dong tao tu don muon #${borrowId}`) + .input('createdBy', sql.Int, createdBy || null) + .query(` + INSERT INTO AssetBorrowRequests ( + AssetId, + RequestType, + RequestStatus, + BorrowerName, + BorrowQuantity, + Unit, + BorrowDate, + RequestNote, + CreatedBy + ) VALUES ( + @assetId, + @requestType, + @requestStatus, + @borrowerName, + @borrowQuantity, + @unit, + @borrowDate, + @requestNote, + @createdBy + ); + SELECT SCOPE_IDENTITY() AS BorrowId; + `); + + const returnRequestId = Number(insertResult.recordset?.[0]?.BorrowId) || null; + if (!returnRequestId) { + await transaction.rollback(); + return res.status(500).json({ success: false, message: 'Khong tao duoc don tra tai san' }); + } + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowId) + .input('returnId', sql.Int, returnRequestId) + .input('quantity', sql.Int, returnQuantity) + .query(` + INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity) + VALUES (@borrowId, @returnId, @quantity); + `); + + await transaction.commit(); + + return res.json({ + success: true, + message: 'Da tao don tra tai san. Don dang cho xu ly.', + data: { + borrowId: returnRequestId, + sourceBorrowId: borrowId, + quantity: returnQuantity + } + }); + } catch (err) { + try { + await transaction.rollback(); + } catch (rollbackErr) { + // Ignore rollback errors when transaction already finished. + } + return res.status(500).json({ success: false, message: err.message }); + } +}); + app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => { const transaction = new sql.Transaction(pool); diff --git a/public/js/app.js b/public/js/app.js index ff5d93d..6c88a9e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3018,6 +3018,7 @@ class AccountManager { const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0)); + const canCreateReturn = this.canCreateAssetReturnRequestFromBorrow(item); const returnProgressHtml = requestType === 'borrow' && returnedQuantity > 0 && statusMeta.value !== 'returned' ? `
Đã trả ${returnedQuantity}/${borrowQuantity}, còn ${remainingQuantity}
` : ''; @@ -3031,8 +3032,17 @@ class AccountManager { info `; + const returnActionHtml = canCreateReturn + ? `` + : ''; const cancelActionHtml = canCancel ? `` + : ''; + const actionHtml = returnActionHtml || cancelActionHtml + ? `
${returnActionHtml}${cancelActionHtml}
` : `-`; return ` @@ -3056,11 +3066,28 @@ class AccountManager { ${this.escapeHtml(note || '-')} ${this.escapeHtml(rejectReason || '-')} ${detailActionHtml} - ${cancelActionHtml} + ${actionHtml} `; } + canCreateAssetReturnRequestFromBorrow(item) { + if (this.normalizeAssetRequestType(item?.RequestType) !== 'borrow') { + return false; + } + + if (this.normalizeAssetRequestStatus(item?.RequestStatus) !== 'approved') { + return false; + } + + const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); + const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); + const remainingQuantity = this.parseNonNegativeInteger(item?.RemainingQuantity, Math.max(borrowQuantity - returnedQuantity, 0)); + const relatedReturnCount = this.parseNonNegativeInteger(item?.RelatedReturnCount, 0); + + return borrowQuantity > 0 && remainingQuantity > 0 && relatedReturnCount <= 0; + } + canCurrentUserCancelAssetRequest(item) { const status = this.normalizeAssetRequestStatus(item?.RequestStatus); if (status !== 'pending') { @@ -3577,6 +3604,15 @@ class AccountManager { return; } + const returnButton = event.target.closest('.asset-borrow-return-btn'); + if (returnButton) { + const requestId = Number(returnButton.dataset.requestId); + if (Number.isFinite(requestId) && requestId > 0) { + this.createAssetReturnRequestFromBorrow(requestId, returnButton); + } + return; + } + const cancelButton = event.target.closest('.asset-borrow-cancel-btn'); if (!cancelButton) { return; @@ -3601,6 +3637,61 @@ class AccountManager { this.setupAssetBorrowPagerListeners(); } + async createAssetReturnRequestFromBorrow(requestId, sourceButton = null) { + const targetId = Number(requestId); + if (!Number.isFinite(targetId) || targetId <= 0) { + this.notifyWarning('Không xác định được đơn mượn cần trả.'); + return; + } + + if (sourceButton) { + sourceButton.disabled = true; + sourceButton.classList.add('opacity-60', 'cursor-not-allowed'); + } + + try { + const response = await fetch(`${this.apiBase}/asset-borrows/${targetId}/return`, { + method: 'POST', + headers: this.getAuthHeaders(false) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + this.notifyFailure(data.message || 'Tạo đơn trả tài sản thất bại'); + return; + } + + this.notifySuccess('Đã tạo đơn trả tài sản. Đơn đang chờ xử lý.'); + await this.fetchAssetBorrows(); + await this.fetchAssets(); + + if (this.currentPage === 'asset-borrows') { + this.renderAssetBorrowsTableBody(); + } + if (this.currentPage === 'assets') { + this.renderAssetsTableBody(); + } + if (this.currentPage === 'my-borrowed-assets') { + this.renderMyBorrowedAssetsTableBody(); + } + + const pendingModal = document.getElementById('assetPendingRequestsModal'); + if (pendingModal?.classList.contains('open')) { + this.renderPendingAssetRequestsModal(); + } + + this.updatePendingAssetRequestsBadge(); + } catch (err) { + console.error(err); + this.notifyFailure('Tạo đơn trả tài sản thất bại'); + } finally { + if (sourceButton && document.body.contains(sourceButton)) { + sourceButton.disabled = false; + sourceButton.classList.remove('opacity-60', 'cursor-not-allowed'); + } + } + } + async refreshAssetBorrowsUI() { await this.fetchAssetBorrows(); if (this.currentPage === 'asset-borrows') {