diff --git a/backend/server.js b/backend/server.js index e27940c..1657da6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -323,19 +323,19 @@ function parseNullableDate(value) { function normalizeAssetStatus(value) { const normalized = String(value || '').trim().toLowerCase(); - if (['in_use', 'in use', 'dang su dung', 'đang sử dụng', 'active'].includes(normalized)) { + if (['in_use', 'in use', 'dang su dung', 'active'].includes(normalized)) { return 'in_use'; } - if (['maintenance', 'bao tri', 'bảo trì'].includes(normalized)) { + if (['maintenance', 'bao tri'].includes(normalized)) { return 'maintenance'; } - if (['disposed', 'thanh ly', 'thanh lý', 'retired'].includes(normalized)) { + if (['disposed', 'thanh ly', 'retired'].includes(normalized)) { return 'disposed'; } - if (['in_stock', 'in stock', 'ton kho', 'tồn kho', 'warehouse'].includes(normalized)) { + if (['in_stock', 'in stock', 'ton kho', 'warehouse'].includes(normalized)) { return 'in_stock'; } @@ -368,11 +368,155 @@ function normalizeAssetPayload(payload = {}) { }; } +function parseBorrowerEntries(rawBorrower) { + const source = String(rawBorrower || '').trim(); + if (!source) { + return []; + } + + const chunks = source + .split(/[\n;]+/g) + .map(item => String(item || '').trim()) + .filter(Boolean); + + const merged = []; + + chunks.forEach(chunk => { + let name = chunk; + let quantity = 1; + + const labeledMatch = chunk.match(/^(.*?)(?:\s*-\s*[^:]+:\s*(\d+))\s*$/i); + if (labeledMatch) { + name = String(labeledMatch[1] || '').trim(); + quantity = parseNonNegativeInteger(labeledMatch[2], 1); + } else { + const colonMatch = chunk.match(/^(.*?)\s*:\s*(\d+)\s*$/); + const xMatch = chunk.match(/^(.*?)\s*x\s*(\d+)\s*$/i); + const parenMatch = chunk.match(/^(.*?)\s*\(\s*(\d+)\s*\)\s*$/); + const fallbackMatch = colonMatch || xMatch || parenMatch; + if (fallbackMatch) { + name = String(fallbackMatch[1] || '').trim(); + quantity = parseNonNegativeInteger(fallbackMatch[2], 1); + } + } + + if (!name || quantity <= 0) { + return; + } + + const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); + if (existed) { + existed.quantity += quantity; + } else { + merged.push({ name, quantity }); + } + }); + + return merged; +} + +function formatBorrowerEntries(entries = []) { + if (!Array.isArray(entries) || !entries.length) { + return null; + } + + const normalized = entries + .map(entry => ({ + name: String(entry?.name || '').trim(), + quantity: parseNonNegativeInteger(entry?.quantity, 0) + })) + .filter(entry => entry.name && entry.quantity > 0); + + if (!normalized.length) { + return null; + } + + return normalized.map(entry => `${entry.name} - so luong: ${entry.quantity}`).join('; '); +} + +function mergeBorrowerEntries(existingBorrower, borrowerName, borrowQuantity) { + const merged = parseBorrowerEntries(existingBorrower); + const name = String(borrowerName || '').trim(); + const quantity = parseNonNegativeInteger(borrowQuantity, 0); + + if (!name || quantity <= 0) { + return formatBorrowerEntries(merged); + } + + const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); + if (existed) { + existed.quantity += quantity; + } else { + merged.push({ name, quantity }); + } + + return formatBorrowerEntries(merged); +} + +function decreaseBorrowerEntries(existingBorrower, borrowerName, returnQuantity) { + const merged = parseBorrowerEntries(existingBorrower); + const name = String(borrowerName || '').trim(); + const quantity = parseNonNegativeInteger(returnQuantity, 0); + + if (!name || quantity <= 0) { + return { + success: false, + message: 'Invalid return payload', + entries: merged + }; + } + + const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); + if (!existed) { + return { + success: false, + message: 'User has no borrowed quantity to return', + entries: merged + }; + } + + if (existed.quantity < quantity) { + return { + success: false, + message: `Return quantity (${quantity}) exceeds borrowed quantity (${existed.quantity})`, + entries: merged + }; + } + + existed.quantity -= quantity; + const normalized = merged.filter(entry => parseNonNegativeInteger(entry?.quantity, 0) > 0); + + return { + success: true, + entries: normalized, + summary: formatBorrowerEntries(normalized) + }; +} + +function normalizeAssetRequestType(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'return' || normalized === 'tra' || normalized === 'return_asset') { + return 'return'; + } + return 'borrow'; +} + +function normalizeAssetRequestStatus(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') { + return 'approved'; + } + if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') { + return 'rejected'; + } + return 'pending'; +} + function normalizeImportToken(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[đĐ]/g, 'd') + .replace(/[\u0111\u0110]/g, 'd') .toLowerCase() .replace(/[^a-z0-9]/g, ''); } @@ -710,7 +854,7 @@ function sanitizeAssetCodeToken(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[đĐ]/g, 'd') + .replace(/[\u0111\u0110]/g, 'd') .toUpperCase() .replace(/[^A-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') @@ -1173,7 +1317,7 @@ async function initializeDatabase() { try { pool = new sql.ConnectionPool(sqlConfig); await pool.connect(); - console.log('✓ Connected to SQL Server'); + console.log('[OK] Connected to SQL Server'); // Check and create database if not exists const masterConnection = new sql.ConnectionPool({ @@ -1198,7 +1342,7 @@ async function initializeDatabase() { // Now create tables in AccManager await createTables(); await migrateLegacyPasswords(); - console.log('✓ Database and tables created'); + console.log('[OK] Database and tables created'); } catch (err) { console.error('Database connection failed:', err); @@ -1240,7 +1384,7 @@ async function migrateLegacyPasswords() { } if (migratedCount > 0) { - console.log(`✓ Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`); + console.log(`[OK] Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`); } } catch (err) { console.error('Password migration error:', err.message); @@ -1343,6 +1487,32 @@ async function createTables() { UpdatedDate DATETIME DEFAULT GETDATE() ) END`, + + // Asset Borrow Requests Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests') + BEGIN + CREATE TABLE AssetBorrowRequests ( + BorrowId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + RequestType NVARCHAR(20) NOT NULL DEFAULT 'borrow', + RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', + BorrowerName NVARCHAR(100) NOT NULL, + BorrowQuantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50), + BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), + RequestNote NVARCHAR(500) NULL, + RejectReason NVARCHAR(1000) NULL, + CreatedBy INT NULL, + ProcessedBy INT NULL, + ProcessedByName NVARCHAR(100) NULL, + ProcessedDate DATETIME NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE(), + FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE, + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL, + FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ) + END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') @@ -1385,6 +1555,16 @@ async function createTables() { console.error('AssetDepartments index creation error:', err.message); } + // Ensure AssetBorrowRequests indexes exist + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_AssetId') CREATE INDEX IX_AssetBorrowRequests_AssetId ON AssetBorrowRequests(AssetId);`); + 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);`); + } catch (err) { + console.error('AssetBorrowRequests index creation error:', err.message); + } + // Ensure new columns exist on Applications for migrations try { await pool.request().query(`IF EXISTS ( @@ -1402,6 +1582,28 @@ async function createTables() { await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Project') IS NULL ALTER TABLE AssetInventory ADD Project NVARCHAR(150) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Borrower') IS NULL ALTER TABLE AssetInventory ADD Borrower NVARCHAR(255) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportedBy') IS NULL ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','Unit') IS NULL ALTER TABLE AssetBorrowRequests ADD Unit NVARCHAR(50) NULL;`); + 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','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;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedByName') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedByName NVARCHAR(100) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedDate DATETIME NULL;`); + await pool.request().query(` + IF NOT EXISTS ( + SELECT 1 + FROM sys.foreign_keys + WHERE name = 'FK_AssetBorrowRequests_ProcessedBy' + ) + ALTER TABLE AssetBorrowRequests + ADD CONSTRAINT FK_AssetBorrowRequests_ProcessedBy + 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(`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 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(` @@ -1455,7 +1657,7 @@ async function createTables() { SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE()) WHERE Username = @username`); - console.log('✓ Admin user created: admin / admin'); + console.log('[OK] Admin user created: admin / admin'); } catch (err) { console.error('Admin user error:', err.message); } @@ -1472,7 +1674,7 @@ async function createTables() { ('Google Workspace', 'Collaboration', 'online', 'mail', 'Google Workspace', 'https://workspace.google.com'), ('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org') END`); - console.log('✓ Sample applications created'); + console.log('[OK] Sample applications created'); } catch (err) { console.error('Applications error:', err.message); } @@ -2499,7 +2701,7 @@ app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => { `); if (existed.recordset.length > 0) { - return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } const inserted = await pool.request() @@ -2517,7 +2719,7 @@ app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => { }); } catch (err) { if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) { - return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } res.status(500).json({ success: false, message: err.message }); @@ -2528,7 +2730,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { try { const departmentId = Number(req.params.id); if (!Number.isInteger(departmentId) || departmentId <= 0) { - return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' }); + return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' }); } const departmentName = normalizeDepartmentName(req.body?.departmentName); @@ -2568,7 +2770,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { `); if (duplicated.recordset.length > 0) { - return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } const transaction = new sql.Transaction(pool); @@ -2607,7 +2809,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { } } catch (err) { if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) { - return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' }); + return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } res.status(500).json({ success: false, message: err.message }); @@ -2618,7 +2820,7 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) = try { const departmentId = Number(req.params.id); if (!Number.isInteger(departmentId) || departmentId <= 0) { - return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ' }); + return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' }); } await syncAssetDepartmentsFromInventory(); @@ -2675,6 +2877,401 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) = // API ROUTES - Asset Inventory // ========================================== +app.get('/api/asset-borrows', 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 request = pool.request(); + if (!canManageRequests) { + request.input('requesterId', sql.Int, requesterId || -1); + } + + const result = await request.query(` + SELECT + br.BorrowId, + br.AssetId, + ai.AssetCode, + ai.AssetName, + br.RequestType, + br.RequestStatus, + br.BorrowerName, + br.BorrowQuantity, + 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 + ${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'} + ORDER BY br.CreatedDate DESC, br.BorrowId DESC + `); + + res.json({ success: true, data: result.recordset }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/asset-borrows', async (req, res) => { + try { + const createdBy = getUserIdFromRequest(req); + const actorName = await getUserDisplayNameById(createdBy); + const assetId = Number(req.body?.assetId); + const requestType = normalizeAssetRequestType(req.body?.requestType); + const borrowQuantity = parseNonNegativeInteger(req.body?.quantity, 0); + const requestedBorrowDate = parseNullableDate(req.body?.borrowDate); + const borrowDate = requestedBorrowDate || new Date(); + const borrowerName = String(actorName || req.body?.borrowerName || '').trim(); + const requestNote = String(req.body?.note || '').trim() || null; + + if (!Number.isInteger(assetId) || assetId <= 0) { + return res.status(400).json({ success: false, message: 'Tai san khong hop le' }); + } + + if (!borrowerName) { + return res.status(400).json({ success: false, message: 'Khong xac dinh duoc nguoi tao don' }); + } + + if (borrowQuantity <= 0) { + return res.status(400).json({ success: false, message: 'So luong phai lon hon 0' }); + } + + const assetResult = await pool.request() + .input('assetId', sql.Int, assetId) + .query(` + SELECT TOP 1 + AssetId, + AssetCode, + AssetName, + Quantity, + ImportInPeriod, + Borrower, + Unit + FROM AssetInventory + WHERE AssetId = @assetId + `); + + const asset = assetResult.recordset?.[0]; + if (!asset) { + return res.status(404).json({ success: false, message: 'Khong tim thay tai san' }); + } + + const currentBorrowedEntries = parseBorrowerEntries(asset.Borrower); + const currentBorrowed = currentBorrowedEntries.reduce((sum, entry) => ( + sum + parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + const endingBalance = Math.max( + parseNonNegativeInteger(asset.Quantity, 0) + parseNonNegativeInteger(asset.ImportInPeriod, 0) - currentBorrowed, + 0 + ); + + const unit = String(req.body?.unit || '').trim() || String(asset.Unit || '').trim() || null; + + if (requestType === 'borrow') { + if (endingBalance <= 0) { + return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky' }); + } + + if (borrowQuantity > endingBalance) { + return res.status(400).json({ + success: false, + message: `So luong muon (${borrowQuantity}) vuot qua ton cuoi ky (${endingBalance})` + }); + } + } else { + const existed = currentBorrowedEntries.find(entry => entry.name.toLowerCase() === borrowerName.toLowerCase()); + const borrowedQuantity = parseNonNegativeInteger(existed?.quantity, 0); + + if (borrowedQuantity <= 0) { + return res.status(400).json({ + success: false, + message: 'Ban chua co du lieu muon tai san nay de tao don tra' + }); + } + + if (borrowQuantity > borrowedQuantity) { + return res.status(400).json({ + success: false, + message: `So luong tra (${borrowQuantity}) vuot qua so luong dang muon (${borrowedQuantity})` + }); + } + } + + const insertResult = await pool.request() + .input('assetId', sql.Int, assetId) + .input('requestType', sql.NVarChar, requestType) + .input('requestStatus', sql.NVarChar, 'pending') + .input('borrowerName', sql.NVarChar, borrowerName) + .input('borrowQuantity', sql.Int, borrowQuantity) + .input('unit', sql.NVarChar, unit) + .input('borrowDate', sql.Date, borrowDate) + .input('requestNote', sql.NVarChar, requestNote) + .input('createdBy', sql.Int, createdBy) + .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; + `); + + 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 + } + }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => { + const transaction = new sql.Transaction(pool); + + try { + const borrowId = Number(req.params.id); + const action = normalizeAssetRequestStatus(req.body?.action); + const rejectReason = String(req.body?.rejectReason || '').trim() || null; + const processedBy = getUserIdFromRequest(req); + const processorName = String( + await getUserDisplayNameById(processedBy) + || req.headers['x-user-role'] + || 'Asset/Admin' + ).trim(); + + if (!Number.isInteger(borrowId) || borrowId <= 0) { + return res.status(400).json({ success: false, message: 'Ma don khong hop le' }); + } + + if (!['approved', 'rejected'].includes(action)) { + return res.status(400).json({ success: false, message: 'Hanh dong khong hop le' }); + } + + if (action === 'rejected' && !rejectReason) { + return res.status(400).json({ success: false, message: 'Vui long nhap ly do tu choi' }); + } + + await transaction.begin(); + + const requestResult = await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowId) + .query(` + SELECT TOP 1 + br.BorrowId, + br.AssetId, + br.RequestType, + br.RequestStatus, + br.BorrowerName, + br.BorrowQuantity, + br.BorrowDate, + br.Unit, + ai.AssetCode, + ai.AssetName, + ai.Quantity, + ai.ImportInPeriod, + ai.Borrower, + ai.Unit AS AssetUnit + FROM AssetBorrowRequests br + INNER JOIN AssetInventory ai ON ai.AssetId = br.AssetId + WHERE br.BorrowId = @borrowId + `); + + const targetRequest = requestResult.recordset?.[0]; + if (!targetRequest) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Khong tim thay don can xu ly' }); + } + + const currentStatus = normalizeAssetRequestStatus(targetRequest.RequestStatus); + if (currentStatus !== 'pending') { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Don nay da duoc xu ly truoc do' }); + } + + if (action === 'approved') { + const requestType = normalizeAssetRequestType(targetRequest.RequestType); + const borrowerName = String(targetRequest.BorrowerName || '').trim(); + const requestQuantity = parseNonNegativeInteger(targetRequest.BorrowQuantity, 0); + + if (requestType === 'borrow') { + const currentBorrowed = parseBorrowerEntries(targetRequest.Borrower).reduce((sum, entry) => ( + sum + parseNonNegativeInteger(entry?.quantity, 0) + ), 0); + const endingBalance = Math.max( + parseNonNegativeInteger(targetRequest.Quantity, 0) + + parseNonNegativeInteger(targetRequest.ImportInPeriod, 0) + - currentBorrowed, + 0 + ); + + if (requestQuantity > endingBalance) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Khong du ton kho de duyet. Ton hien tai: ${endingBalance}` + }); + } + + const mergedBorrowerSummary = mergeBorrowerEntries( + targetRequest.Borrower, + borrowerName, + requestQuantity + ); + + if (mergedBorrowerSummary && mergedBorrowerSummary.length > 255) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: 'Thong tin nguoi muon qua dai, vui long tu choi don va yeu cau nguoi dung dieu chinh.' + }); + } + + await new sql.Request(transaction) + .input('assetId', sql.Int, targetRequest.AssetId) + .input('borrower', sql.NVarChar, mergedBorrowerSummary) + .input('exportedBy', sql.NVarChar, processorName || null) + .query(` + UPDATE AssetInventory + SET Borrower = @borrower, + ExportedBy = @exportedBy, + UpdatedDate = GETDATE() + WHERE AssetId = @assetId + `); + } else { + const decreased = decreaseBorrowerEntries( + targetRequest.Borrower, + borrowerName, + requestQuantity + ); + + if (!decreased.success) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: 'Không thể duyệt trả tài sản: số lượng trả không hợp lệ hoặc không còn người mượn. Bạn có thể xóa đơn chờ này.' + }); + } + + const borrowerSummary = decreased.summary || null; + await new sql.Request(transaction) + .input('assetId', sql.Int, targetRequest.AssetId) + .input('borrower', sql.NVarChar, borrowerSummary) + .input('exportedBy', sql.NVarChar, processorName || null) + .query(` + UPDATE AssetInventory + SET Borrower = @borrower, + ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END, + UpdatedDate = GETDATE() + WHERE AssetId = @assetId + `); + } + } + + await new sql.Request(transaction) + .input('borrowId', sql.Int, borrowId) + .input('requestStatus', sql.NVarChar, action) + .input('rejectReason', sql.NVarChar, action === 'rejected' ? rejectReason : null) + .input('processedBy', sql.Int, processedBy) + .input('processedByName', sql.NVarChar, processorName || null) + .query(` + UPDATE AssetBorrowRequests + SET RequestStatus = @requestStatus, + RejectReason = @rejectReason, + ProcessedBy = @processedBy, + ProcessedByName = @processedByName, + ProcessedDate = GETDATE(), + UpdatedDate = GETDATE() + WHERE BorrowId = @borrowId + `); + + await transaction.commit(); + + return res.json({ + success: true, + message: action === 'approved' + ? 'Da duyet don thanh cong' + : 'Da tu choi don' + }); + } 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.delete('/api/asset-borrows/:id', requireAssetOrAdmin, async (req, res) => { + try { + const borrowId = Number(req.params.id); + if (!Number.isInteger(borrowId) || borrowId <= 0) { + return res.status(400).json({ success: false, message: 'Mã đơn không hợp lệ' }); + } + + const deleteResult = await pool.request() + .input('borrowId', sql.Int, borrowId) + .query(` + DELETE FROM AssetBorrowRequests + OUTPUT DELETED.BorrowId + WHERE BorrowId = @borrowId + AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending' + `); + + if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) { + return res.json({ success: true, message: 'Đã xóa đơn chờ' }); + } + + const existed = await pool.request() + .input('borrowId', sql.Int, borrowId) + .query(` + SELECT TOP 1 BorrowId, RequestStatus + FROM AssetBorrowRequests + WHERE BorrowId = @borrowId + `); + + const row = existed.recordset?.[0]; + if (!row) { + return res.status(404).json({ success: false, message: 'Không tìm thấy đơn cần xóa' }); + } + + return res.status(400).json({ + success: false, + message: 'Chỉ được xóa đơn ở trạng thái chờ xử lý' + }); + } catch (err) { + return res.status(500).json({ success: false, message: err.message }); + } +}); + app.get('/api/assets', async (req, res) => { try { const result = await pool.request().query(` @@ -2692,6 +3289,72 @@ app.get('/api/assets', async (req, res) => { } }); +app.get('/api/assets/search', async (req, res) => { + try { + const rawKeyword = String(req.query.q || '').trim(); + const keywordLike = `%${rawKeyword}%`; + const limit = Math.min(parsePositiveInteger(req.query.limit, 80), 200); + const offset = parseNonNegativeInteger(req.query.offset, 0); + + const result = await pool.request() + .input('limit', sql.Int, limit) + .input('offset', sql.Int, offset) + .input('keyword', sql.NVarChar, rawKeyword) + .input('keywordLike', sql.NVarChar, keywordLike) + .query(` + ;WITH FilteredAssets AS ( + SELECT + AssetId, + AssetCode, + AssetName, + Unit, + UpdatedDate, + CASE WHEN @keyword <> '' AND AssetCode LIKE @keywordLike THEN 0 ELSE 1 END AS CodeRank, + CASE WHEN @keyword <> '' AND AssetName LIKE @keywordLike THEN 0 ELSE 1 END AS NameRank + FROM AssetInventory + WHERE @keyword = '' + OR AssetCode LIKE @keywordLike + OR AssetName LIKE @keywordLike + OR Model LIKE @keywordLike + ), + OrderedAssets AS ( + SELECT + AssetId, + AssetCode, + AssetName, + Unit, + ROW_NUMBER() OVER ( + ORDER BY + CodeRank ASC, + NameRank ASC, + UpdatedDate DESC, + AssetName ASC + ) AS RowNum + FROM FilteredAssets + ) + SELECT AssetId, AssetCode, AssetName, Unit + FROM OrderedAssets + WHERE RowNum > @offset AND RowNum <= (@offset + @limit) + ORDER BY RowNum; + + SELECT COUNT(*) AS TotalCount + FROM AssetInventory + WHERE @keyword = '' + OR AssetCode LIKE @keywordLike + OR AssetName LIKE @keywordLike + OR Model LIKE @keywordLike; + `); + + const rows = Array.isArray(result.recordsets?.[0]) ? result.recordsets[0] : []; + const totalCount = Number(result.recordsets?.[1]?.[0]?.TotalCount) || 0; + const hasMore = offset + rows.length < totalCount; + + res.json({ success: true, data: rows, hasMore, total: totalCount }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + app.get('/api/assets/:id', async (req, res) => { try { const result = await pool.request() @@ -3078,9 +3741,9 @@ async function startServer() { console.log(`\n========================================`); console.log(`AccManager Backend Server`); console.log(`========================================`); - console.log(`✓ Server running on http://localhost:${PORT}`); - console.log(`✓ Database: AccManager`); - console.log(`✓ Default admin: admin / admin`); + console.log(`[OK] Server running on http://localhost:${PORT}`); + console.log('[OK] Database: AccManager'); + console.log('[OK] Default admin: admin / admin'); console.log(`\nAPI Endpoints:`); console.log(` POST /api/auth/login`); console.log(` GET /api/database/info`); diff --git a/database/setup.sql b/database/setup.sql index f8992a2..90eb7ae 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -154,7 +154,99 @@ WHERE NOT EXISTS ( ); -- =========================================== --- 6. CREATE AUDIT LOG TABLE +-- 6. CREATE ASSET BORROW REQUESTS TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests') +BEGIN + CREATE TABLE AssetBorrowRequests ( + BorrowId INT PRIMARY KEY IDENTITY(1,1), + AssetId INT NOT NULL, + RequestType NVARCHAR(20) NOT NULL DEFAULT 'borrow', + RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', + BorrowerName NVARCHAR(100) NOT NULL, + BorrowQuantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50), + BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), + RequestNote NVARCHAR(500) NULL, + RejectReason NVARCHAR(1000) NULL, + CreatedBy INT NULL, + ProcessedBy INT NULL, + ProcessedByName NVARCHAR(100) NULL, + ProcessedDate DATETIME NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE(), + FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE, + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL, + FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ); + PRINT 'Table AssetBorrowRequests created successfully.'; +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'Unit') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD Unit NVARCHAR(50) NULL; +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'BorrowDate') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(GETDATE() AS DATE)); +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'UpdatedDate') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE()); +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestType') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestType DEFAULT('borrow'); +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestStatus') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved'); +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestNote') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL; +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'RejectReason') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD RejectReason NVARCHAR(1000) NULL; +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'ProcessedBy') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD ProcessedBy INT NULL; +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'ProcessedByName') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD ProcessedByName NVARCHAR(100) NULL; +END + +IF COL_LENGTH('dbo.AssetBorrowRequests', 'ProcessedDate') IS NULL +BEGIN + ALTER TABLE AssetBorrowRequests ADD ProcessedDate DATETIME NULL; +END + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_AssetBorrowRequests_ProcessedBy') +BEGIN + ALTER TABLE AssetBorrowRequests + ADD CONSTRAINT FK_AssetBorrowRequests_ProcessedBy + FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL; +END + +UPDATE AssetBorrowRequests +SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow'); + +UPDATE AssetBorrowRequests +SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved'); + +-- =========================================== +-- 7. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -173,7 +265,7 @@ BEGIN END -- =========================================== --- 7. CREATE INDEXES +-- 8. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -210,10 +302,30 @@ BEGIN CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_AssetId') +BEGIN + CREATE INDEX IX_AssetBorrowRequests_AssetId ON AssetBorrowRequests(AssetId); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_BorrowDate') +BEGIN + CREATE INDEX IX_AssetBorrowRequests_BorrowDate ON AssetBorrowRequests(BorrowDate DESC); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestStatus') +BEGIN + CREATE INDEX IX_AssetBorrowRequests_RequestStatus ON AssetBorrowRequests(RequestStatus); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestType') +BEGIN + CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType); +END + PRINT 'Indexes created successfully.'; -- =========================================== --- 8. INSERT INITIAL DATA +-- 9. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists @@ -237,7 +349,7 @@ BEGIN END -- =========================================== --- 9. DISPLAY DATABASE INFORMATION +-- 10. DISPLAY DATABASE INFORMATION -- =========================================== PRINT ''; PRINT '========================================'; diff --git a/public/js/app.js b/public/js/app.js index b284418..7a354db 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -24,6 +24,8 @@ class AccountManager { this.userPageSize = 9; this.assetPage = 1; this.assetPageSize = 10; + this.assetBorrowPage = 1; + this.assetBorrowPageSize = 10; this.apiBase = '/api'; this.currentPage = 'dashboard'; this.accountSearchTerm = ''; @@ -33,6 +35,15 @@ class AccountManager { this.userRoleFilter = ''; this.assetSearchTerm = ''; this.assetStatusFilter = ''; + this.assetBorrows = []; + this.assetBorrowSearchTerm = ''; + this.assetBorrowProductSearchTimer = undefined; + this.assetBorrowProductItems = []; + this.assetBorrowProductQuery = ''; + this.assetBorrowProductOffset = 0; + this.assetBorrowProductLimit = 40; + this.assetBorrowProductHasMore = false; + this.assetBorrowProductLoading = false; this.assetDepartments = []; this.assetDepartmentSearchTerm = ''; this.selectedAssetIds = new Set(); @@ -45,6 +56,9 @@ class AccountManager { this.pendingBorrowAssetId = undefined; this.editingAssetDepartmentId = undefined; this.pendingDeleteAssetDepartmentId = undefined; + this.assetBorrowRequestType = 'borrow'; + this.pendingAssetRequestRejectId = undefined; + this.assetBorrowAutoRefreshTimer = undefined; } configureNotifications() { @@ -157,6 +171,7 @@ class AccountManager { await this.fetchApplications(); await this.fetchAccounts(); await this.fetchAssets(); + await this.fetchAssetBorrows(); await this.fetchAssetDepartments(); if (this.canCurrentUserManageAssets()) { @@ -206,6 +221,10 @@ class AccountManager { this.setupAddButtonListeners(); this.setupFilters(); this.setupAssetPagerListeners(); + } else if (page === 'asset-borrows') { + mainContent.innerHTML = this.getAssetBorrowsContent(); + this.setupAssetBorrowListeners(); + this.setupAddButtonListeners(); } else if (page === 'asset-departments') { mainContent.innerHTML = this.getAssetDepartmentsContent(); this.setupAssetDepartmentListeners(); @@ -230,7 +249,14 @@ class AccountManager { mainContent.innerHTML = this.renderDashboard(); } + if (page === 'asset-borrows') { + this.startAssetBorrowAutoRefresh(); + } else { + this.stopAssetBorrowAutoRefresh(); + } + this.restoreSearchFocus(); + this.updatePendingAssetRequestsBadge(); this.setActiveNav(page); } @@ -427,10 +453,283 @@ class AccountManager { refreshBorrowAssetUserOptions(selectedValue = '') { this.populateUserSelectOptions('borrowAssetUserInput', { selectedValue, - emptyLabel: '-- Chon nguoi muon --' + emptyLabel: '-- Chọn người mượn --' }); } + getAssetBorrowProductDisplayName(asset) { + if (!asset) { + return '-- Chọn tài sản --'; + } + + const code = String(asset.AssetCode || '').trim(); + const name = String(asset.AssetName || '').trim(); + return `${code} - ${name}`.replace(/^\s*-\s*|\s*-\s*$/g, '').trim() || name || '-- Chọn tài sản --'; + } + + getAssetBorrowProductById(assetIdValue) { + const assetId = Number(assetIdValue); + if (!Number.isFinite(assetId) || assetId <= 0) { + return null; + } + + return this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) + || this.assets.find(item => Number(item?.AssetId) === assetId) + || null; + } + + updateAssetBorrowProductDisplay(assetIdValue) { + const hiddenInput = document.getElementById('assetBorrowProductInput'); + const displayNode = document.getElementById('assetBorrowProductDisplayText'); + const unitInput = document.getElementById('assetBorrowUnitInput'); + const asset = this.getAssetBorrowProductById(assetIdValue); + + if (hiddenInput) { + hiddenInput.value = asset ? String(asset.AssetId) : ''; + } + if (displayNode) { + displayNode.textContent = this.getAssetBorrowProductDisplayName(asset); + displayNode.classList.toggle('text-slate-600', !asset); + displayNode.classList.toggle('text-slate-700', !!asset); + } + if (unitInput) { + unitInput.value = asset ? String(asset.Unit || '').trim() : ''; + } + } + + openAssetBorrowProductDropdown() { + const dropdown = document.getElementById('assetBorrowProductDropdown'); + const searchInput = document.getElementById('assetBorrowProductSearchInput'); + if (!dropdown) { + return; + } + + dropdown.classList.remove('hidden'); + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + } + + closeAssetBorrowProductDropdown() { + const dropdown = document.getElementById('assetBorrowProductDropdown'); + if (dropdown) { + dropdown.classList.add('hidden'); + } + } + + renderAssetBorrowProductList() { + const listNode = document.getElementById('assetBorrowProductList'); + const loadingNode = document.getElementById('assetBorrowProductLoading'); + const hiddenInput = document.getElementById('assetBorrowProductInput'); + + if (!listNode) { + return; + } + + const selectedAssetId = Number(hiddenInput?.value || 0); + listNode.style.maxHeight = '224px'; + listNode.style.overflow = 'auto'; + + if (!this.assetBorrowProductItems.length && !this.assetBorrowProductLoading) { + listNode.innerHTML = ` +
${username}
-${service} • ${owner}
+${service} ? ${owner}
Theo dõi trạng thái đơn mượn và đơn trả tài sản.
+| STT | +Tên đầy đủ | +Tài sản | +Danh mục | +Trạng thái | +Đơn vị | +Số lượng | +Ngày | +Ghi chú | +Lý do | +
|---|
Chưa có dữ liệu tài sản. Hãy thêm tài sản đầu tiên.
@@ -2044,7 +3135,7 @@ class AccountManager { return; } - const confirmed = window.confirm(`Bạn có chắc muốn xóa ${selectedIds.length} tài sản đã chọn?`); + const confirmed = window.confirm(`Bạn có chắc mudn xóa ${selectedIds.length} tài sản dã chọn?`); if (!confirmed) { return; } @@ -2106,7 +3197,7 @@ class AccountManager { ['Mã tài sản', asset?.AssetCode], ['Tên tài sản', asset?.AssetName], ['Model', asset?.Model], - ['Số serial', asset?.SerialNumber], + ['Sd serial', asset?.SerialNumber], ['Số lượng (Tồn đầu kỳ)', `${asset?.Quantity || 0} ${asset?.Unit || ''}`.trim()], ['Nhập trong kỳ', asset?.ImportInPeriod ?? 0], ['Xuất trong kỳ', asset?.ExportInPeriod ?? 0], @@ -2270,7 +3361,7 @@ class AccountManager { const toToken = (value) => String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[đĐ]/g, 'd') + .replace(/[\u0111\u0110]/g, 'd') .toUpperCase() .replace(/[^A-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') @@ -2395,7 +3486,7 @@ class AccountManager { const assetId = Number(selectedIds[0]); const asset = this.assets.find(item => Number(item?.AssetId) === assetId) || null; if (!asset && showWarning) { - this.notifyFailure('Không tìm thấy tài sản đã chọn.'); + this.notifyFailure('Không tìm thấy tài sản dã chọn.'); } return asset; } @@ -2708,7 +3799,7 @@ class AccountManager { return String(key || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[đĐ]/g, 'd') + .replace(/[\u0111\u0110]/g, 'd') .toLowerCase() .replace(/[^a-z0-9]/g, ''); } @@ -3673,7 +4764,7 @@ class AccountManager { const toggleIcon = document.getElementById('toggleIcon'); const storedPwd = account?.AccountPassword || ''; passwordEl.dataset.password = storedPwd; - passwordEl.textContent = storedPwd ? '••••••••' : '(no password stored)'; + passwordEl.textContent = storedPwd ? '********' : '(no password stored)'; passwordEl.dataset.visible = 'false'; if (toggleIcon) toggleIcon.textContent = 'visibility'; @@ -3683,7 +4774,7 @@ class AccountManager { const currentPwd = passwordEl.dataset.password || ''; const isVisible = passwordEl.dataset.visible === 'true'; if (isVisible) { - passwordEl.textContent = currentPwd ? '••••••••' : '(no password stored)'; + passwordEl.textContent = currentPwd ? '********' : '(no password stored)'; passwordEl.dataset.visible = 'false'; if (toggleIcon) toggleIcon.textContent = 'visibility'; } else { @@ -3909,6 +5000,24 @@ class AccountManager { addAssetDepartmentBtn.dataset.boundClick = 'true'; } + const addAssetBorrowRequestBtn = document.getElementById('addAssetBorrowRequestBtn'); + if (addAssetBorrowRequestBtn && !addAssetBorrowRequestBtn.dataset.boundClick) { + addAssetBorrowRequestBtn.addEventListener('click', () => this.openAssetBorrowRequestModal('borrow')); + addAssetBorrowRequestBtn.dataset.boundClick = 'true'; + } + + const addAssetReturnRequestBtn = document.getElementById('addAssetReturnRequestBtn'); + if (addAssetReturnRequestBtn && !addAssetReturnRequestBtn.dataset.boundClick) { + addAssetReturnRequestBtn.addEventListener('click', () => this.openAssetBorrowRequestModal('return')); + addAssetReturnRequestBtn.dataset.boundClick = 'true'; + } + + const openPendingAssetBorrowsBtn = document.getElementById('openPendingAssetBorrowsBtn'); + if (openPendingAssetBorrowsBtn && !openPendingAssetBorrowsBtn.dataset.boundClick) { + openPendingAssetBorrowsBtn.addEventListener('click', () => this.openPendingAssetRequestsModal()); + openPendingAssetBorrowsBtn.dataset.boundClick = 'true'; + } + const borrowAssetBtn = document.getElementById('borrowAssetBtn'); if (borrowAssetBtn && !borrowAssetBtn.dataset.boundClick) { borrowAssetBtn.addEventListener('click', () => this.openBorrowAssetModal()); @@ -4935,7 +6044,7 @@ class AccountManager {