This commit is contained in:
2026-04-24 16:11:00 +07:00
parent 3961514f6c
commit d4800beb67
5 changed files with 2094 additions and 44 deletions

View File

@@ -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 đã tn ti' });
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 đã tn ti' });
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 đã tn ti' });
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 đã tn ti' });
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`);

View File

@@ -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 '========================================';

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
<input type="password" id="accountPassword" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="••••••••">
<input type="password" id="accountPassword" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="********">
</div>
<div class="flex gap-3 pt-4">
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAccountModal()">Cancel</button>
@@ -59,7 +59,7 @@
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
<div class="flex items-center gap-2">
<div id="viewAccountPassword" class="flex-1 border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50 text-slate-600">••••••••</div>
<div id="viewAccountPassword" class="flex-1 border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50 text-slate-600">********</div>
<button type="button" class="p-2 rounded-lg hover:bg-slate-100 text-slate-400 transition-colors toggle-password">
<span class="material-symbols-outlined text-lg" id="toggleIcon">visibility</span>
</button>
@@ -329,7 +329,7 @@
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người mượn</label>
<select id="borrowAssetUserInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
<option value="">-- Chon nguoi muon --</option>
<option value="">-- Chn người mượn --</option>
</select>
</div>
<div>
@@ -349,6 +349,134 @@
</div>
</div>
<!-- Asset Borrow Request Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetBorrowRequestModal">
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-visible m-4">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<h3 id="assetBorrowRequestModalTitle" class="text-base font-extrabold text-slate-900">Tạo đơn mượn tài sản</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetBorrowRequestModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="assetBorrowRequestForm" class="p-6 space-y-4 relative">
<input type="hidden" id="assetBorrowRequestTypeInput" value="borrow">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên đầy đủ</label>
<input type="text" id="assetBorrowRequesterInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên tài sản</label>
<div id="assetBorrowProductPicker" class="relative z-[130]">
<input type="hidden" id="assetBorrowProductInput">
<button
type="button"
id="assetBorrowProductDisplayBtn"
class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 text-left flex items-center justify-between gap-2"
>
<span id="assetBorrowProductDisplayText" class="flex-1 min-w-0 truncate text-slate-600">-- Chọn tài sản --</span>
<span class="material-symbols-outlined text-base text-slate-400">expand_more</span>
</button>
<div
id="assetBorrowProductDropdown"
class="hidden absolute left-0 right-0 w-full max-w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-xl z-[140] overflow-hidden"
>
<div class="p-2 border-b border-slate-100">
<input
type="text"
id="assetBorrowProductSearchInput"
class="w-full border border-slate-200 rounded-md text-sm py-2 px-2.5"
placeholder="Tìm theo mã hoặc tên tài sản..."
autocomplete="off"
>
</div>
<div
id="assetBorrowProductList"
class="overflow-auto"
style="max-height: 224px; overflow: auto; overscroll-behavior: contain;"
></div>
<div id="assetBorrowProductLoading" class="hidden px-3 py-2 text-xs text-slate-500 bg-slate-50 border-t border-slate-100">
Đang tải thêm tài sản...
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng</label>
<input type="number" id="assetBorrowQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Đơn vị</label>
<input type="text" id="assetBorrowUnitInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
</div>
<div>
<label id="assetBorrowDateLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ngày mượn</label>
<input type="date" id="assetBorrowDateInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú</label>
<textarea id="assetBorrowNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập ghi chú (nếu có)"></textarea>
</div>
<div class="flex gap-3 pt-2">
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetBorrowRequestModal()">Hủy</button>
<button type="submit" id="assetBorrowRequestSubmitBtn" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Tạo đơn mượn</button>
</div>
</form>
</div>
</div>
<!-- Pending Asset Requests Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetPendingRequestsModal" style="z-index: 120;">
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<h3 class="text-base font-extrabold text-slate-900">Đơn chờ xử lý</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetPendingRequestsModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4 overflow-y-auto">
<div class="rounded-xl border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-blue-50 border-b border-blue-100 flex items-center justify-between">
<h4 class="text-sm font-extrabold text-blue-700">Đơn mượn</h4>
<span id="pendingBorrowCountBadge" class="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-blue-600 text-white text-xs font-extrabold">0</span>
</div>
<div id="pendingBorrowRequestsList" class="p-3 space-y-3 max-h-[60vh] overflow-y-auto"></div>
</div>
<div class="rounded-xl border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-emerald-50 border-b border-emerald-100 flex items-center justify-between">
<h4 class="text-sm font-extrabold text-emerald-700">Đơn trả</h4>
<span id="pendingReturnCountBadge" class="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-emerald-600 text-white text-xs font-extrabold">0</span>
</div>
<div id="pendingReturnRequestsList" class="p-3 space-y-3 max-h-[60vh] overflow-y-auto"></div>
</div>
</div>
</div>
</div>
<!-- Reject Asset Request Modal -->
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetRequestRejectModal" style="z-index: 130;">
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<h3 class="text-base font-extrabold text-slate-900">Từ chối đơn</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetRequestRejectModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="assetRequestRejectForm" class="p-6 space-y-4">
<input type="hidden" id="assetRequestRejectIdInput">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Lý do từ chối</label>
<textarea id="assetRequestRejectReasonInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-24 resize-none" placeholder="Nhập lý do từ chối..." required></textarea>
</div>
<div class="flex gap-3">
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetRequestRejectModal()">Hủy</button>
<button type="submit" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xác nhận từ chối</button>
</div>
</form>
</div>
</div>
<!-- View Asset Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="viewAssetModal">
<div class="modal-content w-full max-w-2xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">

View File

@@ -240,9 +240,13 @@
<span class="material-symbols-outlined">inventory_2</span>
<span>Tài sản</span>
</a>
<a href="#asset-borrows" data-nav="asset-borrows" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
<span class="material-symbols-outlined">assignment_returned</span>
<span>Mượn/Trả tài sản</span>
</a>
<a href="#asset-departments" data-nav="asset-departments" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
<span class="material-symbols-outlined">apartment</span>
<span>Quản Lý</span>
<span> Phòng Ban</span>
</a>
</div>
</div>
@@ -273,6 +277,11 @@
</button>
</div>
<div class="topbar-actions flex items-center gap-4">
<button id="pendingAssetRequestsBtn" type="button" class="hidden relative flex items-center gap-2 px-3 py-2 rounded-lg border border-amber-200 bg-amber-50 hover:bg-amber-100 text-amber-800 transition-colors" title="Đơn chờ xử lý">
<span class="material-symbols-outlined text-base">notifications_active</span>
<span class="text-xs font-bold">Đơn chờ</span>
<span id="pendingAssetRequestsBadge" class="hidden absolute -top-2.5 -right-2.5 min-w-[22px] h-[22px] px-1.5 rounded-full bg-red-600 text-white text-xs font-extrabold leading-[22px] text-center ring-2 ring-white">0</span>
</button>
<button id="profileBtn" type="button" class="profile-btn flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" title="Sửa hồ sơ">
<span class="material-symbols-outlined text-slate-600 dark:text-slate-400">account_circle</span>
<div class="profile-meta flex flex-col">
@@ -292,6 +301,6 @@
</div>
</main>
<script src="../js/app.js?v=20260421-4"></script>
<script src="../js/app.js?v=20260424-1"></script>
</body>
</html>