diff --git a/backend/server.js b/backend/server.js index 45f6d25..1ada4a3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -674,6 +674,9 @@ function normalizeAssetRequestStatus(value) { if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') { return 'approved'; } + if (normalized === 'returned' || normalized === 'return_done' || normalized === 'done' || normalized === 'da_tra') { + return 'returned'; + } if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') { return 'rejected'; } @@ -1787,6 +1790,7 @@ async function createTables() { RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', BorrowerName NVARCHAR(100) NOT NULL, BorrowQuantity INT NOT NULL DEFAULT 1, + ReturnedQuantity INT NOT NULL DEFAULT 0, Unit NVARCHAR(50), BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), RequestNote NVARCHAR(500) NULL, @@ -1803,6 +1807,20 @@ async function createTables() { ) END`, + // Asset Borrow/Return Links Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequestLinks') + BEGIN + CREATE TABLE AssetBorrowRequestLinks ( + LinkId INT PRIMARY KEY IDENTITY(1,1), + BorrowId INT NOT NULL, + ReturnId INT NOT NULL, + Quantity INT NOT NULL DEFAULT 1, + CreatedDate DATETIME NOT NULL DEFAULT GETDATE(), + FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION, + FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION + ) + END`, + // Asset Export History Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory') BEGIN @@ -1878,6 +1896,9 @@ async function createTables() { await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_BorrowDate') CREATE INDEX IX_AssetBorrowRequests_BorrowDate ON AssetBorrowRequests(BorrowDate DESC);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestStatus') CREATE INDEX IX_AssetBorrowRequests_RequestStatus ON AssetBorrowRequests(RequestStatus);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestType') CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequestLinks_BorrowId') CREATE INDEX IX_AssetBorrowRequestLinks_BorrowId ON AssetBorrowRequestLinks(BorrowId);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequestLinks_ReturnId') CREATE INDEX IX_AssetBorrowRequestLinks_ReturnId ON AssetBorrowRequestLinks(ReturnId);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetBorrowRequestLinks_BorrowReturn') CREATE UNIQUE INDEX UX_AssetBorrowRequestLinks_BorrowReturn ON AssetBorrowRequestLinks(BorrowId, ReturnId);`); } catch (err) { console.error('AssetBorrowRequests index creation error:', err.message); } @@ -1913,6 +1934,7 @@ async function createTables() { await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','BorrowDate') IS NULL ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(GETDATE() AS DATE));`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestType') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestType DEFAULT('borrow');`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestStatus') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved');`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ReturnedQuantity') IS NULL ALTER TABLE AssetBorrowRequests ADD ReturnedQuantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequests_ReturnedQuantity DEFAULT(0);`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestNote') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RejectReason') IS NULL ALTER TABLE AssetBorrowRequests ADD RejectReason NVARCHAR(1000) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedBy') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedBy INT NULL;`); @@ -1929,6 +1951,20 @@ 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 NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequestLinks') + BEGIN + CREATE TABLE AssetBorrowRequestLinks ( + LinkId INT PRIMARY KEY IDENTITY(1,1), + BorrowId INT NOT NULL, + ReturnId INT NOT NULL, + Quantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_Quantity DEFAULT(1), + CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_CreatedDate DEFAULT(GETDATE()), + FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION, + FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION + ); + END + `); 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);`); @@ -1959,6 +1995,68 @@ async function createTables() { `); 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 AssetBorrowRequests SET ReturnedQuantity = 0 WHERE ReturnedQuantity IS NULL OR ReturnedQuantity < 0;`); + await pool.request().query(` + IF OBJECT_ID('dbo.AssetBorrowRequestLinks', 'U') IS NOT NULL + BEGIN + INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity) + SELECT matchedBorrow.BorrowId, + ret.BorrowId, + CASE + WHEN ISNULL(ret.BorrowQuantity, 0) <= 0 THEN 1 + WHEN ISNULL(ret.BorrowQuantity, 0) > ISNULL(matchedBorrow.BorrowQuantity, 0) THEN ISNULL(matchedBorrow.BorrowQuantity, 0) + ELSE ISNULL(ret.BorrowQuantity, 0) + END + FROM AssetBorrowRequests ret + CROSS APPLY ( + SELECT TOP 1 b.BorrowId, b.BorrowQuantity + FROM AssetBorrowRequests b + WHERE LOWER(LTRIM(RTRIM(ISNULL(b.RequestType, '')))) = 'borrow' + AND LOWER(LTRIM(RTRIM(ISNULL(b.RequestStatus, '')))) IN ('approved', 'returned') + AND b.AssetId = ret.AssetId + AND ( + (ret.CreatedBy IS NOT NULL AND b.CreatedBy = ret.CreatedBy) + OR LOWER(LTRIM(RTRIM(ISNULL(b.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(ret.BorrowerName, '')))) + ) + AND b.BorrowDate <= ret.BorrowDate + ORDER BY b.BorrowDate DESC, b.CreatedDate DESC, b.BorrowId DESC + ) matchedBorrow + WHERE LOWER(LTRIM(RTRIM(ISNULL(ret.RequestType, '')))) = 'return' + AND LOWER(LTRIM(RTRIM(ISNULL(ret.RequestStatus, '')))) IN ('pending', 'approved') + AND NOT EXISTS ( + SELECT 1 + FROM AssetBorrowRequestLinks existed + WHERE existed.ReturnId = ret.BorrowId + ) + AND NOT EXISTS ( + SELECT 1 + FROM AssetBorrowRequestLinks duplicateLink + WHERE duplicateLink.ReturnId = ret.BorrowId + AND duplicateLink.BorrowId = matchedBorrow.BorrowId + ); + + UPDATE borrowRows + SET ReturnedQuantity = CASE + WHEN summary.ReturnedQuantity > ISNULL(borrowRows.BorrowQuantity, 0) THEN ISNULL(borrowRows.BorrowQuantity, 0) + ELSE summary.ReturnedQuantity + END, + RequestStatus = CASE + WHEN summary.ReturnedQuantity >= ISNULL(borrowRows.BorrowQuantity, 0) + THEN 'returned' + ELSE borrowRows.RequestStatus + END, + UpdatedDate = GETDATE() + FROM AssetBorrowRequests borrowRows + INNER JOIN ( + SELECT links.BorrowId, SUM(ISNULL(links.Quantity, 0)) AS ReturnedQuantity + FROM AssetBorrowRequestLinks links + INNER JOIN AssetBorrowRequests returns ON returns.BorrowId = links.ReturnId + WHERE LOWER(LTRIM(RTRIM(ISNULL(returns.RequestStatus, '')))) = 'approved' + GROUP BY links.BorrowId + ) summary ON summary.BorrowId = borrowRows.BorrowId + WHERE LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestType, '')))) = 'borrow'; + END + `); await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`); await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`); await pool.request().query(`UPDATE AssetInventory SET UsedQuantity = CASE WHEN UsedQuantity < 0 THEN 0 ELSE ISNULL(UsedQuantity, 0) END;`); @@ -3632,6 +3730,17 @@ app.get('/api/asset-borrows', async (req, res) => { br.RequestStatus, br.BorrowerName, br.BorrowQuantity, + ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity, + CASE + WHEN LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow' + THEN CASE + WHEN ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) < 0 THEN 0 + ELSE ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) + END + ELSE 0 + END AS RemainingQuantity, + ISNULL(returnSummary.ReturnCount, 0) AS RelatedReturnCount, + ISNULL(borrowSummary.BorrowCount, 0) AS RelatedBorrowCount, COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit, br.BorrowDate, br.RequestNote, @@ -3643,6 +3752,36 @@ app.get('/api/asset-borrows', async (req, res) => { br.CreatedDate FROM AssetBorrowRequests br LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId + OUTER APPLY ( + SELECT COUNT(1) AS ReturnCount + FROM AssetBorrowRequests rr + WHERE LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow' + AND LOWER(LTRIM(RTRIM(ISNULL(rr.RequestType, '')))) = 'return' + AND LOWER(LTRIM(RTRIM(ISNULL(rr.RequestStatus, '')))) IN ('pending', 'approved') + AND ( + EXISTS ( + SELECT 1 + FROM AssetBorrowRequestLinks l + WHERE l.BorrowId = br.BorrowId + AND l.ReturnId = rr.BorrowId + ) + OR ( + rr.AssetId = br.AssetId + AND rr.BorrowDate >= br.BorrowDate + AND ( + (rr.CreatedBy IS NOT NULL AND br.CreatedBy IS NOT NULL AND rr.CreatedBy = br.CreatedBy) + OR LOWER(LTRIM(RTRIM(ISNULL(rr.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(br.BorrowerName, '')))) + ) + ) + ) + ) returnSummary + OUTER APPLY ( + SELECT COUNT(1) AS BorrowCount + FROM AssetBorrowRequestLinks l + INNER JOIN AssetBorrowRequests bb ON bb.BorrowId = l.BorrowId + WHERE l.ReturnId = br.BorrowId + AND LOWER(LTRIM(RTRIM(ISNULL(bb.RequestType, '')))) = 'borrow' + ) borrowSummary ${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'} ORDER BY br.CreatedDate DESC, br.BorrowId DESC `); @@ -3653,7 +3792,182 @@ app.get('/api/asset-borrows', async (req, res) => { } }); +app.get('/api/asset-borrows/:id/history', async (req, res) => { + 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 khong hop le' }); + } + + if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) { + return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' }); + } + + const targetRequest = pool.request() + .input('borrowId', sql.Int, borrowId) + .input('requesterId', sql.Int, requesterId || -1); + + const targetResult = await targetRequest.query(` + SELECT TOP 1 + br.BorrowId, + br.AssetId, + ai.AssetCode, + ai.AssetName, + br.RequestType, + br.RequestStatus, + br.BorrowerName, + br.BorrowQuantity, + ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity, + CASE + WHEN LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow' + THEN CASE + WHEN ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) < 0 THEN 0 + ELSE ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) + END + ELSE 0 + END AS RemainingQuantity, + COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit, + br.BorrowDate, + br.RequestNote, + br.RejectReason, + br.CreatedBy, + br.ProcessedBy, + br.ProcessedByName, + br.ProcessedDate, + br.CreatedDate + FROM AssetBorrowRequests br + LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId + WHERE br.BorrowId = @borrowId + ${canManageRequests ? '' : 'AND br.CreatedBy = @requesterId'} + `); + + const target = targetResult.recordset?.[0]; + if (!target) { + return res.status(404).json({ success: false, message: 'Khong tim thay don' }); + } + + const targetType = normalizeAssetRequestType(target.RequestType); + const relatedRequest = pool.request() + .input('borrowId', sql.Int, borrowId) + .input('assetId', sql.Int, Number(target.AssetId) || -1) + .input('borrowerName', sql.NVarChar, String(target.BorrowerName || '').trim()) + .input('createdBy', sql.Int, Number.isInteger(Number(target.CreatedBy)) ? Number(target.CreatedBy) : null) + .input('borrowDate', sql.Date, target.BorrowDate || null) + .input('targetType', sql.NVarChar, targetType) + .input('requesterId', sql.Int, requesterId || -1); + + const relatedResult = await relatedRequest.query(` + ;WITH linkedIds AS ( + SELECT l.BorrowId AS RelatedId + FROM AssetBorrowRequestLinks l + WHERE l.ReturnId = @borrowId + UNION + SELECT l.ReturnId AS RelatedId + FROM AssetBorrowRequestLinks l + WHERE l.BorrowId = @borrowId + ) + SELECT DISTINCT + br.BorrowId, + br.AssetId, + ai.AssetCode, + ai.AssetName, + br.RequestType, + br.RequestStatus, + br.BorrowerName, + br.BorrowQuantity, + ISNULL(br.ReturnedQuantity, 0) AS ReturnedQuantity, + CASE + WHEN LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow' + THEN CASE + WHEN ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) < 0 THEN 0 + ELSE ISNULL(br.BorrowQuantity, 0) - ISNULL(br.ReturnedQuantity, 0) + END + ELSE 0 + END AS RemainingQuantity, + COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit, + br.BorrowDate, + br.RequestNote, + br.RejectReason, + br.CreatedBy, + br.ProcessedBy, + br.ProcessedByName, + br.ProcessedDate, + br.CreatedDate + FROM AssetBorrowRequests br + LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId + WHERE ( + br.BorrowId = @borrowId + OR br.BorrowId IN (SELECT RelatedId FROM linkedIds) + OR ( + br.AssetId = @assetId + AND LOWER(LTRIM(RTRIM(ISNULL(br.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(@borrowerName, '')))) + AND ( + @createdBy IS NULL + OR br.CreatedBy = @createdBy + OR br.CreatedBy IS NULL + ) + AND ( + (@targetType = 'borrow' + AND LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'return' + AND br.BorrowDate >= @borrowDate) + OR + (@targetType = 'return' + AND LOWER(LTRIM(RTRIM(ISNULL(br.RequestType, '')))) = 'borrow' + AND br.BorrowDate <= @borrowDate) + ) + ) + ) + ${canManageRequests ? '' : 'AND br.CreatedBy = @requesterId'} + ORDER BY br.BorrowDate ASC, br.CreatedDate ASC, br.BorrowId ASC + `); + + const linkResult = await pool.request() + .input('borrowId', sql.Int, borrowId) + .query(` + SELECT + l.LinkId, + l.BorrowId, + l.ReturnId, + l.Quantity, + l.CreatedDate + FROM AssetBorrowRequestLinks l + WHERE l.BorrowId = @borrowId + OR l.ReturnId = @borrowId + OR l.BorrowId IN ( + SELECT linked.BorrowId + FROM AssetBorrowRequestLinks linked + WHERE linked.ReturnId = @borrowId + ) + OR l.ReturnId IN ( + SELECT linked.ReturnId + FROM AssetBorrowRequestLinks linked + WHERE linked.BorrowId = @borrowId + ) + ORDER BY l.CreatedDate ASC, l.LinkId ASC + `); + + const relatedRows = Array.isArray(relatedResult.recordset) ? relatedResult.recordset : []; + res.json({ + success: true, + data: { + request: target, + borrowRequests: relatedRows.filter(item => normalizeAssetRequestType(item.RequestType) === 'borrow'), + returnRequests: relatedRows.filter(item => normalizeAssetRequestType(item.RequestType) === 'return'), + links: Array.isArray(linkResult.recordset) ? linkResult.recordset : [] + } + }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + app.post('/api/asset-borrows', async (req, res) => { + let transaction; + try { const createdBy = getUserIdFromRequest(req); const actorName = await getUserDisplayNameById(createdBy); @@ -3738,7 +4052,60 @@ app.post('/api/asset-borrows', async (req, res) => { } } - const insertResult = await pool.request() + transaction = new sql.Transaction(pool); + await transaction.begin(); + + let returnLinkRows = []; + if (requestType === 'return') { + const createdByValue = Number.isInteger(Number(createdBy)) ? Number(createdBy) : null; + const linkableResult = await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .input('borrowerName', sql.NVarChar, borrowerName) + .input('createdBy', sql.Int, createdByValue) + .query(` + SELECT + borrowRows.BorrowId, + borrowRows.BorrowQuantity, + ISNULL(borrowRows.ReturnedQuantity, 0) AS ReturnedQuantity, + ISNULL(activeReturns.ActiveReturnQuantity, 0) AS ActiveReturnQuantity + FROM AssetBorrowRequests borrowRows WITH (UPDLOCK, HOLDLOCK) + 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 = borrowRows.BorrowId + AND LOWER(LTRIM(RTRIM(ISNULL(returnRows.RequestStatus, '')))) IN ('pending', 'approved') + ) activeReturns + WHERE borrowRows.AssetId = @assetId + AND LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestType, '')))) = 'borrow' + AND LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestStatus, '')))) IN ('approved', 'returned') + AND LOWER(LTRIM(RTRIM(ISNULL(borrowRows.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(@borrowerName, '')))) + AND ( + @createdBy IS NULL + OR borrowRows.CreatedBy = @createdBy + OR borrowRows.CreatedBy IS NULL + ) + ORDER BY borrowRows.BorrowDate ASC, borrowRows.CreatedDate ASC, borrowRows.BorrowId ASC + `); + + returnLinkRows = Array.isArray(linkableResult.recordset) ? linkableResult.recordset : []; + const availableReturnQuantity = returnLinkRows.reduce((sum, row) => { + const originalQuantity = parseNonNegativeInteger(row.BorrowQuantity, 0); + const returnedQuantity = parseNonNegativeInteger(row.ReturnedQuantity, 0); + const activeReturnQuantity = parseNonNegativeInteger(row.ActiveReturnQuantity, 0); + return sum + Math.max(originalQuantity - Math.max(returnedQuantity, activeReturnQuantity), 0); + }, 0); + + if (borrowQuantity > availableReturnQuantity) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `So luong tra (${borrowQuantity}) vuot qua so luong con co the tao don tra (${availableReturnQuantity}). Co the da co don tra dang cho duyet.` + }); + } + } + + const insertResult = await new sql.Request(transaction) .input('assetId', sql.Int, assetId) .input('requestType', sql.NVarChar, requestType) .input('requestStatus', sql.NVarChar, 'pending') @@ -3773,16 +4140,58 @@ app.post('/api/asset-borrows', async (req, res) => { SELECT SCOPE_IDENTITY() AS BorrowId; `); + const createdRequestId = Number(insertResult.recordset?.[0]?.BorrowId) || null; + + if (requestType === 'return' && createdRequestId) { + let remainingToLink = borrowQuantity; + for (const row of returnLinkRows) { + if (remainingToLink <= 0) { + break; + } + + const borrowRequestId = Number(row.BorrowId); + const originalQuantity = parseNonNegativeInteger(row.BorrowQuantity, 0); + const returnedQuantity = parseNonNegativeInteger(row.ReturnedQuantity, 0); + const activeReturnQuantity = parseNonNegativeInteger(row.ActiveReturnQuantity, 0); + const availableQuantity = Math.max(originalQuantity - Math.max(returnedQuantity, activeReturnQuantity), 0); + const linkedQuantity = Math.min(availableQuantity, remainingToLink); + + if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0 || linkedQuantity <= 0) { + continue; + } + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowRequestId) + .input('returnId', sql.Int, createdRequestId) + .input('quantity', sql.Int, linkedQuantity) + .query(` + INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity) + VALUES (@borrowId, @returnId, @quantity); + `); + + remainingToLink -= linkedQuantity; + } + } + + await transaction.commit(); + res.json({ success: true, message: requestType === 'return' ? 'Tao don tra tai san thanh cong. Don dang cho xu ly.' : 'Tao don muon tai san thanh cong. Don dang cho xu ly.', data: { - borrowId: insertResult.recordset?.[0]?.BorrowId || null + borrowId: createdRequestId } }); } catch (err) { + if (transaction) { + try { + await transaction.rollback(); + } catch (rollbackErr) { + // Ignore rollback errors when transaction already finished. + } + } res.status(500).json({ success: false, message: err.message }); } }); @@ -3827,6 +4236,7 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) br.BorrowQuantity, br.BorrowDate, br.Unit, + br.CreatedBy, ai.AssetCode, ai.AssetName, ai.Quantity, @@ -3990,6 +4400,133 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) UpdatedDate = GETDATE() WHERE AssetId = @assetId `); + + const existingReturnLinksResult = await new sql.Request(transaction) + .input('returnId', sql.Int, borrowId) + .query(` + SELECT + links.BorrowId, + links.Quantity, + borrowRows.BorrowQuantity, + ISNULL(borrowRows.ReturnedQuantity, 0) AS ReturnedQuantity + FROM AssetBorrowRequestLinks links + INNER JOIN AssetBorrowRequests borrowRows WITH (UPDLOCK, HOLDLOCK) + ON borrowRows.BorrowId = links.BorrowId + WHERE links.ReturnId = @returnId + ORDER BY borrowRows.BorrowDate ASC, borrowRows.CreatedDate ASC, borrowRows.BorrowId ASC + `); + + const existingReturnLinks = Array.isArray(existingReturnLinksResult.recordset) + ? existingReturnLinksResult.recordset + : []; + + if (existingReturnLinks.length) { + for (const linkRow of existingReturnLinks) { + const borrowRequestId = Number(linkRow.BorrowId); + const linkedQuantity = parseNonNegativeInteger(linkRow.Quantity, 0); + if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0 || linkedQuantity <= 0) { + continue; + } + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowRequestId) + .input('quantity', sql.Int, linkedQuantity) + .query(` + UPDATE AssetBorrowRequests + SET ReturnedQuantity = CASE + WHEN ISNULL(ReturnedQuantity, 0) + @quantity > ISNULL(BorrowQuantity, 0) + THEN ISNULL(BorrowQuantity, 0) + ELSE ISNULL(ReturnedQuantity, 0) + @quantity + END, + RequestStatus = CASE + WHEN ISNULL(ReturnedQuantity, 0) + @quantity >= ISNULL(BorrowQuantity, 0) + THEN 'returned' + ELSE RequestStatus + END, + UpdatedDate = GETDATE() + WHERE BorrowId = @borrowId + `); + } + } else { + let remainingToLink = requestQuantity; + const createdByValue = Number.isInteger(Number(targetRequest.CreatedBy)) + ? Number(targetRequest.CreatedBy) + : null; + const borrowRowsResult = await new sql.Request(transaction) + .input('assetId', sql.Int, targetRequest.AssetId) + .input('borrowerName', sql.NVarChar, borrowerName) + .input('createdBy', sql.Int, createdByValue) + .query(` + SELECT + BorrowId, + BorrowQuantity, + ISNULL(ReturnedQuantity, 0) AS ReturnedQuantity + FROM AssetBorrowRequests WITH (UPDLOCK, HOLDLOCK) + WHERE AssetId = @assetId + AND LOWER(LTRIM(RTRIM(ISNULL(RequestType, '')))) = 'borrow' + AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) IN ('approved', 'returned') + AND ISNULL(BorrowQuantity, 0) > ISNULL(ReturnedQuantity, 0) + AND LOWER(LTRIM(RTRIM(ISNULL(BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(@borrowerName, '')))) + AND ( + @createdBy IS NULL + OR CreatedBy = @createdBy + OR CreatedBy IS NULL + ) + ORDER BY BorrowDate ASC, CreatedDate ASC, BorrowId ASC + `); + + for (const borrowRow of (borrowRowsResult.recordset || [])) { + if (remainingToLink <= 0) { + break; + } + + const borrowRequestId = Number(borrowRow.BorrowId); + const originalQuantity = parseNonNegativeInteger(borrowRow.BorrowQuantity, 0); + const alreadyReturned = parseNonNegativeInteger(borrowRow.ReturnedQuantity, 0); + const availableToReturn = Math.max(originalQuantity - alreadyReturned, 0); + const linkedQuantity = Math.min(availableToReturn, remainingToLink); + + if (!Number.isInteger(borrowRequestId) || borrowRequestId <= 0 || linkedQuantity <= 0) { + continue; + } + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowRequestId) + .input('returnId', sql.Int, borrowId) + .input('quantity', sql.Int, linkedQuantity) + .query(` + IF NOT EXISTS ( + SELECT 1 + FROM AssetBorrowRequestLinks + WHERE BorrowId = @borrowId + AND ReturnId = @returnId + ) + INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity) + VALUES (@borrowId, @returnId, @quantity); + `); + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowRequestId) + .input('quantity', sql.Int, linkedQuantity) + .query(` + UPDATE AssetBorrowRequests + SET ReturnedQuantity = CASE + WHEN ISNULL(ReturnedQuantity, 0) + @quantity > ISNULL(BorrowQuantity, 0) + THEN ISNULL(BorrowQuantity, 0) + ELSE ISNULL(ReturnedQuantity, 0) + @quantity + END, + RequestStatus = CASE + WHEN ISNULL(ReturnedQuantity, 0) + @quantity >= ISNULL(BorrowQuantity, 0) + THEN 'returned' + ELSE RequestStatus + END, + UpdatedDate = GETDATE() + WHERE BorrowId = @borrowId + `); + + remainingToLink -= linkedQuantity; + } + } } } @@ -4029,6 +4566,8 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) }); app.delete('/api/asset-borrows/:id', 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); @@ -4043,35 +4582,26 @@ app.delete('/api/asset-borrows/:id', async (req, res) => { return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' }); } - const deleteResult = await pool.request() + await transaction.begin(); + + const targetResult = await new sql.Request(transaction) .input('borrowId', sql.Int, borrowId) .input('requesterId', sql.Int, requesterId || -1) .query(` - DELETE FROM AssetBorrowRequests - OUTPUT DELETED.BorrowId + SELECT TOP 1 BorrowId, RequestStatus, CreatedBy + FROM AssetBorrowRequests WITH (UPDLOCK, HOLDLOCK) WHERE BorrowId = @borrowId - AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending' ${canManageRequests ? '' : 'AND CreatedBy = @requesterId'} `); - if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) { - return res.json({ success: true, message: 'Da huy don cho' }); - } - - const existed = await pool.request() - .input('borrowId', sql.Int, borrowId) - .query(` - SELECT TOP 1 BorrowId, RequestStatus, CreatedBy - FROM AssetBorrowRequests - WHERE BorrowId = @borrowId - `); - - const row = existed.recordset?.[0]; + const row = targetResult.recordset?.[0]; if (!row) { + await transaction.rollback(); return res.status(404).json({ success: false, message: 'Khong tim thay don can xoa' }); } if (!canManageRequests && Number(row.CreatedBy) !== requesterId) { + await transaction.rollback(); return res.status(403).json({ success: false, message: 'Ban chi duoc huy don do chinh minh tao' @@ -4080,17 +4610,37 @@ app.delete('/api/asset-borrows/:id', async (req, res) => { const currentStatus = normalizeAssetRequestStatus(row.RequestStatus); if (currentStatus !== 'pending') { + await transaction.rollback(); return res.status(400).json({ success: false, message: 'Chi duoc huy don o trang thai cho xu ly' }); } - return res.status(400).json({ - success: false, - message: 'Khong the huy don vao luc nay' - }); + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowId) + .query(` + DELETE FROM AssetBorrowRequestLinks + WHERE BorrowId = @borrowId + OR ReturnId = @borrowId + `); + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowId) + .query(` + DELETE FROM AssetBorrowRequests + WHERE BorrowId = @borrowId + `); + + await transaction.commit(); + + return res.json({ success: true, message: 'Da huy don cho' }); } 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 }); } }); @@ -4593,13 +5143,45 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => { }); app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => { + const transaction = new sql.Transaction(pool); + try { - await pool.request() - .input('assetId', sql.Int, req.params.id) - .query('DELETE FROM AssetInventory WHERE AssetId = @assetId'); + const assetId = Number(req.params.id); + if (!Number.isInteger(assetId) || assetId <= 0) { + return res.status(400).json({ success: false, message: 'Asset id is invalid' }); + } + + await transaction.begin(); + + await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .query(` + DELETE links + FROM AssetBorrowRequestLinks links + INNER JOIN AssetBorrowRequests requests + ON requests.BorrowId = links.BorrowId + OR requests.BorrowId = links.ReturnId + WHERE requests.AssetId = @assetId + `); + + const deleteResult = await new sql.Request(transaction) + .input('assetId', sql.Int, assetId) + .query('DELETE FROM AssetInventory OUTPUT DELETED.AssetId WHERE AssetId = @assetId'); + + if (!deleteResult.recordset?.length) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Asset not found' }); + } + + await transaction.commit(); res.json({ success: true, message: 'Asset deleted' }); } catch (err) { + try { + await transaction.rollback(); + } catch (rollbackErr) { + // Ignore rollback errors when transaction already finished. + } res.status(500).json({ success: false, message: err.message }); } }); diff --git a/database/setup.sql b/database/setup.sql index 254d691..a4f6e9f 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -222,6 +222,7 @@ BEGIN RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', BorrowerName NVARCHAR(100) NOT NULL, BorrowQuantity INT NOT NULL DEFAULT 1, + ReturnedQuantity INT NOT NULL DEFAULT 0, Unit NVARCHAR(50), BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), RequestNote NVARCHAR(500) NULL, @@ -264,6 +265,11 @@ BEGIN ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved'); END +IF COL_LENGTH('dbo.AssetBorrowRequests', 'ReturnedQuantity') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD ReturnedQuantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequests_ReturnedQuantity DEFAULT(0); +END + IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestNote') IS NULL BEGIN ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL; @@ -302,6 +308,83 @@ SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow'); UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved'); +UPDATE AssetBorrowRequests +SET ReturnedQuantity = 0 +WHERE ReturnedQuantity IS NULL OR ReturnedQuantity < 0; + +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequestLinks') +BEGIN + CREATE TABLE AssetBorrowRequestLinks ( + LinkId INT PRIMARY KEY IDENTITY(1,1), + BorrowId INT NOT NULL, + ReturnId INT NOT NULL, + Quantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_Quantity DEFAULT(1), + CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_CreatedDate DEFAULT(GETDATE()), + FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION, + FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION + ); +END + +IF OBJECT_ID('dbo.AssetBorrowRequestLinks', 'U') IS NOT NULL +BEGIN + INSERT INTO AssetBorrowRequestLinks (BorrowId, ReturnId, Quantity) + SELECT matchedBorrow.BorrowId, + ret.BorrowId, + CASE + WHEN ISNULL(ret.BorrowQuantity, 0) <= 0 THEN 1 + WHEN ISNULL(ret.BorrowQuantity, 0) > ISNULL(matchedBorrow.BorrowQuantity, 0) THEN ISNULL(matchedBorrow.BorrowQuantity, 0) + ELSE ISNULL(ret.BorrowQuantity, 0) + END + FROM AssetBorrowRequests ret + CROSS APPLY ( + SELECT TOP 1 b.BorrowId, b.BorrowQuantity + FROM AssetBorrowRequests b + WHERE LOWER(LTRIM(RTRIM(ISNULL(b.RequestType, '')))) = 'borrow' + AND LOWER(LTRIM(RTRIM(ISNULL(b.RequestStatus, '')))) IN ('approved', 'returned') + AND b.AssetId = ret.AssetId + AND ( + (ret.CreatedBy IS NOT NULL AND b.CreatedBy = ret.CreatedBy) + OR LOWER(LTRIM(RTRIM(ISNULL(b.BorrowerName, '')))) = LOWER(LTRIM(RTRIM(ISNULL(ret.BorrowerName, '')))) + ) + AND b.BorrowDate <= ret.BorrowDate + ORDER BY b.BorrowDate DESC, b.CreatedDate DESC, b.BorrowId DESC + ) matchedBorrow + WHERE LOWER(LTRIM(RTRIM(ISNULL(ret.RequestType, '')))) = 'return' + AND LOWER(LTRIM(RTRIM(ISNULL(ret.RequestStatus, '')))) IN ('pending', 'approved') + AND NOT EXISTS ( + SELECT 1 + FROM AssetBorrowRequestLinks existed + WHERE existed.ReturnId = ret.BorrowId + ) + AND NOT EXISTS ( + SELECT 1 + FROM AssetBorrowRequestLinks duplicateLink + WHERE duplicateLink.ReturnId = ret.BorrowId + AND duplicateLink.BorrowId = matchedBorrow.BorrowId + ); + + UPDATE borrowRows + SET ReturnedQuantity = CASE + WHEN summary.ReturnedQuantity > ISNULL(borrowRows.BorrowQuantity, 0) THEN ISNULL(borrowRows.BorrowQuantity, 0) + ELSE summary.ReturnedQuantity + END, + RequestStatus = CASE + WHEN summary.ReturnedQuantity >= ISNULL(borrowRows.BorrowQuantity, 0) + THEN 'returned' + ELSE borrowRows.RequestStatus + END, + UpdatedDate = GETDATE() + FROM AssetBorrowRequests borrowRows + INNER JOIN ( + SELECT links.BorrowId, SUM(ISNULL(links.Quantity, 0)) AS ReturnedQuantity + FROM AssetBorrowRequestLinks links + INNER JOIN AssetBorrowRequests returns ON returns.BorrowId = links.ReturnId + WHERE LOWER(LTRIM(RTRIM(ISNULL(returns.RequestStatus, '')))) = 'approved' + GROUP BY links.BorrowId + ) summary ON summary.BorrowId = borrowRows.BorrowId + WHERE LOWER(LTRIM(RTRIM(ISNULL(borrowRows.RequestType, '')))) = 'borrow'; +END + -- =========================================== -- 8. CREATE ASSET EXPORT HISTORY TABLE -- =========================================== @@ -482,6 +565,21 @@ BEGIN CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequestLinks_BorrowId') +BEGIN + CREATE INDEX IX_AssetBorrowRequestLinks_BorrowId ON AssetBorrowRequestLinks(BorrowId); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequestLinks_ReturnId') +BEGIN + CREATE INDEX IX_AssetBorrowRequestLinks_ReturnId ON AssetBorrowRequestLinks(ReturnId); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetBorrowRequestLinks_BorrowReturn') +BEGIN + CREATE UNIQUE INDEX UX_AssetBorrowRequestLinks_BorrowReturn ON AssetBorrowRequestLinks(BorrowId, ReturnId); +END + IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId') BEGIN CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId); diff --git a/public/js/app.js b/public/js/app.js index b75b421..ff5d93d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1477,6 +1477,12 @@ class AccountManager { return rows.filter(item => { const requestType = this.normalizeAssetRequestType(item.RequestType); + const requestStatus = this.normalizeAssetRequestStatus(item.RequestStatus); + const relatedReturnCount = this.parseNonNegativeInteger(item?.RelatedReturnCount, 0); + if (requestType === 'borrow' && (requestStatus === 'returned' || relatedReturnCount > 0 || this.hasActiveReturnForBorrowRequest(item, rows))) { + return false; + } + const matchesType = !typeFilter || requestType === typeFilter; if (!matchesType) { return false; @@ -1491,9 +1497,11 @@ class AccountManager { item.AssetCode, item.AssetName, this.getAssetRequestTypeMeta(item.RequestType).label, - this.getAssetRequestStatusMeta(item.RequestStatus).label, + this.getAssetRequestStatusMeta(item.RequestStatus, item).label, item.Unit, item.BorrowQuantity, + item.ReturnedQuantity, + item.RemainingQuantity, item.BorrowDate, item.RequestNote, item.RejectReason @@ -1503,6 +1511,56 @@ class AccountManager { }); } + hasActiveReturnForBorrowRequest(borrowRequest, rows = []) { + const assetId = Number(borrowRequest?.AssetId); + const borrowerName = String(borrowRequest?.BorrowerName || '').trim().toLowerCase(); + const createdBy = Number(borrowRequest?.CreatedBy); + const borrowDate = new Date(borrowRequest?.BorrowDate || 0); + const borrowTime = Number.isNaN(borrowDate.getTime()) ? null : borrowDate.getTime(); + + if (!Number.isFinite(assetId) || assetId <= 0) { + return false; + } + + return (Array.isArray(rows) ? rows : []).some(candidate => { + if (!candidate || candidate === borrowRequest) { + return false; + } + + if (this.normalizeAssetRequestType(candidate?.RequestType) !== 'return') { + return false; + } + + const status = this.normalizeAssetRequestStatus(candidate?.RequestStatus); + if (status !== 'pending' && status !== 'approved') { + return false; + } + + if (Number(candidate?.AssetId) !== assetId) { + return false; + } + + const candidateCreatedBy = Number(candidate?.CreatedBy); + const sameCreator = Number.isFinite(createdBy) + && Number.isFinite(candidateCreatedBy) + && createdBy === candidateCreatedBy; + const sameBorrower = borrowerName + && String(candidate?.BorrowerName || '').trim().toLowerCase() === borrowerName; + + if (!sameCreator && !sameBorrower) { + return false; + } + + if (borrowTime === null) { + return true; + } + + const returnDate = new Date(candidate?.BorrowDate || 0); + const returnTime = Number.isNaN(returnDate.getTime()) ? null : returnDate.getTime(); + return returnTime === null || returnTime >= borrowTime; + }); + } + syncSelectedAssetIds() { if (!(this.selectedAssetIds instanceof Set)) { this.selectedAssetIds = new Set(); @@ -1919,6 +1977,7 @@ class AccountManager { normalizeAssetRequestStatus(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'approved') return 'approved'; + if (normalized === 'returned') return 'returned'; if (normalized === 'rejected') return 'rejected'; return 'pending'; } @@ -1940,12 +1999,23 @@ class AccountManager { }; } - getAssetRequestStatusMeta(value) { + getAssetRequestStatusMeta(value, item = null) { const status = this.normalizeAssetRequestStatus(value); + const requestType = item ? this.normalizeAssetRequestType(item?.RequestType) : ''; + const borrowQuantity = this.parseNonNegativeInteger(item?.BorrowQuantity, 0); + const returnedQuantity = this.parseNonNegativeInteger(item?.ReturnedQuantity, 0); + if (status === 'returned' || (requestType === 'borrow' && status === 'approved' && borrowQuantity > 0 && returnedQuantity >= borrowQuantity)) { + return { + value: 'returned', + label: 'Đã trả', + className: 'bg-slate-100 text-slate-700 border border-slate-200' + }; + } + if (status === 'approved') { return { value: 'approved', - label: 'Chấp nhận', + label: requestType === 'borrow' ? 'Đang mượn' : (requestType === 'return' ? 'Đã trả' : 'Chấp nhận'), className: 'bg-green-100 text-green-700 border border-green-200' }; } @@ -2939,11 +3009,28 @@ class AccountManager { const assetName = item.AssetName || '-'; const assetCode = item.AssetCode ? `
| STT | @@ -3255,13 +3344,14 @@ class AccountManager {STT | Tên đầy đủ | Tài sản | -Danh mục | +Danh mục | Trạng thái | Đơn vị | Số lượng | Ngày | Ghi chú | Lý do | +Chi tiết | Hành động |
|---|