This commit is contained in:
2026-05-15 09:56:33 +07:00
parent 927317a87e
commit 4aa4ac0d57
4 changed files with 950 additions and 36 deletions

View File

@@ -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 });
}
});

View File

@@ -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);

View File

@@ -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 ? `<div class="text-[11px] text-slate-500">${this.escapeHtml(item.AssetCode)}</div>` : '';
const typeMeta = this.getAssetRequestTypeMeta(item.RequestType);
const statusMeta = this.getAssetRequestStatusMeta(item.RequestStatus);
const statusMeta = this.getAssetRequestStatusMeta(item.RequestStatus, item);
const note = String(item?.RequestNote || '').trim();
const rejectReason = String(item?.RejectReason || '').trim();
const canCancel = this.canCurrentUserCancelAssetRequest(item);
const requestId = Number(item?.BorrowId) || 0;
const requestType = this.normalizeAssetRequestType(item?.RequestType);
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 returnProgressHtml = requestType === 'borrow' && returnedQuantity > 0 && statusMeta.value !== 'returned'
? `<div class="mt-1 text-[11px] text-slate-500 whitespace-nowrap">Đã trả ${returnedQuantity}/${borrowQuantity}, còn ${remainingQuantity}</div>`
: '';
const detailActionHtml = `
<button
class="asset-borrow-detail-btn inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-primary transition-colors"
data-request-id="${requestId}"
title="Xem chi tiết"
aria-label="Xem chi tiết đơn"
>
<span class="material-symbols-outlined text-base">info</span>
</button>
`;
const cancelActionHtml = canCancel
? `<button class="asset-borrow-cancel-btn px-3 py-1.5 rounded-md bg-red-600 hover:bg-red-700 text-white text-xs font-bold whitespace-nowrap" data-request-id="${requestId}">Hủy đơn</button>`
: `<span class="text-xs text-slate-400">-</span>`;
@@ -2956,17 +3043,19 @@ class AccountManager {
<div>${this.escapeHtml(assetName)}</div>
${assetCode}
</td>
<td class="px-4 py-3 text-sm text-slate-600">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold ${typeMeta.className}">${typeMeta.label}</span>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold whitespace-nowrap ${typeMeta.className}">${typeMeta.label}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-600">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold ${statusMeta.className}">${statusMeta.label}</span>
${returnProgressHtml}
</td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item.Unit || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item.BorrowQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(item.BorrowDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs">${this.escapeHtml(note || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs">${this.escapeHtml(rejectReason || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600 text-center">${detailActionHtml}</td>
<td class="px-4 py-3 text-sm text-slate-600">${cancelActionHtml}</td>
</tr>
`;
@@ -2993,7 +3082,7 @@ class AccountManager {
buildAssetBorrowEmptyRowHtml() {
return `
<tr>
<td colspan="11" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có đơn mượn/trả tài sản nào.</td>
<td colspan="12" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có đơn mượn/trả tài sản nào.</td>
</tr>
`;
}
@@ -3058,7 +3147,7 @@ class AccountManager {
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1">
<table class="w-full text-left border-collapse">
<table class="w-full text-left border-collapse" style="min-width: 1500px;">
<thead class="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">STT</th>
@@ -3255,13 +3344,14 @@ class AccountManager {
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">STT</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tên đầy đủ</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tài sản</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Danh mục</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 whitespace-nowrap">Danh mục</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Trạng thái</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Đơn vị</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Số lượng</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Lý do</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-center">Chi tiết</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Hành động</th>
</tr>
</thead>
@@ -3304,6 +3394,134 @@ class AccountManager {
this.setupAssetBorrowPagerListeners();
this.updatePendingAssetRequestsBadge();
}
async openAssetBorrowDetailsModal(requestId) {
try {
const response = await fetch(`${this.apiBase}/asset-borrows/${requestId}/history`, {
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Không tải được chi tiết đơn.');
return;
}
this.showAssetBorrowDetailsModal(data.data || {});
} catch (err) {
console.error(err);
this.notifyFailure('Không tải được chi tiết đơn.');
}
}
buildAssetBorrowDetailCardHtml(item) {
const typeMeta = this.getAssetRequestTypeMeta(item?.RequestType);
const statusMeta = this.getAssetRequestStatusMeta(item?.RequestStatus, item);
const requestType = this.normalizeAssetRequestType(item?.RequestType);
const dateLabel = requestType === 'return' ? 'Ngày trả' : 'Ngày mượn';
const assetName = `${item?.AssetCode ? `${item.AssetCode} - ` : ''}${item?.AssetName || '-'}`;
const note = String(item?.RequestNote || '').trim();
const rejectReason = String(item?.RejectReason || '').trim();
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 returnSummary = requestType === 'borrow' && returnedQuantity > 0
? `<div><span class="font-bold">Đã trả:</span> ${returnedQuantity}/${borrowQuantity}${remainingQuantity > 0 ? `, còn ${remainingQuantity}` : ''}</div>`
: '';
return `
<div class="w-full min-w-0 overflow-hidden rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 space-y-2 break-words">
<div class="flex flex-wrap items-center gap-2 min-w-0">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold ${typeMeta.className}">${typeMeta.label}</span>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold ${statusMeta.className}">${statusMeta.label}</span>
<span class="ml-auto text-xs font-extrabold text-slate-500">#${Number(item?.BorrowId) || '-'}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 min-w-0">
<div class="min-w-0"><span class="font-bold">Người tạo:</span> ${this.escapeHtml(item?.BorrowerName || '-')}</div>
<div class="min-w-0"><span class="font-bold">${dateLabel}:</span> ${this.formatDateOnly(item?.BorrowDate)}</div>
<div class="md:col-span-2 min-w-0"><span class="font-bold">Tài sản:</span> ${this.escapeHtml(assetName)}</div>
<div class="min-w-0"><span class="font-bold">Số lượng:</span> ${Number(item?.BorrowQuantity) || 0}</div>
<div class="min-w-0"><span class="font-bold">Đơn vị:</span> ${this.escapeHtml(item?.Unit || '-')}</div>
${returnSummary}
<div class="min-w-0"><span class="font-bold">Ngày xử lý:</span> ${this.formatDateTime(item?.ProcessedDate)}</div>
<div class="min-w-0"><span class="font-bold">Người xử lý:</span> ${this.escapeHtml(item?.ProcessedByName || '-')}</div>
<div class="md:col-span-2 min-w-0"><span class="font-bold">Ghi chú:</span> ${this.escapeHtml(note || '-')}</div>
<div class="md:col-span-2 min-w-0"><span class="font-bold">Lý do:</span> ${this.escapeHtml(rejectReason || '-')}</div>
</div>
</div>
`;
}
showAssetBorrowDetailsModal(history) {
const selected = history?.request || {};
const borrowRequests = Array.isArray(history?.borrowRequests) ? history.borrowRequests : [];
const returnRequests = Array.isArray(history?.returnRequests) ? history.returnRequests : [];
const selectedTypeMeta = this.getAssetRequestTypeMeta(selected?.RequestType);
const selectedStatusMeta = this.getAssetRequestStatusMeta(selected?.RequestStatus, selected);
const assetName = `${selected?.AssetCode ? `${selected.AssetCode} - ` : ''}${selected?.AssetName || '-'}`;
const renderList = (items, emptyText) => items.length
? items.map(item => this.buildAssetBorrowDetailCardHtml(item)).join('')
: `<div class="rounded-lg border border-dashed border-slate-300 px-4 py-8 text-sm text-center text-slate-500">${emptyText}</div>`;
let container = document.getElementById('assetBorrowDetailsModalContainer');
if (!container) {
container = document.createElement('div');
container.id = 'assetBorrowDetailsModalContainer';
document.body.appendChild(container);
}
container.innerHTML = `
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm modal-backdrop open" id="assetBorrowDetailsModal" style="z-index:1000;display:flex;align-items:center;justify-content:center;padding:16px;overflow:hidden;">
<div class="modal-content bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col" style="position:relative;z-index:1001;width:min(1080px, calc(100vw - 32px));max-height:calc(100vh - 32px);box-sizing:border-box;">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between gap-3 bg-slate-50">
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-900">Chi tiết mượn/trả tài sản</h3>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500 min-w-0">
<span class="font-bold text-slate-700 break-words min-w-0">${this.escapeHtml(assetName)}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded ${selectedTypeMeta.className}">${selectedTypeMeta.label}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded ${selectedStatusMeta.className}">${selectedStatusMeta.label}</span>
</div>
</div>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetBorrowDetailsModal()" title="Đóng">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-4 md:p-6 overflow-y-auto overflow-x-hidden bg-slate-50/60">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-w-0">
<section class="space-y-3 min-w-0">
<div class="flex items-center justify-between">
<h4 class="text-sm font-extrabold text-blue-700">Lịch sử đơn mượn</h4>
<span class="text-xs font-bold text-slate-500">${borrowRequests.length}</span>
</div>
${renderList(borrowRequests, 'Không tìm thấy đơn mượn liên quan.')}
</section>
<section class="space-y-3 min-w-0">
<div class="flex items-center justify-between">
<h4 class="text-sm font-extrabold text-emerald-700">Lịch sử đơn trả</h4>
<span class="text-xs font-bold text-slate-500">${returnRequests.length}</span>
</div>
${renderList(returnRequests, 'Chưa có đơn trả liên quan.')}
</section>
</div>
</div>
<div class="p-4 border-t border-slate-100 bg-white flex justify-end">
<button type="button" class="px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50" onclick="closeAssetBorrowDetailsModal()">Đóng</button>
</div>
</div>
</div>
`;
const modal = document.getElementById('assetBorrowDetailsModal');
if (modal) {
modal.addEventListener('click', event => {
if (event.target === modal) {
closeAssetBorrowDetailsModal();
}
});
}
}
setupAssetBorrowPagerListeners() {
document.querySelectorAll('.asset-borrow-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -3350,6 +3568,15 @@ class AccountManager {
const tableBody = document.querySelector('.asset-borrows-table-body');
if (tableBody && tableBody.dataset.boundActions !== 'true') {
tableBody.addEventListener('click', (event) => {
const detailButton = event.target.closest('.asset-borrow-detail-btn');
if (detailButton) {
const requestId = Number(detailButton.dataset.requestId);
if (Number.isFinite(requestId) && requestId > 0) {
this.openAssetBorrowDetailsModal(requestId);
}
return;
}
const cancelButton = event.target.closest('.asset-borrow-cancel-btn');
if (!cancelButton) {
return;
@@ -7523,6 +7750,13 @@ function closeAssetPendingRequestsModal() {
}
}
function closeAssetBorrowDetailsModal() {
const modal = document.getElementById('assetBorrowDetailsModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetRequestRejectModal() {
const modal = document.getElementById('assetRequestRejectModal');
if (modal) {

View File

@@ -309,6 +309,6 @@
</div>
</main>
<script src="../js/app.js?v=20260424-1"></script>
<script src="../js/app.js?v=20260515-4"></script>
</body>
</html>