From d4800beb6771cb018a4d2494ccb6f8ec250d6ac0 Mon Sep 17 00:00:00 2001 From: DungTT Date: Fri, 24 Apr 2026 16:11:00 +0700 Subject: [PATCH] request --- backend/server.js | 703 ++++++++++++++++++++++- database/setup.sql | 120 +++- public/js/app.js | 1168 ++++++++++++++++++++++++++++++++++++++- public/modals.html | 134 ++++- public/pages/index.html | 13 +- 5 files changed, 2094 insertions(+), 44 deletions(-) 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 = ` +
Không tìm thấy tài sản phù hợp.
+ `; + } else { + listNode.innerHTML = this.assetBorrowProductItems.map(asset => { + const assetId = Number(asset?.AssetId); + const isSelected = Number.isFinite(selectedAssetId) && selectedAssetId === assetId; + const displayName = this.getAssetBorrowProductDisplayName(asset); + return ` + + `; + }).join(''); + } + + if (loadingNode) { + loadingNode.classList.toggle('hidden', !this.assetBorrowProductLoading); + } + + document.querySelectorAll('.asset-borrow-product-option').forEach(button => { + if (button.dataset.boundClick === 'true') { + return; + } + + button.addEventListener('click', () => { + const assetId = Number(button.dataset.assetId); + this.updateAssetBorrowProductDisplay(assetId); + this.closeAssetBorrowProductDropdown(); + }); + button.dataset.boundClick = 'true'; + }); + } + + resetAssetBorrowProductSearchState(query = '') { + this.assetBorrowProductQuery = String(query || '').trim(); + this.assetBorrowProductOffset = 0; + this.assetBorrowProductHasMore = true; + this.assetBorrowProductItems = []; + } + + async searchAssetBorrowProducts(keyword = '', selectedAssetId = '', options = {}) { + const { reset = true } = options; + const query = String(keyword || '').trim(); + const currentSelectedId = selectedAssetId || document.getElementById('assetBorrowProductInput')?.value || ''; + + if (reset) { + this.resetAssetBorrowProductSearchState(query); + } + + if (this.assetBorrowProductLoading || !this.assetBorrowProductHasMore) { + return; + } + + this.assetBorrowProductLoading = true; + this.renderAssetBorrowProductList(); + + const encodedKeyword = encodeURIComponent(this.assetBorrowProductQuery); + const offset = this.assetBorrowProductOffset; + const limit = this.assetBorrowProductLimit; + + const appendRows = (rows = [], hasMore = false) => { + const source = Array.isArray(rows) ? rows : []; + if (source.length) { + const merged = new Map( + this.assetBorrowProductItems.map(item => [String(item.AssetId), item]) + ); + source.forEach(item => { + merged.set(String(item.AssetId), item); + }); + this.assetBorrowProductItems = Array.from(merged.values()); + this.assetBorrowProductOffset += source.length; + } + + this.assetBorrowProductHasMore = Boolean(hasMore); + this.assetBorrowProductLoading = false; + this.renderAssetBorrowProductList(); + + const selectedValue = String(currentSelectedId || '').trim(); + if (selectedValue && this.getAssetBorrowProductById(selectedValue)) { + this.updateAssetBorrowProductDisplay(selectedValue); + } else if (!document.getElementById('assetBorrowProductInput')?.value && this.assetBorrowProductItems.length) { + this.updateAssetBorrowProductDisplay(this.assetBorrowProductItems[0].AssetId); + } else { + this.updateAssetBorrowProductDisplay(document.getElementById('assetBorrowProductInput')?.value || ''); + } + }; + + const fallbackFromLocalAssets = () => { + const source = Array.isArray(this.assets) ? this.assets : []; + const normalized = this.assetBorrowProductQuery.toLowerCase(); + const filtered = source.filter(asset => { + if (!normalized) { + return true; + } + + const haystack = [ + asset?.AssetCode, + asset?.AssetName, + asset?.Model + ].map(value => String(value || '').toLowerCase()); + + return haystack.some(value => value.includes(normalized)); + }); + + const pageRows = filtered.slice(offset, offset + limit); + const hasMore = (offset + pageRows.length) < filtered.length; + appendRows(pageRows, hasMore); + }; + + try { + const response = await fetch(`${this.apiBase}/assets/search?q=${encodedKeyword}&limit=${limit}&offset=${offset}`, { + headers: this.getAuthHeaders(false) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + fallbackFromLocalAssets(); + return; + } + + appendRows(data.data || [], data.hasMore === true); + } catch (err) { + console.error('Search asset borrow products error:', err); + fallbackFromLocalAssets(); + } + } + + setupAssetBorrowRequestModalListeners() { + const picker = document.getElementById('assetBorrowProductPicker'); + const displayBtn = document.getElementById('assetBorrowProductDisplayBtn'); + const dropdown = document.getElementById('assetBorrowProductDropdown'); + const productList = document.getElementById('assetBorrowProductList'); + const productSearchInput = document.getElementById('assetBorrowProductSearchInput'); + + if (displayBtn && displayBtn.dataset.boundClick !== 'true') { + displayBtn.addEventListener('click', async () => { + const isOpen = dropdown && !dropdown.classList.contains('hidden'); + if (isOpen) { + this.closeAssetBorrowProductDropdown(); + return; + } + + this.openAssetBorrowProductDropdown(); + if (!this.assetBorrowProductItems.length) { + await this.searchAssetBorrowProducts(productSearchInput?.value || '', document.getElementById('assetBorrowProductInput')?.value || '', { reset: true }); + } + }); + displayBtn.dataset.boundClick = 'true'; + } + + if (productSearchInput && productSearchInput.dataset.boundInput !== 'true') { + productSearchInput.addEventListener('input', () => { + if (this.assetBorrowProductSearchTimer) { + clearTimeout(this.assetBorrowProductSearchTimer); + } + + const selectedAssetId = document.getElementById('assetBorrowProductInput')?.value || ''; + this.assetBorrowProductSearchTimer = setTimeout(() => { + this.searchAssetBorrowProducts(productSearchInput.value, selectedAssetId, { reset: true }); + }, 250); + }); + productSearchInput.dataset.boundInput = 'true'; + } + + if (productList && productList.dataset.boundScroll !== 'true') { + productList.addEventListener('scroll', () => { + const threshold = 40; + const isNearBottom = productList.scrollTop + productList.clientHeight >= productList.scrollHeight - threshold; + if (!isNearBottom) { + return; + } + + if (this.assetBorrowProductHasMore && !this.assetBorrowProductLoading) { + this.searchAssetBorrowProducts( + productSearchInput?.value || '', + document.getElementById('assetBorrowProductInput')?.value || '', + { reset: false } + ); + } + }); + productList.dataset.boundScroll = 'true'; + } + + if (picker && picker.dataset.boundOutsideClick !== 'true') { + document.addEventListener('click', (event) => { + if (!picker.contains(event.target)) { + this.closeAssetBorrowProductDropdown(); + } + }); + picker.dataset.boundOutsideClick = 'true'; + } + } + getUniqueAssetDepartmentNames() { const rows = Array.isArray(this.assetDepartments) ? this.assetDepartments : []; const seen = new Set(); @@ -491,6 +790,13 @@ class AccountManager { if (data.success) { this.assets = data.data.map(asset => this.normalizeAssetComputedFields(asset)); this.syncSelectedAssetIds(); + const borrowModal = document.getElementById('assetBorrowRequestModal'); + if (borrowModal?.classList.contains('open')) { + await this.searchAssetBorrowProducts( + document.getElementById('assetBorrowProductSearchInput')?.value || '', + document.getElementById('assetBorrowProductInput')?.value || '' + ); + } } else { console.error('Load assets failed:', data.message); } @@ -499,6 +805,23 @@ class AccountManager { } } + async fetchAssetBorrows() { + try { + const res = await fetch(`${this.apiBase}/asset-borrows`, { + headers: this.getAuthHeaders(false) + }); + const data = await res.json(); + if (data.success) { + this.assetBorrows = Array.isArray(data.data) ? data.data : []; + this.updatePendingAssetRequestsBadge(); + } else { + console.error('Load asset borrows failed:', data.message); + } + } catch (err) { + console.error('Fetch asset borrows error:', err); + } + } + async fetchAssetDepartments() { try { const res = await fetch(`${this.apiBase}/asset-departments`); @@ -559,6 +882,7 @@ class AccountManager { const accountSearch = document.getElementById('accountSearch'); const appSearch = document.getElementById('appSearch'); const assetSearch = document.getElementById('assetSearch'); + const assetBorrowSearch = document.getElementById('assetBorrowSearch'); const assetDepartmentSearch = document.getElementById('assetDepartmentSearch'); if (accountSearch && accountSearch.dataset.focused === 'true') { @@ -579,6 +903,12 @@ class AccountManager { assetSearch.setSelectionRange(pos, pos); } + if (assetBorrowSearch && assetBorrowSearch.dataset.focused === 'true') { + const pos = assetBorrowSearch.selectionStart || assetBorrowSearch.value.length; + assetBorrowSearch.focus(); + assetBorrowSearch.setSelectionRange(pos, pos); + } + if (assetDepartmentSearch && assetDepartmentSearch.dataset.focused === 'true') { const pos = assetDepartmentSearch.selectionStart || assetDepartmentSearch.value.length; assetDepartmentSearch.focus(); @@ -612,6 +942,11 @@ class AccountManager { profileBtn.addEventListener('click', () => this.openProfileModal()); } + const pendingAssetRequestsBtn = document.getElementById('pendingAssetRequestsBtn'); + if (pendingAssetRequestsBtn) { + pendingAssetRequestsBtn.addEventListener('click', () => this.openPendingAssetRequestsModal()); + } + // Update account display this.updateAccountDisplay(); @@ -657,6 +992,24 @@ class AccountManager { } } + const assetBorrowRequestForm = document.getElementById('assetBorrowRequestForm'); + if (assetBorrowRequestForm) { + if (!assetBorrowRequestForm.dataset.boundSubmit) { + assetBorrowRequestForm.addEventListener('submit', (e) => this.handleAssetBorrowRequestSubmit(e)); + assetBorrowRequestForm.dataset.boundSubmit = 'true'; + } + } + + const assetRequestRejectForm = document.getElementById('assetRequestRejectForm'); + if (assetRequestRejectForm) { + if (!assetRequestRejectForm.dataset.boundSubmit) { + assetRequestRejectForm.addEventListener('submit', (e) => this.handleAssetRequestRejectSubmit(e)); + assetRequestRejectForm.dataset.boundSubmit = 'true'; + } + } + + this.setupAssetBorrowRequestModalListeners(); + const assetDepartmentForm = document.getElementById('assetDepartmentForm'); if (assetDepartmentForm) { if (!assetDepartmentForm.dataset.boundSubmit) { @@ -750,6 +1103,32 @@ class AccountManager { }); } + getFilteredAssetBorrows() { + const search = String(this.assetBorrowSearchTerm || '').toLowerCase(); + const rows = Array.isArray(this.assetBorrows) ? this.assetBorrows : []; + + return rows.filter(item => { + if (!search) { + return true; + } + + const haystack = [ + item.BorrowerName, + item.AssetCode, + item.AssetName, + this.getAssetRequestTypeMeta(item.RequestType).label, + this.getAssetRequestStatusMeta(item.RequestStatus).label, + item.Unit, + item.BorrowQuantity, + item.BorrowDate, + item.RequestNote, + item.RejectReason + ].map(value => String(value || '').toLowerCase()); + + return haystack.some(value => value.includes(search)); + }); + } + syncSelectedAssetIds() { if (!(this.selectedAssetIds instanceof Set)) { this.selectedAssetIds = new Set(); @@ -864,7 +1243,7 @@ class AccountManager {

${username}

-

${service} • ${owner}

+

${service} ? ${owner}

`;}).join('')} @@ -1148,6 +1527,93 @@ class AccountManager { .replace(/'/g, '''); } + normalizeAssetRequestType(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'return' ? 'return' : 'borrow'; + } + + normalizeAssetRequestStatus(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'approved') return 'approved'; + if (normalized === 'rejected') return 'rejected'; + return 'pending'; + } + + getAssetRequestTypeMeta(value) { + const requestType = this.normalizeAssetRequestType(value); + if (requestType === 'return') { + return { + value: 'return', + label: 'Trả tài sản', + className: 'bg-emerald-100 text-emerald-700 border border-emerald-200' + }; + } + + return { + value: 'borrow', + label: 'Mượn tài sản', + className: 'bg-blue-100 text-blue-700 border border-blue-200' + }; + } + + getAssetRequestStatusMeta(value) { + const status = this.normalizeAssetRequestStatus(value); + if (status === 'approved') { + return { + value: 'approved', + label: 'Chấp nhận', + className: 'bg-emerald-100 text-emerald-700 border border-emerald-200' + }; + } + + if (status === 'rejected') { + return { + value: 'rejected', + label: 'Từ chối', + className: 'bg-red-100 text-red-700 border border-red-200' + }; + } + + return { + value: 'pending', + label: 'Đang chờ', + className: 'bg-amber-100 text-amber-700 border border-amber-200' + }; + } + + getPendingAssetRequestCount() { + if (!this.canCurrentUserManageAssets()) { + return 0; + } + + return (Array.isArray(this.assetBorrows) ? this.assetBorrows : []) + .filter(item => this.normalizeAssetRequestStatus(item?.RequestStatus) === 'pending') + .length; + } + + updatePendingAssetRequestsBadge() { + const shouldShow = this.canCurrentUserManageAssets(); + const count = this.getPendingAssetRequestCount(); + const displayCount = count > 99 ? '99+' : String(count); + + const topButton = document.getElementById('pendingAssetRequestsBtn'); + if (topButton) { + topButton.classList.toggle('hidden', !shouldShow); + } + + const topBadge = document.getElementById('pendingAssetRequestsBadge'); + if (topBadge) { + topBadge.classList.toggle('hidden', !shouldShow || count <= 0); + topBadge.textContent = displayCount; + } + + const pageBadge = document.getElementById('pendingAssetBorrowsCountBadge'); + if (pageBadge) { + pageBadge.classList.toggle('hidden', count <= 0); + pageBadge.textContent = displayCount; + } + } + parseBorrowerEntries(rawBorrower) { if (Array.isArray(rawBorrower)) { const merged = []; @@ -1432,7 +1898,7 @@ class AccountManager { STT Phòng ban - Số tài sản + Sd tài sản Thao tác @@ -1678,6 +2144,631 @@ class AccountManager { } } + buildAssetBorrowRowHtml(item, rowNumber) { + const assetName = item.AssetName || '-'; + const assetCode = item.AssetCode ? `
${this.escapeHtml(item.AssetCode)}
` : ''; + const typeMeta = this.getAssetRequestTypeMeta(item.RequestType); + const statusMeta = this.getAssetRequestStatusMeta(item.RequestStatus); + const note = String(item?.RequestNote || '').trim(); + const rejectReason = String(item?.RejectReason || '').trim(); + + return ` + + ${rowNumber} + ${this.escapeHtml(item.BorrowerName || '-')} + +
${this.escapeHtml(assetName)}
+ ${assetCode} + + + ${typeMeta.label} + + + ${statusMeta.label} + + ${this.escapeHtml(item.Unit || '-')} + ${Number(item.BorrowQuantity) || 0} + ${this.formatDateOnly(item.BorrowDate)} + ${this.escapeHtml(note || '-')} + ${this.escapeHtml(rejectReason || '-')} + + `; + } + + buildAssetBorrowEmptyRowHtml() { + return ` + + Chưa có đơn mượn/trả tài sản nào. + + `; + } + + renderAssetBorrowsPager(pageInfo) { + const pager = document.getElementById('assetBorrowsPager'); + if (!pager) { + return; + } + + pager.innerHTML = ` + Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total} +
+ + Trang ${pageInfo.current} / ${pageInfo.totalPages} + +
+ `; + } + + getAssetBorrowsContent() { + const canManageAssets = this.canCurrentUserManageAssets(); + const filteredBorrows = this.getFilteredAssetBorrows(); + const pageInfo = this.getPaged(filteredBorrows, this.assetBorrowPage, this.assetBorrowPageSize); + this.assetBorrowPage = pageInfo.current; + const pendingCount = this.getPendingAssetRequestCount(); + + return ` +
+ + +
+
+ Tìm kiếm + +
+
+ +
+
+ + + + + + + + + + + + + + + + + ${pageInfo.data.length > 0 ? pageInfo.data.map((item, index) => this.buildAssetBorrowRowHtml(item, pageInfo.start + index)).join('') : this.buildAssetBorrowEmptyRowHtml()} + +
STTTên đầy đủTài sảnDanh mụcTrạng tháiĐơn vịSố lượngNgàyGhi chúLý do
+
+
+ Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total} +
+ + Trang ${pageInfo.current} / ${pageInfo.totalPages} + +
+
+
+
+ `; + } + + renderAssetBorrowsTableBody() { + const tbody = document.querySelector('.asset-borrows-table-body'); + if (!tbody) { + return; + } + + const pageInfo = this.getPaged(this.getFilteredAssetBorrows(), this.assetBorrowPage, this.assetBorrowPageSize); + this.assetBorrowPage = pageInfo.current; + + if (!pageInfo.data.length) { + tbody.innerHTML = this.buildAssetBorrowEmptyRowHtml(); + } else { + tbody.innerHTML = pageInfo.data + .map((item, index) => this.buildAssetBorrowRowHtml(item, pageInfo.start + index)) + .join(''); + } + + this.renderAssetBorrowsPager(pageInfo); + this.setupAssetBorrowPagerListeners(); + this.updatePendingAssetRequestsBadge(); + } + setupAssetBorrowPagerListeners() { + document.querySelectorAll('.asset-borrow-page-btn').forEach(btn => { + btn.addEventListener('click', () => { + const targetPage = Number(btn.dataset.page); + if (!targetPage || targetPage < 1) { + return; + } + this.assetBorrowPage = targetPage; + this.renderAssetBorrowsTableBody(); + }); + }); + } + + setupAssetBorrowListeners() { + const searchInput = document.getElementById('assetBorrowSearch'); + if (searchInput && searchInput.dataset.boundInput !== 'true') { + searchInput.addEventListener('input', (event) => { + this.assetBorrowSearchTerm = String(event.target.value || '').trim(); + this.assetBorrowPage = 1; + this.renderAssetBorrowsTableBody(); + }); + searchInput.addEventListener('focus', () => { + searchInput.dataset.focused = 'true'; + }); + searchInput.addEventListener('blur', () => { + searchInput.dataset.focused = 'false'; + }); + searchInput.dataset.boundInput = 'true'; + } + + this.setupAssetBorrowPagerListeners(); + } + + async refreshAssetBorrowsUI() { + await this.fetchAssetBorrows(); + if (this.currentPage === 'asset-borrows') { + this.renderAssetBorrowsTableBody(); + } + const pendingModal = document.getElementById('assetPendingRequestsModal'); + if (pendingModal?.classList.contains('open')) { + this.renderPendingAssetRequestsModal(); + } + } + + startAssetBorrowAutoRefresh() { + if (this.assetBorrowAutoRefreshTimer) { + return; + } + + this.assetBorrowAutoRefreshTimer = setInterval(() => { + if (this.currentPage === 'asset-borrows') { + this.refreshAssetBorrowsUI(); + } + }, 15000); + } + + stopAssetBorrowAutoRefresh() { + if (!this.assetBorrowAutoRefreshTimer) { + return; + } + + clearInterval(this.assetBorrowAutoRefreshTimer); + this.assetBorrowAutoRefreshTimer = undefined; + } + + async openAssetBorrowRequestModal(requestType = 'borrow') { + if (!this.assets.length) { + await this.fetchAssets(); + } + + this.assetBorrowRequestType = this.normalizeAssetRequestType(requestType); + const modal = document.getElementById('assetBorrowRequestModal'); + const typeInput = document.getElementById('assetBorrowRequestTypeInput'); + const titleNode = document.getElementById('assetBorrowRequestModalTitle'); + const dateLabel = document.getElementById('assetBorrowDateLabel'); + const submitBtn = document.getElementById('assetBorrowRequestSubmitBtn'); + const noteInput = document.getElementById('assetBorrowNoteInput'); + const requesterInput = document.getElementById('assetBorrowRequesterInput'); + const productSearchInput = document.getElementById('assetBorrowProductSearchInput'); + const productInput = document.getElementById('assetBorrowProductInput'); + const quantityInput = document.getElementById('assetBorrowQuantityInput'); + const dateInput = document.getElementById('assetBorrowDateInput'); + + if (!modal || !requesterInput || !productInput || !quantityInput || !dateInput || !productSearchInput || !typeInput) { + this.notifyFailure('Không tìm thấy biểu mẫu đơn mượn/trả tài sản.'); + return; + } + + const isReturnRequest = this.assetBorrowRequestType === 'return'; + typeInput.value = this.assetBorrowRequestType; + if (titleNode) { + titleNode.textContent = isReturnRequest ? 'Tạo đơn trả tài sản' : 'Tạo đơn mượn tài sản'; + } + if (dateLabel) { + dateLabel.textContent = isReturnRequest ? 'Ngày trả' : 'Ngày mượn'; + } + if (submitBtn) { + submitBtn.textContent = isReturnRequest ? 'Tạo đơn trả' : 'Tạo đơn mượn'; + } + + requesterInput.value = this.getCurrentUserDisplayName(); + quantityInput.value = '1'; + quantityInput.min = '1'; + dateInput.value = this.toDateInputValue(new Date()); + if (noteInput) { + noteInput.value = ''; + } + productSearchInput.value = ''; + productInput.value = ''; + this.updateAssetBorrowProductDisplay(''); + this.closeAssetBorrowProductDropdown(); + + await this.searchAssetBorrowProducts('', '', { reset: true }); + if (!this.assetBorrowProductItems.length) { + this.notifyWarning('Hiện chưa có tài sản để tạo đơn.'); + return; + } + + modal.classList.add('open'); + } + + async handleAssetBorrowRequestSubmit(event) { + event.preventDefault(); + + const typeInput = document.getElementById('assetBorrowRequestTypeInput'); + const productInput = document.getElementById('assetBorrowProductInput'); + const quantityInput = document.getElementById('assetBorrowQuantityInput'); + const unitInput = document.getElementById('assetBorrowUnitInput'); + const dateInput = document.getElementById('assetBorrowDateInput'); + const requesterInput = document.getElementById('assetBorrowRequesterInput'); + const noteInput = document.getElementById('assetBorrowNoteInput'); + + const requestType = this.normalizeAssetRequestType(typeInput?.value || this.assetBorrowRequestType); + + const assetId = Number(productInput?.value || 0); + if (!Number.isFinite(assetId) || assetId <= 0) { + this.notifyWarning('Vui lòng chọn tài sản.'); + return; + } + + const quantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); + if (quantity <= 0) { + this.notifyWarning('Số lượng phải lớn hơn 0.'); + return; + } + + const borrowDate = String(dateInput?.value || '').trim() || this.toDateInputValue(new Date()); + const unit = String(unitInput?.value || '').trim(); + const borrowerName = String(requesterInput?.value || this.getCurrentUserDisplayName() || '').trim(); + const note = String(noteInput?.value || '').trim(); + + try { + const response = await fetch(`${this.apiBase}/asset-borrows`, { + method: 'POST', + headers: this.getAuthHeaders(true), + body: JSON.stringify({ + assetId, + requestType, + quantity, + unit, + borrowDate, + borrowerName, + note + }) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + this.notifyFailure(data.message || 'Tạo đơn thất bại'); + return; + } + + closeAssetBorrowRequestModal(); + this.notifySuccess(requestType === 'return' ? 'Tạo đơn trả tài sản thành công' : 'Tạo đơn mượn tài sản thành công'); + await this.refreshAssetBorrowsUI(); + } catch (err) { + console.error(err); + this.notifyFailure('Tạo đơn thất bại'); + } + } +buildPendingAssetRequestCardHtml(item) { + const typeMeta = this.getAssetRequestTypeMeta(item?.RequestType); + const dateLabel = typeMeta.value === 'return' ? 'Ngày trả' : 'Ngày mượn'; + const note = String(item?.RequestNote || '').trim(); + const requestId = Number(item?.BorrowId) || 0; + + return ` +
+
+ ${typeMeta.label} + #${requestId || '-'} +
+
+
Tên đầy đủ: ${this.escapeHtml(item?.BorrowerName || '-')}
+
Tên tài sản: ${this.escapeHtml(item?.AssetCode || '')} ${item?.AssetCode ? '- ' : ''}${this.escapeHtml(item?.AssetName || '-')}
+
Số lượng: ${Number(item?.BorrowQuantity) || 0} ${this.escapeHtml(item?.Unit || '')}
+
Ghi chú: ${this.escapeHtml(note || '-')}
+
${dateLabel}: ${this.formatDateOnly(item?.BorrowDate)}
+
+
+ + + +
+
+ `; + } + + bindPendingAssetRequestActionButtons() { + document.querySelectorAll('.asset-request-approve-btn').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => { + const requestId = Number(btn.dataset.requestId); + if (!Number.isFinite(requestId) || requestId <= 0) { + return; + } + this.processAssetBorrowRequest(requestId, 'approved'); + }); + + btn.dataset.boundClick = 'true'; + }); + + document.querySelectorAll('.asset-request-reject-btn').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => { + const requestId = Number(btn.dataset.requestId); + if (!Number.isFinite(requestId) || requestId <= 0) { + return; + } + this.openAssetRequestRejectModal(requestId); + }); + + btn.dataset.boundClick = 'true'; + }); + + document.querySelectorAll('.asset-request-delete-btn').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => { + const requestId = Number(btn.dataset.requestId); + if (!Number.isFinite(requestId) || requestId <= 0) { + return; + } + this.deletePendingAssetBorrowRequest(requestId); + }); + + btn.dataset.boundClick = 'true'; + }); + } + + renderPendingAssetRequestsModal() { + const borrowList = document.getElementById('pendingBorrowRequestsList'); + const returnList = document.getElementById('pendingReturnRequestsList'); + if (!borrowList || !returnList) { + return; + } + + const pendingRequests = (Array.isArray(this.assetBorrows) ? this.assetBorrows : []) + .filter(item => this.normalizeAssetRequestStatus(item?.RequestStatus) === 'pending'); + + const pendingBorrowRequests = pendingRequests.filter(item => this.normalizeAssetRequestType(item?.RequestType) === 'borrow'); + const pendingReturnRequests = pendingRequests.filter(item => this.normalizeAssetRequestType(item?.RequestType) === 'return'); + + const borrowCountBadge = document.getElementById('pendingBorrowCountBadge'); + const returnCountBadge = document.getElementById('pendingReturnCountBadge'); + if (borrowCountBadge) { + borrowCountBadge.textContent = pendingBorrowRequests.length > 99 ? '99+' : String(pendingBorrowRequests.length); + } + if (returnCountBadge) { + returnCountBadge.textContent = pendingReturnRequests.length > 99 ? '99+' : String(pendingReturnRequests.length); + } + + borrowList.innerHTML = pendingBorrowRequests.length + ? pendingBorrowRequests.map(item => this.buildPendingAssetRequestCardHtml(item)).join('') + : `
Không có đơn mượn nào đang chờ.
`; + + returnList.innerHTML = pendingReturnRequests.length + ? pendingReturnRequests.map(item => this.buildPendingAssetRequestCardHtml(item)).join('') + : `
Không có đơn trả nào đang chờ.
`; + + this.bindPendingAssetRequestActionButtons(); + } + + async openPendingAssetRequestsModal() { + if (!this.canCurrentUserManageAssets()) { + this.notifyWarning('Chỉ role Asset/Admin mới được xử lý đơn chờ.'); + return; + } + + const modal = document.getElementById('assetPendingRequestsModal'); + if (!modal) { + this.notifyFailure('Không tìm thấy hộp thoại đơn chờ.'); + return; + } + + await this.fetchAssetBorrows(); + this.renderPendingAssetRequestsModal(); + modal.style.zIndex = '120'; + modal.classList.add('open'); + } + + openAssetRequestRejectModal(requestId) { + if (!this.canCurrentUserManageAssets()) { + return; + } + + const rejectModal = document.getElementById('assetRequestRejectModal'); + const idInput = document.getElementById('assetRequestRejectIdInput'); + const reasonInput = document.getElementById('assetRequestRejectReasonInput'); + if (!rejectModal || !idInput || !reasonInput) { + this.notifyFailure('Không tìm thấy hộp thoại từ chối đơn.'); + return; + } + + this.pendingAssetRequestRejectId = Number(requestId); + idInput.value = String(requestId); + reasonInput.value = ''; + rejectModal.style.zIndex = '130'; + rejectModal.classList.add('open'); + reasonInput.focus(); + } + + async handleAssetRequestRejectSubmit(event) { + event.preventDefault(); + + const idInput = document.getElementById('assetRequestRejectIdInput'); + const reasonInput = document.getElementById('assetRequestRejectReasonInput'); + const requestId = Number(idInput?.value || this.pendingAssetRequestRejectId); + const reason = String(reasonInput?.value || '').trim(); + + if (!Number.isFinite(requestId) || requestId <= 0) { + this.notifyWarning('Không xác định được đơn cần từ chối.'); + return; + } + + if (!reason) { + this.notifyWarning('Vui lòng nhập lý do từ chối.'); + return; + } + + await this.processAssetBorrowRequest(requestId, 'rejected', reason); + } + + async processAssetBorrowRequest(requestId, action, rejectReason = '') { + if (!this.canCurrentUserManageAssets()) { + this.notifyWarning('Chỉ role Asset/Admin mới được xử lý đơn chờ.'); + return; + } + + try { + const response = await fetch(`${this.apiBase}/asset-borrows/${requestId}/process`, { + method: 'POST', + headers: this.getAuthHeaders(true), + body: JSON.stringify({ + action, + rejectReason + }) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + const failureMessage = data?.message || 'Xử lý đơn thất bại'; + this.notifyFailure(failureMessage); + + const canAutoSuggestDelete = action === 'approved' + && typeof failureMessage === 'string' + && failureMessage.toLowerCase().includes('xóa đơn chờ'); + + if (canAutoSuggestDelete) { + const shouldDelete = window.confirm(`Đơn #${requestId} không còn hợp lệ. Bạn có muốn xóa đơn chờ này không?`); + if (shouldDelete) { + await this.deletePendingAssetBorrowRequest(requestId); + } + } + return; + } + + if (action === 'rejected') { + this.pendingAssetRequestRejectId = undefined; + closeAssetRequestRejectModal(); + } + + this.notifySuccess(action === 'approved' ? 'Đã chấp nhận đơn' : 'Đã từ chối đơn'); + await this.fetchAssetBorrows(); + await this.fetchAssets(); + + if (this.currentPage === 'asset-borrows') { + this.renderAssetBorrowsTableBody(); + } + if (this.currentPage === 'assets') { + this.renderAssetsTableBody(); + } + + const pendingModal = document.getElementById('assetPendingRequestsModal'); + if (pendingModal?.classList.contains('open')) { + this.renderPendingAssetRequestsModal(); + } + + this.updatePendingAssetRequestsBadge(); + } catch (err) { + console.error(err); + this.notifyFailure('Xử lý đơn thất bại'); + } + } + + async deletePendingAssetBorrowRequest(requestId) { + if (!this.canCurrentUserManageAssets()) { + this.notifyWarning('Chỉ role Asset/Admin mới được xóa đơn chờ.'); + return; + } + + const targetId = Number(requestId); + if (!Number.isFinite(targetId) || targetId <= 0) { + this.notifyWarning('Không xác định được đơn cần xóa.'); + return; + } + + const confirmed = window.confirm(`Bạn có chắc muốn xóa đơn chờ #${targetId}?`); + if (!confirmed) { + return; + } + + try { + const response = await fetch(`${this.apiBase}/asset-borrows/${targetId}`, { + method: 'DELETE', + headers: this.getAuthHeaders(false) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + this.notifyFailure(data.message || 'Xóa đơn chờ thất bại'); + return; + } + + this.notifySuccess(data.message || 'Đã xóa đơn chờ'); + await this.fetchAssetBorrows(); + + if (this.currentPage === 'asset-borrows') { + this.renderAssetBorrowsTableBody(); + } + + const pendingModal = document.getElementById('assetPendingRequestsModal'); + if (pendingModal?.classList.contains('open')) { + this.renderPendingAssetRequestsModal(); + } + + this.updatePendingAssetRequestsBadge(); + } catch (err) { + console.error(err); + this.notifyFailure('Xóa đơn chờ thất bại'); + } + } getAssetsContent() { this.syncSelectedAssetIds(); const canManageAssets = this.canCurrentUserManageAssets(); @@ -1736,7 +2827,7 @@ class AccountManager { @@ -1838,7 +2929,7 @@ class AccountManager {

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 {
-
••••••••
+
********
@@ -4996,7 +6105,7 @@ class AccountManager { if (passwordEl) { passwordEl.dataset.password = passwordValue; passwordEl.dataset.visible = 'false'; - passwordEl.textContent = hasReadablePassword ? '••••••••' : '(khong the hien thi - can reset password 1 lan)'; + passwordEl.textContent = hasReadablePassword ? '********' : '(khong the hien thi - can reset password 1 lan)'; } if (passwordToggleBtn && passwordEl) { @@ -5006,7 +6115,7 @@ class AccountManager { } const isVisible = passwordEl.dataset.visible === 'true'; if (isVisible) { - passwordEl.textContent = '••••••••'; + passwordEl.textContent = '********'; passwordEl.dataset.visible = 'false'; if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility'; } else { @@ -5125,6 +6234,35 @@ function closeBorrowAssetModal() { } } +function closeAssetBorrowRequestModal() { + const modal = document.getElementById('assetBorrowRequestModal'); + const dropdown = document.getElementById('assetBorrowProductDropdown'); + if (modal) { + modal.classList.remove('open'); + } + if (dropdown) { + dropdown.classList.add('hidden'); + } +} + +function closeAssetPendingRequestsModal() { + const modal = document.getElementById('assetPendingRequestsModal'); + if (modal) { + modal.classList.remove('open'); + } + const rejectModal = document.getElementById('assetRequestRejectModal'); + if (rejectModal) { + rejectModal.classList.remove('open'); + } +} + +function closeAssetRequestRejectModal() { + const modal = document.getElementById('assetRequestRejectModal'); + if (modal) { + modal.classList.remove('open'); + } +} + function closeAssetDepartmentModal() { const modal = document.getElementById('assetDepartmentModal'); if (modal) { diff --git a/public/modals.html b/public/modals.html index 3e7edf0..067c805 100644 --- a/public/modals.html +++ b/public/modals.html @@ -24,7 +24,7 @@
- +
@@ -59,7 +59,7 @@
-
••••••••
+
********
@@ -329,7 +329,7 @@
@@ -349,6 +349,134 @@
+ + + + + + + + + @@ -273,6 +277,11 @@
+