Compare commits

...

2 Commits

Author SHA1 Message Date
e1b553ba79 fix mượn trả 2026-05-15 10:05:42 +07:00
4aa4ac0d57 fixx v1 2026-05-15 09:56:33 +07:00
4 changed files with 1203 additions and 37 deletions

View File

@@ -674,6 +674,9 @@ function normalizeAssetRequestStatus(value) {
if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') { if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') {
return 'approved'; return 'approved';
} }
if (normalized === 'returned' || normalized === 'return_done' || normalized === 'done' || normalized === 'da_tra') {
return 'returned';
}
if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') { if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') {
return 'rejected'; return 'rejected';
} }
@@ -1787,6 +1790,7 @@ async function createTables() {
RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending',
BorrowerName NVARCHAR(100) NOT NULL, BorrowerName NVARCHAR(100) NOT NULL,
BorrowQuantity INT NOT NULL DEFAULT 1, BorrowQuantity INT NOT NULL DEFAULT 1,
ReturnedQuantity INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50), Unit NVARCHAR(50),
BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE),
RequestNote NVARCHAR(500) NULL, RequestNote NVARCHAR(500) NULL,
@@ -1803,6 +1807,20 @@ async function createTables() {
) )
END`, 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 // Asset Export History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory') `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory')
BEGIN 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_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_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_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) { } catch (err) {
console.error('AssetBorrowRequests index creation error:', err.message); 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','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','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','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','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','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;`); 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; FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
`); `);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE());`); await pool.request().query(`IF COL_LENGTH('dbo.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','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','AssetName') IS NULL ALTER TABLE AssetExportHistory ADD AssetName NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportQuantity') IS NULL ALTER TABLE AssetExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_AssetExportHistory_ExportQuantity DEFAULT(1);`); await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','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 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 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 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 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;`); 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.RequestStatus,
br.BorrowerName, br.BorrowerName,
br.BorrowQuantity, 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, COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit,
br.BorrowDate, br.BorrowDate,
br.RequestNote, br.RequestNote,
@@ -3643,6 +3752,36 @@ app.get('/api/asset-borrows', async (req, res) => {
br.CreatedDate br.CreatedDate
FROM AssetBorrowRequests br FROM AssetBorrowRequests br
LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId 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'} ${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'}
ORDER BY br.CreatedDate DESC, br.BorrowId DESC 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) => { app.post('/api/asset-borrows', async (req, res) => {
let transaction;
try { try {
const createdBy = getUserIdFromRequest(req); const createdBy = getUserIdFromRequest(req);
const actorName = await getUserDisplayNameById(createdBy); 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('assetId', sql.Int, assetId)
.input('requestType', sql.NVarChar, requestType) .input('requestType', sql.NVarChar, requestType)
.input('requestStatus', sql.NVarChar, 'pending') .input('requestStatus', sql.NVarChar, 'pending')
@@ -3773,20 +4140,223 @@ app.post('/api/asset-borrows', async (req, res) => {
SELECT SCOPE_IDENTITY() AS BorrowId; 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({ res.json({
success: true, success: true,
message: requestType === 'return' message: requestType === 'return'
? 'Tao don tra tai san thanh cong. Don dang cho xu ly.' ? 'Tao don tra tai san thanh cong. Don dang cho xu ly.'
: 'Tao don muon tai san thanh cong. Don dang cho xu ly.', : 'Tao don muon tai san thanh cong. Don dang cho xu ly.',
data: { data: {
borrowId: insertResult.recordset?.[0]?.BorrowId || null borrowId: createdRequestId
} }
}); });
} catch (err) { } 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 }); res.status(500).json({ success: false, message: err.message });
} }
}); });
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) => { app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => {
const transaction = new sql.Transaction(pool); const transaction = new sql.Transaction(pool);
@@ -3827,6 +4397,7 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
br.BorrowQuantity, br.BorrowQuantity,
br.BorrowDate, br.BorrowDate,
br.Unit, br.Unit,
br.CreatedBy,
ai.AssetCode, ai.AssetCode,
ai.AssetName, ai.AssetName,
ai.Quantity, ai.Quantity,
@@ -3990,6 +4561,133 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
UpdatedDate = GETDATE() UpdatedDate = GETDATE()
WHERE AssetId = @assetId 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 +4727,8 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
}); });
app.delete('/api/asset-borrows/:id', async (req, res) => { app.delete('/api/asset-borrows/:id', async (req, res) => {
const transaction = new sql.Transaction(pool);
try { try {
const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole); const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
const requesterId = getUserIdFromRequest(req); const requesterId = getUserIdFromRequest(req);
@@ -4043,35 +4743,26 @@ app.delete('/api/asset-borrows/:id', async (req, res) => {
return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' }); 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('borrowId', sql.Int, borrowId)
.input('requesterId', sql.Int, requesterId || -1) .input('requesterId', sql.Int, requesterId || -1)
.query(` .query(`
DELETE FROM AssetBorrowRequests SELECT TOP 1 BorrowId, RequestStatus, CreatedBy
OUTPUT DELETED.BorrowId FROM AssetBorrowRequests WITH (UPDLOCK, HOLDLOCK)
WHERE BorrowId = @borrowId WHERE BorrowId = @borrowId
AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending'
${canManageRequests ? '' : 'AND CreatedBy = @requesterId'} ${canManageRequests ? '' : 'AND CreatedBy = @requesterId'}
`); `);
if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) { const row = targetResult.recordset?.[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];
if (!row) { if (!row) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Khong tim thay don can xoa' }); return res.status(404).json({ success: false, message: 'Khong tim thay don can xoa' });
} }
if (!canManageRequests && Number(row.CreatedBy) !== requesterId) { if (!canManageRequests && Number(row.CreatedBy) !== requesterId) {
await transaction.rollback();
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ban chi duoc huy don do chinh minh tao' message: 'Ban chi duoc huy don do chinh minh tao'
@@ -4080,17 +4771,37 @@ app.delete('/api/asset-borrows/:id', async (req, res) => {
const currentStatus = normalizeAssetRequestStatus(row.RequestStatus); const currentStatus = normalizeAssetRequestStatus(row.RequestStatus);
if (currentStatus !== 'pending') { if (currentStatus !== 'pending') {
await transaction.rollback();
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Chi duoc huy don o trang thai cho xu ly' message: 'Chi duoc huy don o trang thai cho xu ly'
}); });
} }
return res.status(400).json({ await new sql.Request(transaction)
success: false, .input('borrowId', sql.Int, borrowId)
message: 'Khong the huy don vao luc nay' .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) { } 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 }); return res.status(500).json({ success: false, message: err.message });
} }
}); });
@@ -4593,13 +5304,45 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
}); });
app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => { app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
const transaction = new sql.Transaction(pool);
try { try {
await pool.request() const assetId = Number(req.params.id);
.input('assetId', sql.Int, req.params.id) if (!Number.isInteger(assetId) || assetId <= 0) {
.query('DELETE FROM AssetInventory WHERE AssetId = @assetId'); 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' }); res.json({ success: true, message: 'Asset deleted' });
} catch (err) { } catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors when transaction already finished.
}
res.status(500).json({ success: false, message: err.message }); res.status(500).json({ success: false, message: err.message });
} }
}); });

View File

@@ -222,6 +222,7 @@ BEGIN
RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending',
BorrowerName NVARCHAR(100) NOT NULL, BorrowerName NVARCHAR(100) NOT NULL,
BorrowQuantity INT NOT NULL DEFAULT 1, BorrowQuantity INT NOT NULL DEFAULT 1,
ReturnedQuantity INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50), Unit NVARCHAR(50),
BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE),
RequestNote NVARCHAR(500) NULL, RequestNote NVARCHAR(500) NULL,
@@ -264,6 +265,11 @@ BEGIN
ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved'); ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved');
END 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 IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestNote') IS NULL
BEGIN BEGIN
ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL; ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL;
@@ -302,6 +308,83 @@ SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow');
UPDATE AssetBorrowRequests UPDATE AssetBorrowRequests
SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved'); 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 -- 8. CREATE ASSET EXPORT HISTORY TABLE
-- =========================================== -- ===========================================
@@ -482,6 +565,21 @@ BEGIN
CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType); CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);
END 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') IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId')
BEGIN BEGIN
CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId); CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId);

View File

@@ -1477,6 +1477,12 @@ class AccountManager {
return rows.filter(item => { return rows.filter(item => {
const requestType = this.normalizeAssetRequestType(item.RequestType); 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; const matchesType = !typeFilter || requestType === typeFilter;
if (!matchesType) { if (!matchesType) {
return false; return false;
@@ -1491,9 +1497,11 @@ class AccountManager {
item.AssetCode, item.AssetCode,
item.AssetName, item.AssetName,
this.getAssetRequestTypeMeta(item.RequestType).label, this.getAssetRequestTypeMeta(item.RequestType).label,
this.getAssetRequestStatusMeta(item.RequestStatus).label, this.getAssetRequestStatusMeta(item.RequestStatus, item).label,
item.Unit, item.Unit,
item.BorrowQuantity, item.BorrowQuantity,
item.ReturnedQuantity,
item.RemainingQuantity,
item.BorrowDate, item.BorrowDate,
item.RequestNote, item.RequestNote,
item.RejectReason 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() { syncSelectedAssetIds() {
if (!(this.selectedAssetIds instanceof Set)) { if (!(this.selectedAssetIds instanceof Set)) {
this.selectedAssetIds = new Set(); this.selectedAssetIds = new Set();
@@ -1919,6 +1977,7 @@ class AccountManager {
normalizeAssetRequestStatus(value) { normalizeAssetRequestStatus(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'approved') return 'approved'; if (normalized === 'approved') return 'approved';
if (normalized === 'returned') return 'returned';
if (normalized === 'rejected') return 'rejected'; if (normalized === 'rejected') return 'rejected';
return 'pending'; return 'pending';
} }
@@ -1940,12 +1999,23 @@ class AccountManager {
}; };
} }
getAssetRequestStatusMeta(value) { getAssetRequestStatusMeta(value, item = null) {
const status = this.normalizeAssetRequestStatus(value); 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') { if (status === 'approved') {
return { return {
value: 'approved', 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' className: 'bg-green-100 text-green-700 border border-green-200'
}; };
} }
@@ -2939,13 +3009,40 @@ class AccountManager {
const assetName = item.AssetName || '-'; const assetName = item.AssetName || '-';
const assetCode = item.AssetCode ? `<div class="text-[11px] text-slate-500">${this.escapeHtml(item.AssetCode)}</div>` : ''; const assetCode = item.AssetCode ? `<div class="text-[11px] text-slate-500">${this.escapeHtml(item.AssetCode)}</div>` : '';
const typeMeta = this.getAssetRequestTypeMeta(item.RequestType); 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 note = String(item?.RequestNote || '').trim();
const rejectReason = String(item?.RejectReason || '').trim(); const rejectReason = String(item?.RejectReason || '').trim();
const canCancel = this.canCurrentUserCancelAssetRequest(item); const canCancel = this.canCurrentUserCancelAssetRequest(item);
const requestId = Number(item?.BorrowId) || 0; 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 canCreateReturn = this.canCreateAssetReturnRequestFromBorrow(item);
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 returnActionHtml = canCreateReturn
? `<button class="asset-borrow-return-btn inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold whitespace-nowrap" data-request-id="${requestId}" title="Tạo đơn trả từ đơn mượn này">
<span class="material-symbols-outlined text-sm">assignment_return</span>
Trả tài sản
</button>`
: '';
const cancelActionHtml = canCancel 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>` ? `<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>`
: '';
const actionHtml = returnActionHtml || cancelActionHtml
? `<div class="flex items-center gap-2">${returnActionHtml}${cancelActionHtml}</div>`
: `<span class="text-xs text-slate-400">-</span>`; : `<span class="text-xs text-slate-400">-</span>`;
return ` return `
@@ -2956,22 +3053,41 @@ class AccountManager {
<div>${this.escapeHtml(assetName)}</div> <div>${this.escapeHtml(assetName)}</div>
${assetCode} ${assetCode}
</td> </td>
<td class="px-4 py-3 text-sm text-slate-600"> <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 ${typeMeta.className}">${typeMeta.label}</span> <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>
<td class="px-4 py-3 text-sm text-slate-600"> <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> <span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold ${statusMeta.className}">${statusMeta.label}</span>
${returnProgressHtml}
</td> </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">${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">${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 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(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 max-w-xs">${this.escapeHtml(rejectReason || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600">${cancelActionHtml}</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">${actionHtml}</td>
</tr> </tr>
`; `;
} }
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) { canCurrentUserCancelAssetRequest(item) {
const status = this.normalizeAssetRequestStatus(item?.RequestStatus); const status = this.normalizeAssetRequestStatus(item?.RequestStatus);
if (status !== 'pending') { if (status !== 'pending') {
@@ -2993,7 +3109,7 @@ class AccountManager {
buildAssetBorrowEmptyRowHtml() { buildAssetBorrowEmptyRowHtml() {
return ` return `
<tr> <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> </tr>
`; `;
} }
@@ -3058,7 +3174,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="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"> <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"> <thead class="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
<tr> <tr>
<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">STT</th>
@@ -3255,13 +3371,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">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ê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">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">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">Đơ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">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">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">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">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> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Hành động</th>
</tr> </tr>
</thead> </thead>
@@ -3304,6 +3421,134 @@ class AccountManager {
this.setupAssetBorrowPagerListeners(); this.setupAssetBorrowPagerListeners();
this.updatePendingAssetRequestsBadge(); 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() { setupAssetBorrowPagerListeners() {
document.querySelectorAll('.asset-borrow-page-btn').forEach(btn => { document.querySelectorAll('.asset-borrow-page-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -3350,6 +3595,24 @@ class AccountManager {
const tableBody = document.querySelector('.asset-borrows-table-body'); const tableBody = document.querySelector('.asset-borrows-table-body');
if (tableBody && tableBody.dataset.boundActions !== 'true') { if (tableBody && tableBody.dataset.boundActions !== 'true') {
tableBody.addEventListener('click', (event) => { 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 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'); const cancelButton = event.target.closest('.asset-borrow-cancel-btn');
if (!cancelButton) { if (!cancelButton) {
return; return;
@@ -3374,6 +3637,61 @@ class AccountManager {
this.setupAssetBorrowPagerListeners(); 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() { async refreshAssetBorrowsUI() {
await this.fetchAssetBorrows(); await this.fetchAssetBorrows();
if (this.currentPage === 'asset-borrows') { if (this.currentPage === 'asset-borrows') {
@@ -7523,6 +7841,13 @@ function closeAssetPendingRequestsModal() {
} }
} }
function closeAssetBorrowDetailsModal() {
const modal = document.getElementById('assetBorrowDetailsModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetRequestRejectModal() { function closeAssetRequestRejectModal() {
const modal = document.getElementById('assetRequestRejectModal'); const modal = document.getElementById('assetRequestRejectModal');
if (modal) { if (modal) {

View File

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