request
This commit is contained in:
@@ -323,19 +323,19 @@ function parseNullableDate(value) {
|
|||||||
function normalizeAssetStatus(value) {
|
function normalizeAssetStatus(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
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';
|
return 'in_use';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['maintenance', 'bao tri', 'bảo trì'].includes(normalized)) {
|
if (['maintenance', 'bao tri'].includes(normalized)) {
|
||||||
return 'maintenance';
|
return 'maintenance';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['disposed', 'thanh ly', 'thanh lý', 'retired'].includes(normalized)) {
|
if (['disposed', 'thanh ly', 'retired'].includes(normalized)) {
|
||||||
return 'disposed';
|
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';
|
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) {
|
function normalizeImportToken(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
.replace(/[đĐ]/g, 'd')
|
.replace(/[\u0111\u0110]/g, 'd')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]/g, '');
|
.replace(/[^a-z0-9]/g, '');
|
||||||
}
|
}
|
||||||
@@ -710,7 +854,7 @@ function sanitizeAssetCodeToken(value) {
|
|||||||
return String(value || '')
|
return String(value || '')
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
.replace(/[đĐ]/g, 'd')
|
.replace(/[\u0111\u0110]/g, 'd')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.replace(/[^A-Z0-9]+/g, '-')
|
.replace(/[^A-Z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
@@ -1173,7 +1317,7 @@ async function initializeDatabase() {
|
|||||||
try {
|
try {
|
||||||
pool = new sql.ConnectionPool(sqlConfig);
|
pool = new sql.ConnectionPool(sqlConfig);
|
||||||
await pool.connect();
|
await pool.connect();
|
||||||
console.log('✓ Connected to SQL Server');
|
console.log('[OK] Connected to SQL Server');
|
||||||
|
|
||||||
// Check and create database if not exists
|
// Check and create database if not exists
|
||||||
const masterConnection = new sql.ConnectionPool({
|
const masterConnection = new sql.ConnectionPool({
|
||||||
@@ -1198,7 +1342,7 @@ async function initializeDatabase() {
|
|||||||
// Now create tables in AccManager
|
// Now create tables in AccManager
|
||||||
await createTables();
|
await createTables();
|
||||||
await migrateLegacyPasswords();
|
await migrateLegacyPasswords();
|
||||||
console.log('✓ Database and tables created');
|
console.log('[OK] Database and tables created');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Database connection failed:', err);
|
console.error('Database connection failed:', err);
|
||||||
@@ -1240,7 +1384,7 @@ async function migrateLegacyPasswords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (migratedCount > 0) {
|
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) {
|
} catch (err) {
|
||||||
console.error('Password migration error:', err.message);
|
console.error('Password migration error:', err.message);
|
||||||
@@ -1343,6 +1487,32 @@ async function createTables() {
|
|||||||
UpdatedDate DATETIME DEFAULT GETDATE()
|
UpdatedDate DATETIME DEFAULT GETDATE()
|
||||||
)
|
)
|
||||||
END`,
|
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
|
// AuditLog Table
|
||||||
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
|
`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);
|
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
|
// Ensure new columns exist on Applications for migrations
|
||||||
try {
|
try {
|
||||||
await pool.request().query(`IF EXISTS (
|
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','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','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.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 EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`);
|
||||||
await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`);
|
await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`);
|
||||||
await pool.request().query(`
|
await pool.request().query(`
|
||||||
@@ -1455,7 +1657,7 @@ async function createTables() {
|
|||||||
SET EmailVerified = 1,
|
SET EmailVerified = 1,
|
||||||
EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE())
|
EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE())
|
||||||
WHERE Username = @username`);
|
WHERE Username = @username`);
|
||||||
console.log('✓ Admin user created: admin / admin');
|
console.log('[OK] Admin user created: admin / admin');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Admin user error:', err.message);
|
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'),
|
('Google Workspace', 'Collaboration', 'online', 'mail', 'Google Workspace', 'https://workspace.google.com'),
|
||||||
('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org')
|
('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org')
|
||||||
END`);
|
END`);
|
||||||
console.log('✓ Sample applications created');
|
console.log('[OK] Sample applications created');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Applications error:', err.message);
|
console.error('Applications error:', err.message);
|
||||||
}
|
}
|
||||||
@@ -2499,7 +2701,7 @@ app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
if (existed.recordset.length > 0) {
|
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()
|
const inserted = await pool.request()
|
||||||
@@ -2517,7 +2719,7 @@ app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
|
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 });
|
res.status(500).json({ success: false, message: err.message });
|
||||||
@@ -2528,7 +2730,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const departmentId = Number(req.params.id);
|
const departmentId = Number(req.params.id);
|
||||||
if (!Number.isInteger(departmentId) || departmentId <= 0) {
|
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);
|
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) {
|
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);
|
const transaction = new sql.Transaction(pool);
|
||||||
@@ -2607,7 +2809,7 @@ app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
|
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 });
|
res.status(500).json({ success: false, message: err.message });
|
||||||
@@ -2618,7 +2820,7 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) =
|
|||||||
try {
|
try {
|
||||||
const departmentId = Number(req.params.id);
|
const departmentId = Number(req.params.id);
|
||||||
if (!Number.isInteger(departmentId) || departmentId <= 0) {
|
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();
|
await syncAssetDepartmentsFromInventory();
|
||||||
@@ -2675,6 +2877,401 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) =
|
|||||||
// API ROUTES - Asset Inventory
|
// 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) => {
|
app.get('/api/assets', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.request().query(`
|
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) => {
|
app.get('/api/assets/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.request()
|
const result = await pool.request()
|
||||||
@@ -3078,9 +3741,9 @@ async function startServer() {
|
|||||||
console.log(`\n========================================`);
|
console.log(`\n========================================`);
|
||||||
console.log(`AccManager Backend Server`);
|
console.log(`AccManager Backend Server`);
|
||||||
console.log(`========================================`);
|
console.log(`========================================`);
|
||||||
console.log(`✓ Server running on http://localhost:${PORT}`);
|
console.log(`[OK] Server running on http://localhost:${PORT}`);
|
||||||
console.log(`✓ Database: AccManager`);
|
console.log('[OK] Database: AccManager');
|
||||||
console.log(`✓ Default admin: admin / admin`);
|
console.log('[OK] Default admin: admin / admin');
|
||||||
console.log(`\nAPI Endpoints:`);
|
console.log(`\nAPI Endpoints:`);
|
||||||
console.log(` POST /api/auth/login`);
|
console.log(` POST /api/auth/login`);
|
||||||
console.log(` GET /api/database/info`);
|
console.log(` GET /api/database/info`);
|
||||||
|
|||||||
@@ -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')
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -173,7 +265,7 @@ BEGIN
|
|||||||
END
|
END
|
||||||
|
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
-- 7. CREATE INDEXES
|
-- 8. CREATE INDEXES
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
|
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -210,10 +302,30 @@ BEGIN
|
|||||||
CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);
|
CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);
|
||||||
END
|
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.';
|
PRINT 'Indexes created successfully.';
|
||||||
|
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
-- 8. INSERT INITIAL DATA
|
-- 9. INSERT INITIAL DATA
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
|
|
||||||
-- Check if admin user exists
|
-- Check if admin user exists
|
||||||
@@ -237,7 +349,7 @@ BEGIN
|
|||||||
END
|
END
|
||||||
|
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
-- 9. DISPLAY DATABASE INFORMATION
|
-- 10. DISPLAY DATABASE INFORMATION
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
PRINT '';
|
PRINT '';
|
||||||
PRINT '========================================';
|
PRINT '========================================';
|
||||||
|
|||||||
1168
public/js/app.js
1168
public/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
|
<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>
|
||||||
<div class="flex gap-3 pt-4">
|
<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>
|
<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>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Password</label>
|
<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 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">
|
<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>
|
<span class="material-symbols-outlined text-lg" id="toggleIcon">visibility</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người mượn</label>
|
<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>
|
<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="">-- Chọn người mượn --</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -349,6 +349,134 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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-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);">
|
<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);">
|
||||||
|
|||||||
@@ -240,9 +240,13 @@
|
|||||||
<span class="material-symbols-outlined">inventory_2</span>
|
<span class="material-symbols-outlined">inventory_2</span>
|
||||||
<span>Tài sản</span>
|
<span>Tài sản</span>
|
||||||
</a>
|
</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">
|
<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 class="material-symbols-outlined">apartment</span>
|
||||||
<span>Quản Lý</span>
|
<span> Phòng Ban</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +277,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions flex items-center gap-4">
|
<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ơ">
|
<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>
|
<span class="material-symbols-outlined text-slate-600 dark:text-slate-400">account_circle</span>
|
||||||
<div class="profile-meta flex flex-col">
|
<div class="profile-meta flex flex-col">
|
||||||
@@ -292,6 +301,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="../js/app.js?v=20260421-4"></script>
|
<script src="../js/app.js?v=20260424-1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user