24/04/2026 - mã tài sản

This commit is contained in:
2026-04-24 13:56:12 +07:00
parent 9526628334
commit 3961514f6c
5 changed files with 1048 additions and 52 deletions

View File

@@ -209,6 +209,54 @@ async function getUserDisplayNameById(userId) {
}
}
function normalizeDepartmentName(value) {
return String(value || '').trim();
}
async function syncAssetDepartmentsFromInventory() {
if (!pool) {
return;
}
await pool.request().query(`
WITH SourceDepartments AS (
SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName
FROM AssetInventory
WHERE Department IS NOT NULL
AND LTRIM(RTRIM(Department)) <> ''
)
INSERT INTO AssetDepartments (DepartmentName)
SELECT source.DepartmentName
FROM SourceDepartments source
WHERE NOT EXISTS (
SELECT 1
FROM AssetDepartments target
WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName)
);
`);
}
async function ensureDepartmentExists(departmentName) {
const normalized = normalizeDepartmentName(departmentName);
if (!normalized || !pool) {
return;
}
await pool.request()
.input('departmentName', sql.NVarChar, normalized)
.query(`
IF NOT EXISTS (
SELECT 1
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
)
BEGIN
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
END
`);
}
function parsePositiveInteger(value, fallback = 1) {
const parsed = Number(value);
if (Number.isInteger(parsed) && parsed > 0) {
@@ -678,6 +726,44 @@ function generateImportAssetCodeFromRow(mapped, rowNumber = 0) {
return `IMP-${base}-${suffix}`;
}
function generateManualAssetCodeFromPayload(payload = {}) {
const fromModel = sanitizeAssetCodeToken(payload.model);
const fromSerial = sanitizeAssetCodeToken(payload.serialNumber);
const fromName = sanitizeAssetCodeToken(payload.assetName);
const base = (fromModel || fromSerial || fromName || 'ASSET').slice(0, 32);
const now = new Date();
const timestamp = [
String(now.getFullYear()),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
String(now.getMilliseconds()).padStart(3, '0')
].join('');
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `AST-${base}-${timestamp}${randomSuffix}`;
}
async function generateUniqueManualAssetCode(payload = {}, maxAttempts = 8) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = generateManualAssetCodeFromPayload(payload);
const existed = await pool.request()
.input('assetCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 AssetId
FROM AssetInventory
WHERE AssetCode = @assetCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
}
throw new Error('Cannot generate unique asset code');
}
function finalizeImportedAssetPayload(mapped, rowNumber = 0) {
const result = { ...mapped };
if (!result.assetName) {
@@ -1246,6 +1332,17 @@ async function createTables() {
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Asset Departments Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
CREATE TABLE AssetDepartments (
DepartmentId INT PRIMARY KEY IDENTITY(1,1),
DepartmentName NVARCHAR(100) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE()
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
@@ -1276,10 +1373,18 @@ async function createTables() {
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);`);
} catch (err) {
console.error('AssetInventory index creation error:', err.message);
}
// Ensure AssetDepartments indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`);
} catch (err) {
console.error('AssetDepartments index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
@@ -1324,6 +1429,13 @@ async function createTables() {
console.error('Column addition error (Applications):', err.message);
}
// Sync legacy departments from AssetInventory to AssetDepartments
try {
await syncAssetDepartmentsFromInventory();
} catch (err) {
console.error('AssetDepartments sync error:', err.message);
}
// Insert initial admin user
try {
const adminPasswordHash = await hashPassword('admin');
@@ -2341,6 +2453,224 @@ app.delete('/api/accounts/:id', async (req, res) => {
}
});
// ==========================================
// API ROUTES - Asset Departments
// ==========================================
app.get('/api/asset-departments', async (req, res) => {
try {
await syncAssetDepartmentsFromInventory();
const result = await pool.request().query(`
SELECT
d.DepartmentId,
d.DepartmentName,
d.CreatedDate,
d.UpdatedDate,
COUNT(ai.AssetId) AS AssetCount
FROM AssetDepartments d
LEFT JOIN AssetInventory ai
ON LOWER(LTRIM(RTRIM(ai.Department))) = LOWER(LTRIM(RTRIM(d.DepartmentName)))
GROUP BY d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate
ORDER BY d.DepartmentName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
try {
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const existed = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (existed.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
const inserted = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
SELECT SCOPE_IDENTITY() AS DepartmentId;
`);
res.json({
success: true,
message: 'Đã thêm phòng ban',
departmentId: inserted.recordset[0]?.DepartmentId
});
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
res.status(500).json({ success: false, message: err.message });
}
});
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ệ' });
}
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const currentDepartment = currentResult.recordset[0];
const currentName = String(currentDepartment.DepartmentName || '').trim();
if (currentName.toLowerCase() === departmentName.toLowerCase()) {
return res.json({ success: true, message: 'Đã cập nhật phòng ban' });
}
const duplicated = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE DepartmentId <> @departmentId
AND LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (duplicated.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetDepartments
SET DepartmentName = @departmentName,
UpdatedDate = GETDATE()
WHERE DepartmentId = @departmentId
`);
await new sql.Request(transaction)
.input('oldDepartmentName', sql.NVarChar, currentName)
.input('newDepartmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = @newDepartmentName,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@oldDepartmentName)
`);
await transaction.commit();
res.json({ success: true, message: 'Đã cập nhật phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phòng ban đã tồn tại' });
}
res.status(500).json({ success: false, message: err.message });
}
});
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ệ' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const departmentName = String(currentResult.recordset[0].DepartmentName || '').trim();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = NULL,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@departmentName)
`);
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.query(`
DELETE FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
await transaction.commit();
res.json({ success: true, message: 'Đã xóa phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Asset Inventory
// ==========================================
@@ -2391,10 +2721,16 @@ app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
if (!payload.assetCode || !payload.assetName) {
return res.status(400).json({ success: false, message: 'Asset code and asset name are required' });
if (!payload.assetName) {
return res.status(400).json({ success: false, message: 'Asset name is required' });
}
if (!payload.assetCode) {
payload.assetCode = await generateUniqueManualAssetCode(payload);
}
await ensureDepartmentExists(payload.department);
const result = await pool.request()
.input('assetCode', sql.NVarChar, payload.assetCode)
.input('assetName', sql.NVarChar, payload.assetName)
@@ -2451,6 +2787,8 @@ app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
return res.status(400).json({ success: false, message: 'Asset code and asset name are required' });
}
await ensureDepartmentExists(payload.department);
await pool.request()
.input('assetId', sql.Int, req.params.id)
.input('assetCode', sql.NVarChar, payload.assetCode)
@@ -2561,7 +2899,13 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
const normalizedRows = incomingRows
.map(row => normalizeAssetPayload(row))
.map((row, rowIndex) => {
const normalized = normalizeAssetPayload(row);
if (!normalized.assetCode && normalized.assetName) {
normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1);
}
return normalized;
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => row.assetCode && row.assetName);
@@ -2648,6 +2992,7 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
}
await transaction.commit();
await syncAssetDepartmentsFromInventory();
res.json({
success: true,

View File

@@ -125,7 +125,36 @@ BEGIN
END
-- ===========================================
-- 5. CREATE AUDIT LOG TABLE
-- 5. CREATE ASSET DEPARTMENTS TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
CREATE TABLE AssetDepartments (
DepartmentId INT PRIMARY KEY IDENTITY(1,1),
DepartmentName NVARCHAR(100) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE()
);
PRINT 'Table AssetDepartments created successfully.';
END
;WITH SourceDepartments AS (
SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName
FROM AssetInventory
WHERE Department IS NOT NULL
AND LTRIM(RTRIM(Department)) <> ''
)
INSERT INTO AssetDepartments (DepartmentName)
SELECT source.DepartmentName
FROM SourceDepartments source
WHERE NOT EXISTS (
SELECT 1
FROM AssetDepartments target
WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName)
);
-- ===========================================
-- 6. CREATE AUDIT LOG TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
@@ -144,7 +173,7 @@ BEGIN
END
-- ===========================================
-- 6. CREATE INDEXES
-- 7. CREATE INDEXES
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
BEGIN
@@ -171,10 +200,20 @@ BEGIN
CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department')
BEGIN
CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName')
BEGIN
CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);
END
PRINT 'Indexes created successfully.';
-- ===========================================
-- 7. INSERT INITIAL DATA
-- 8. INSERT INITIAL DATA
-- ===========================================
-- Check if admin user exists
@@ -198,7 +237,7 @@ BEGIN
END
-- ===========================================
-- 8. DISPLAY DATABASE INFORMATION
-- 9. DISPLAY DATABASE INFORMATION
-- ===========================================
PRINT '';
PRINT '========================================';

View File

@@ -33,6 +33,8 @@ class AccountManager {
this.userRoleFilter = '';
this.assetSearchTerm = '';
this.assetStatusFilter = '';
this.assetDepartments = [];
this.assetDepartmentSearchTerm = '';
this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900;
this.boundResizeHandler = null;
@@ -41,6 +43,8 @@ class AccountManager {
this.pendingAccountAppId = undefined;
this.editingAssetBorrowerEntries = [];
this.pendingBorrowAssetId = undefined;
this.editingAssetDepartmentId = undefined;
this.pendingDeleteAssetDepartmentId = undefined;
}
configureNotifications() {
@@ -127,12 +131,12 @@ class AccountManager {
return role === 'admin' || role === 'asset';
}
ensureAssetManagePermission(actionLabel = 'thuc hien thao tac nay') {
ensureAssetManagePermission(actionLabel = 'thc hin thao tác này') {
if (this.canCurrentUserManageAssets()) {
return true;
}
this.notifyWarning(`Ban chi co quyen xem tai san. Chi role Asset/Admin moi duoc ${actionLabel}.`);
this.notifyWarning(`Bn ch có quyn xem tài sn. Ch role Asset/Admin mi được ${actionLabel}.`);
return false;
}
@@ -153,6 +157,7 @@ class AccountManager {
await this.fetchApplications();
await this.fetchAccounts();
await this.fetchAssets();
await this.fetchAssetDepartments();
if (this.canCurrentUserManageAssets()) {
await this.fetchUsers();
@@ -201,6 +206,10 @@ class AccountManager {
this.setupAddButtonListeners();
this.setupFilters();
this.setupAssetPagerListeners();
} else if (page === 'asset-departments') {
mainContent.innerHTML = this.getAssetDepartmentsContent();
this.setupAssetDepartmentListeners();
this.setupAddButtonListeners();
} else if (page === 'accounts') {
mainContent.innerHTML = this.getAccountsContent();
this.setupAccountRowListeners();
@@ -422,6 +431,59 @@ class AccountManager {
});
}
getUniqueAssetDepartmentNames() {
const rows = Array.isArray(this.assetDepartments) ? this.assetDepartments : [];
const seen = new Set();
return rows
.map(item => String(item?.DepartmentName || '').trim())
.filter(name => {
if (!name) return false;
const key = name.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' }));
}
refreshAssetDepartmentOptions(selectedValue = '') {
const select = document.getElementById('assetDepartmentInput');
if (!select) {
return;
}
const normalizedSelected = String(selectedValue || select.value || '').trim();
const departmentNames = this.getUniqueAssetDepartmentNames();
select.innerHTML = '';
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = '-- Chọn phòng ban --';
select.appendChild(emptyOption);
let hasSelected = false;
departmentNames.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
if (normalizedSelected && name === normalizedSelected) {
option.selected = true;
hasSelected = true;
}
select.appendChild(option);
});
if (normalizedSelected && !hasSelected) {
const legacyOption = document.createElement('option');
legacyOption.value = normalizedSelected;
legacyOption.textContent = normalizedSelected;
legacyOption.selected = true;
select.appendChild(legacyOption);
} else if (!normalizedSelected) {
select.value = '';
}
}
async fetchAssets() {
try {
const res = await fetch(`${this.apiBase}/assets`);
@@ -437,6 +499,21 @@ class AccountManager {
}
}
async fetchAssetDepartments() {
try {
const res = await fetch(`${this.apiBase}/asset-departments`);
const data = await res.json();
if (data.success) {
this.assetDepartments = Array.isArray(data.data) ? data.data : [];
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
} else {
console.error('Load asset departments failed:', data.message);
}
} catch (err) {
console.error('Fetch asset departments error:', err);
}
}
async fetchRoles() {
try {
const res = await fetch(`${this.apiBase}/roles`);
@@ -472,6 +549,7 @@ class AccountManager {
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
} catch (error) {
console.error('Lỗi load modals:', error);
}
@@ -481,6 +559,7 @@ class AccountManager {
const accountSearch = document.getElementById('accountSearch');
const appSearch = document.getElementById('appSearch');
const assetSearch = document.getElementById('assetSearch');
const assetDepartmentSearch = document.getElementById('assetDepartmentSearch');
if (accountSearch && accountSearch.dataset.focused === 'true') {
const pos = accountSearch.selectionStart || accountSearch.value.length;
@@ -499,6 +578,12 @@ class AccountManager {
assetSearch.focus();
assetSearch.setSelectionRange(pos, pos);
}
if (assetDepartmentSearch && assetDepartmentSearch.dataset.focused === 'true') {
const pos = assetDepartmentSearch.selectionStart || assetDepartmentSearch.value.length;
assetDepartmentSearch.focus();
assetDepartmentSearch.setSelectionRange(pos, pos);
}
}
setupEventListeners() {
@@ -559,7 +644,9 @@ class AccountManager {
assetForm.addEventListener('submit', (e) => this.handleAssetSubmit(e));
assetForm.dataset.boundSubmit = 'true';
}
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
this.setupAssetStockListeners();
this.setupAssetFormValidationListeners();
}
const borrowAssetForm = document.getElementById('borrowAssetForm');
@@ -570,6 +657,23 @@ class AccountManager {
}
}
const assetDepartmentForm = document.getElementById('assetDepartmentForm');
if (assetDepartmentForm) {
if (!assetDepartmentForm.dataset.boundSubmit) {
assetDepartmentForm.addEventListener('submit', (e) => this.handleAssetDepartmentSubmit(e));
assetDepartmentForm.dataset.boundSubmit = 'true';
}
}
document.querySelectorAll('.confirm-delete-asset-department').forEach(btn => {
if (btn.dataset.boundClick === 'true') {
return;
}
btn.addEventListener('click', () => this.confirmDeleteAssetDepartment());
btn.dataset.boundClick = 'true';
});
// Close when clicking backdrop outside modal content
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.addEventListener('click', (evt) => {
@@ -905,36 +1009,7 @@ class AccountManager {
</button>
</div>
<!-- Dashboard Stats -->
<div class="apps-stats grid grid-cols-3 gap-4 mb-6 shrink-0">
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl shadow-sm border border-outline-variant/5 flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-xl">lan</span>
</div>
<div>
<p class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider">Active</p>
<p class="text-lg font-black text-on-surface">${this.applications.filter(a => a.status === 'online').length}</p>
</div>
</div>
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl shadow-sm border border-outline-variant/5 flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-tertiary/10 flex items-center justify-center text-tertiary">
<span class="material-symbols-outlined text-xl">bolt</span>
</div>
<div>
<p class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider">Total</p>
<p class="text-lg font-black text-on-surface">${this.applications.length}</p>
</div>
</div>
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl shadow-sm border border-outline-variant/5 flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center text-secondary">
<span class="material-symbols-outlined text-xl">database</span>
</div>
<div>
<p class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider">Health</p>
<p class="text-lg font-black text-on-surface">99.9%</p>
</div>
</div>
</div>
<!-- Applications List -->
<div class="bg-surface-container-lowest rounded-xl shadow-sm border border-outline-variant/10 overflow-hidden flex flex-col flex-1 min-h-0">
@@ -1258,6 +1333,351 @@ class AccountManager {
});
}
getFilteredAssetDepartments() {
const search = String(this.assetDepartmentSearchTerm || '').toLowerCase();
const source = Array.isArray(this.assetDepartments) ? this.assetDepartments : [];
return source.filter(item => {
if (!search) {
return true;
}
const name = String(item?.DepartmentName || '').toLowerCase();
return name.includes(search);
});
}
buildAssetDepartmentsRowsHtml(departments = []) {
const canManageAssets = this.canCurrentUserManageAssets();
if (!departments.length) {
return `
<tr>
<td colspan="4" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có phòng ban nào.</td>
</tr>
`;
}
return departments.map((item, index) => {
const departmentId = Number(item?.DepartmentId);
const assetCount = Number(item?.AssetCount) || 0;
const departmentName = this.escapeHtml(item?.DepartmentName || '-');
return `
<tr class="hover:bg-slate-50/80 transition-colors">
<td class="px-4 py-3 text-sm text-slate-600">${index + 1}</td>
<td class="px-4 py-3 text-sm font-semibold text-slate-700">${departmentName}</td>
<td class="px-4 py-3 text-sm text-slate-600">${assetCount}</td>
<td class="px-4 py-3 text-right">
<div class="inline-flex items-center gap-1.5">
<button
class="p-1.5 text-slate-400 transition-colors edit-asset-department ${canManageAssets ? 'hover:text-primary' : 'opacity-40 cursor-not-allowed'}"
data-department-id="${departmentId}"
${canManageAssets ? '' : 'disabled'}
title="${canManageAssets ? 'Sửa phòng ban' : 'Chỉ xem'}"
>
<span class="material-symbols-outlined text-lg">edit</span>
</button>
<button
class="p-1.5 text-slate-400 transition-colors delete-asset-department ${canManageAssets ? 'hover:text-error' : 'opacity-40 cursor-not-allowed'}"
data-department-id="${departmentId}"
${canManageAssets ? '' : 'disabled'}
title="${canManageAssets ? 'Xóa phòng ban' : 'Chỉ xem'}"
>
<span class="material-symbols-outlined text-lg">delete</span>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
getAssetDepartmentsContent() {
const filteredDepartments = this.getFilteredAssetDepartments();
const canManageAssets = this.canCurrentUserManageAssets();
return `
<div class="asset-departments-page flex flex-col p-4 md:p-6 overflow-hidden h-full">
<div class="page-header flex items-center justify-between gap-4 mb-5 shrink-0">
<div>
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Quản Lý Phòng Ban</h1>
<p class="text-sm text-on-surface-variant">Thêm, sửa, xóa danh mục phòng ban sử dụng trong tài sản.</p>
</div>
<button
id="addAssetDepartmentBtn"
class="bg-primary text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-primary-dim' : 'opacity-50 cursor-not-allowed'}"
${canManageAssets ? '' : 'disabled'}
>
<span class="material-symbols-outlined text-base">add_home_work</span>
Thêm phòng ban
</button>
</div>
<div class="page-filters flex items-center gap-3 mb-4 shrink-0">
<div class="flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input
id="assetDepartmentSearch"
value="${this.escapeHtml(this.assetDepartmentSearchTerm)}"
class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm"
placeholder="Nhập tên phòng ban..."
>
</div>
</div>
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">STT</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Phòng ban</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Số tài sản</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Thao tác</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 asset-departments-table-body">
${this.buildAssetDepartmentsRowsHtml(filteredDepartments)}
</tbody>
</table>
</div>
<div class="px-4 py-2 border-t border-slate-200 bg-slate-50 text-xs text-slate-600">
Tổng phòng ban: <span id="assetDepartmentCount">${filteredDepartments.length}</span>
</div>
</div>
</div>
`;
}
renderAssetDepartmentsTableBody() {
const tbody = document.querySelector('.asset-departments-table-body');
if (!tbody) {
return;
}
const filteredDepartments = this.getFilteredAssetDepartments();
tbody.innerHTML = this.buildAssetDepartmentsRowsHtml(filteredDepartments);
const countElement = document.getElementById('assetDepartmentCount');
if (countElement) {
countElement.textContent = String(filteredDepartments.length);
}
this.setupAssetDepartmentActionListeners();
}
setupAssetDepartmentActionListeners() {
document.querySelectorAll('.edit-asset-department').forEach(btn => {
if (btn.dataset.boundClick === 'true') {
return;
}
btn.addEventListener('click', () => {
const departmentId = Number(btn.dataset.departmentId);
if (!Number.isFinite(departmentId)) {
return;
}
this.handleUpdateAssetDepartment(departmentId);
});
btn.dataset.boundClick = 'true';
});
document.querySelectorAll('.delete-asset-department').forEach(btn => {
if (btn.dataset.boundClick === 'true') {
return;
}
btn.addEventListener('click', () => {
const departmentId = Number(btn.dataset.departmentId);
if (!Number.isFinite(departmentId)) {
return;
}
this.handleDeleteAssetDepartment(departmentId);
});
btn.dataset.boundClick = 'true';
});
}
setupAssetDepartmentListeners() {
const searchInput = document.getElementById('assetDepartmentSearch');
if (searchInput && searchInput.dataset.boundInput !== 'true') {
searchInput.addEventListener('input', (event) => {
this.assetDepartmentSearchTerm = String(event.target.value || '').trim();
this.renderAssetDepartmentsTableBody();
});
searchInput.addEventListener('focus', () => {
searchInput.dataset.focused = 'true';
});
searchInput.addEventListener('blur', () => {
searchInput.dataset.focused = 'false';
});
searchInput.dataset.boundInput = 'true';
}
this.setupAssetDepartmentActionListeners();
}
async refreshAssetDepartmentsUI() {
await this.fetchAssetDepartments();
if (this.currentPage === 'asset-departments') {
this.renderAssetDepartmentsTableBody();
}
}
getAssetDepartmentById(departmentId) {
return this.assetDepartments.find(item => Number(item?.DepartmentId) === Number(departmentId)) || null;
}
openAssetDepartmentModal(department = null) {
const modal = document.getElementById('assetDepartmentModal');
const titleNode = document.getElementById('assetDepartmentModalTitle');
const nameInput = document.getElementById('assetDepartmentNameInput');
if (!modal || !nameInput) {
this.notifyFailure('Không mở được biểu mẫu phòng ban');
return;
}
const editing = department && Number.isFinite(Number(department.DepartmentId));
this.editingAssetDepartmentId = editing ? Number(department.DepartmentId) : undefined;
if (titleNode) {
titleNode.textContent = editing ? 'Sửa phòng ban' : 'Thêm phòng ban';
}
nameInput.value = editing ? String(department.DepartmentName || '') : '';
modal.classList.add('open');
nameInput.focus();
nameInput.select();
}
openDeleteAssetDepartmentModal(department) {
const modal = document.getElementById('deleteAssetDepartmentModal');
const nameNode = document.getElementById('deleteAssetDepartmentName');
if (!modal) {
this.notifyFailure('Không mở được hộp thoại xóa phòng ban');
return;
}
this.pendingDeleteAssetDepartmentId = Number(department?.DepartmentId);
if (nameNode) {
nameNode.textContent = String(department?.DepartmentName || '-');
}
modal.classList.add('open');
}
async handleCreateAssetDepartment() {
if (!this.ensureAssetManagePermission('thêm phòng ban')) {
return;
}
this.openAssetDepartmentModal(null);
}
async handleAssetDepartmentSubmit(event) {
event.preventDefault();
if (!this.ensureAssetManagePermission('thêm hoặc sửa phòng ban')) {
return;
}
const nameInput = document.getElementById('assetDepartmentNameInput');
const departmentName = String(nameInput?.value || '').trim();
if (!departmentName) {
this.notifyWarning('Tên phòng ban là bắt buộc');
return;
}
const isEdit = Number.isFinite(Number(this.editingAssetDepartmentId));
const endpoint = isEdit
? `${this.apiBase}/asset-departments/${this.editingAssetDepartmentId}`
: `${this.apiBase}/asset-departments`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: this.getAuthHeaders(true),
body: JSON.stringify({ departmentName })
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Lưu phòng ban thất bại');
return;
}
this.editingAssetDepartmentId = undefined;
closeAssetDepartmentModal();
this.notifySuccess(isEdit ? 'Cập nhật phòng ban thành công' : 'Thêm phòng ban thành công');
await this.refreshAssetDepartmentsUI();
await this.refreshAssetsUI();
} catch (err) {
console.error(err);
this.notifyFailure('Lưu phòng ban thất bại');
}
}
async handleUpdateAssetDepartment(departmentId) {
if (!this.ensureAssetManagePermission('sửa phòng ban')) {
return;
}
const targetDepartment = this.getAssetDepartmentById(departmentId);
if (!targetDepartment) {
this.notifyWarning('Không tìm thấy phòng ban');
return;
}
this.openAssetDepartmentModal(targetDepartment);
}
async handleDeleteAssetDepartment(departmentId) {
if (!this.ensureAssetManagePermission('xóa phòng ban')) {
return;
}
const targetDepartment = this.getAssetDepartmentById(departmentId);
if (!targetDepartment) {
this.notifyWarning('Không tìm thấy phòng ban');
return;
}
this.openDeleteAssetDepartmentModal(targetDepartment);
}
async confirmDeleteAssetDepartment() {
if (!this.ensureAssetManagePermission('xóa phòng ban')) {
return;
}
if (!Number.isFinite(Number(this.pendingDeleteAssetDepartmentId))) {
return;
}
try {
const response = await fetch(`${this.apiBase}/asset-departments/${this.pendingDeleteAssetDepartmentId}`, {
method: 'DELETE',
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Xóa phòng ban thất bại');
return;
}
this.pendingDeleteAssetDepartmentId = undefined;
closeDeleteAssetDepartmentModal();
this.notifySuccess('Xóa phòng ban thành công');
await this.refreshAssetDepartmentsUI();
await this.refreshAssetsUI();
} catch (err) {
console.error(err);
this.notifyFailure('Xóa phòng ban thất bại');
}
}
getAssetsContent() {
this.syncSelectedAssetIds();
const canManageAssets = this.canCurrentUserManageAssets();
@@ -1323,8 +1743,8 @@ class AccountManager {
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
${pageInfo.data.length > 0 ? `
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1">
<table class="w-full text-left border-collapse" style="min-width: 2400px;">
<thead class="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
<table class="w-full text-left border-collapse" style="min-width: 2400px; border-collapse: separate; border-spacing: 0;">
<thead class="sticky top-0 z-50 bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-4 py-2.5 text-center">
<input type="checkbox" id="selectAllAssetsCheckbox" class="w-4 h-4" ${allOnPageSelected ? 'checked' : ''} ${canManageAssets ? '' : 'disabled'} title="Chọn tất cả dòng trong trang hiện tại">
@@ -1349,7 +1769,7 @@ class AccountManager {
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày tạo</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người xuất</th>
<th class="sticky right-0 z-20 bg-slate-50 px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]" style="width: 140px; min-width: 140px; white-space: nowrap;">Thao tác</th>
<th class="sticky top-0 right-0 z-[100] bg-slate-50 px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]" style="z-index: 100; width: 140px; min-width: 140px; white-space: nowrap;">Thao tác</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 assets-table-body">
@@ -1385,7 +1805,7 @@ class AccountManager {
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs truncate" title="${asset.Notes || ''}">${asset.Notes || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.CreatedDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</td>
<td class="sticky right-0 z-10 bg-white group-hover:bg-slate-50/80 px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]" style="width: 140px; min-width: 140px; white-space: nowrap;">
<td class="sticky right-0 z-10 bg-white group-hover:bg-slate-50/80 px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]" style="z-index: 1; width: 140px; min-width: 140px; white-space: nowrap;">
<div class="ml-auto" style="margin-left: auto; display: flex; flex-wrap: nowrap; align-items: center; justify-content: flex-end; gap: 6px; white-space: nowrap;">
<button class="p-1.5 text-slate-400 transition-colors view-asset hover:text-slate-600" style="display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;" data-asset-id="${asset.AssetId}" title="Xem chi tiết">
<span class="material-symbols-outlined text-lg">info</span>
@@ -1469,7 +1889,7 @@ class AccountManager {
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs truncate" title="${asset.Notes || ''}">${asset.Notes || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.CreatedDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</td>
<td class="sticky right-0 z-10 bg-white group-hover:bg-slate-50/80 px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]" style="width: 140px; min-width: 140px; white-space: nowrap;">
<td class="sticky right-0 z-10 bg-white group-hover:bg-slate-50/80 px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(15,23,42,0.45)]" style="z-index: 1; width: 140px; min-width: 140px; white-space: nowrap;">
<div class="ml-auto" style="margin-left: auto; display: flex; flex-wrap: nowrap; align-items: center; justify-content: flex-end; gap: 6px; white-space: nowrap;">
<button class="p-1.5 text-slate-400 transition-colors view-asset hover:text-slate-600" style="display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;" data-asset-id="${asset.AssetId}" title="Xem chi tiết">
<span class="material-symbols-outlined text-lg">info</span>
@@ -1719,6 +2139,7 @@ class AccountManager {
const sourceAsset = asset || {};
const borrowerEntries = this.parseBorrowerEntries(sourceAsset?.Borrower);
this.editingAssetBorrowerEntries = borrowerEntries;
this.clearAssetFormValidation();
document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || '';
document.getElementById('assetNameInput').value = sourceAsset?.AssetName || '';
@@ -1728,7 +2149,7 @@ class AccountManager {
document.getElementById('assetQuantityInput').value = this.parseNonNegativeInteger(sourceAsset?.Quantity, 0);
document.getElementById('assetImportInPeriodInput').value = this.parseNonNegativeInteger(sourceAsset?.ImportInPeriod, 0);
document.getElementById('assetUnitInput').value = sourceAsset?.Unit || '';
document.getElementById('assetDepartmentInput').value = sourceAsset?.Department || '';
this.refreshAssetDepartmentOptions(sourceAsset?.Department || '');
document.getElementById('assetProjectInput').value = sourceAsset?.Project || '';
document.getElementById('assetLocationInput').value = sourceAsset?.Location || '';
this.refreshAssetCustodianOptions(sourceAsset?.Custodian || '');
@@ -1760,13 +2181,116 @@ class AccountManager {
this.populateAssetForm(null);
}
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
if (!this.users.length) {
this.fetchUsers();
}
this.setAssetCodeFieldMode(this.editingAssetId !== undefined);
this.clearAssetFormValidation();
document.getElementById('assetModal').classList.add('open');
}
setupAssetFormValidationListeners() {
const bindInput = (inputId, errorId) => {
const input = document.getElementById(inputId);
if (!input || input.dataset.boundValidation === 'true') {
return;
}
input.addEventListener('input', () => this.clearAssetFieldValidation(inputId, errorId));
input.addEventListener('change', () => this.clearAssetFieldValidation(inputId, errorId));
input.dataset.boundValidation = 'true';
};
bindInput('assetCodeInput', 'assetCodeError');
bindInput('assetNameInput', 'assetNameError');
}
clearAssetFieldValidation(inputId, errorId) {
const input = document.getElementById(inputId);
const errorNode = document.getElementById(errorId);
if (input) {
input.classList.remove('border-error/30', 'ring-2', 'ring-error/20');
input.removeAttribute('aria-invalid');
}
if (errorNode) {
errorNode.textContent = '';
errorNode.classList.add('hidden');
}
}
clearAssetFormValidation() {
this.clearAssetFieldValidation('assetCodeInput', 'assetCodeError');
this.clearAssetFieldValidation('assetNameInput', 'assetNameError');
}
setAssetFieldValidationError(inputId, errorId, message) {
const input = document.getElementById(inputId);
const errorNode = document.getElementById(errorId);
if (input) {
input.classList.add('border-error/30', 'ring-2', 'ring-error/20');
input.setAttribute('aria-invalid', 'true');
}
if (errorNode) {
errorNode.textContent = message || '';
errorNode.classList.remove('hidden');
}
}
setAssetCodeFieldMode(isEdit) {
const codeInput = document.getElementById('assetCodeInput');
const codeLabel = document.getElementById('assetCodeLabel');
const codeHint = document.getElementById('assetCodeHint');
if (codeInput) {
codeInput.required = !!isEdit;
codeInput.placeholder = isEdit ? 'Bắt buộc khi cập nhật' : 'Để trống để hệ thống tự tạo';
}
if (codeLabel) {
codeLabel.innerHTML = isEdit
? 'Mã tài sản <span class="text-red-600">*</span>'
: 'Mã tài sản';
}
if (codeHint) {
codeHint.textContent = isEdit
? 'Khi cập nhật, mã tài sản là bắt buộc.'
: 'Để trống khi thêm mới, hệ thống sẽ tự tạo mã.';
}
}
generateManualAssetCodeForCreate(payload = {}) {
const toToken = (value) => String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[đĐ]/g, 'd')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
const base = toToken(payload.model) || toToken(payload.serialNumber) || toToken(payload.assetName) || 'ASSET';
const now = new Date();
const timestamp = [
String(now.getFullYear()),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
String(now.getMilliseconds()).padStart(3, '0')
].join('');
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `AST-${base}-${timestamp}${randomSuffix}`;
}
collectAssetFormPayload() {
const quantity = this.parseNonNegativeInteger(document.getElementById('assetQuantityInput')?.value ?? 0, 0);
const importInPeriod = this.parseNonNegativeInteger(document.getElementById('assetImportInPeriodInput')?.value ?? 0, 0);
@@ -2016,13 +2540,32 @@ class AccountManager {
return;
}
const isEdit = this.editingAssetId !== undefined;
const payload = this.collectAssetFormPayload();
if (!payload.assetCode || !payload.assetName) {
this.notifyWarning('Mã tài sản và tên tài sản là bắt buộc');
this.clearAssetFormValidation();
if (!payload.assetName) {
this.setAssetFieldValidationError('assetNameInput', 'assetNameError', 'Vui lòng nhập tên tài sản.');
this.notifyWarning('Vui lòng nhập đầy đủ các trường bắt buộc.');
document.getElementById('assetNameInput')?.focus();
return;
}
const isEdit = this.editingAssetId !== undefined;
if (isEdit && !payload.assetCode) {
this.setAssetFieldValidationError('assetCodeInput', 'assetCodeError', 'Mã tài sản là bắt buộc khi cập nhật.');
this.notifyWarning('Vui lòng nhập đầy đủ các trường bắt buộc.');
document.getElementById('assetCodeInput')?.focus();
return;
}
if (!isEdit && !payload.assetCode) {
payload.assetCode = this.generateManualAssetCodeForCreate(payload);
const codeInput = document.getElementById('assetCodeInput');
if (codeInput) {
codeInput.value = payload.assetCode;
}
}
const url = isEdit ? `${this.apiBase}/assets/${this.editingAssetId}` : `${this.apiBase}/assets`;
const method = isEdit ? 'PUT' : 'POST';
@@ -2051,6 +2594,7 @@ class AccountManager {
async refreshAssetsUI() {
await this.fetchAssets();
await this.fetchAssetDepartments();
if (this.currentPage === 'assets') {
this.renderView('assets');
}
@@ -3359,6 +3903,12 @@ class AccountManager {
});
});
const addAssetDepartmentBtn = document.getElementById('addAssetDepartmentBtn');
if (addAssetDepartmentBtn && !addAssetDepartmentBtn.dataset.boundClick) {
addAssetDepartmentBtn.addEventListener('click', () => this.handleCreateAssetDepartment());
addAssetDepartmentBtn.dataset.boundClick = 'true';
}
const borrowAssetBtn = document.getElementById('borrowAssetBtn');
if (borrowAssetBtn && !borrowAssetBtn.dataset.boundClick) {
borrowAssetBtn.addEventListener('click', () => this.openBorrowAssetModal());
@@ -4575,6 +5125,20 @@ function closeBorrowAssetModal() {
}
}
function closeAssetDepartmentModal() {
const modal = document.getElementById('assetDepartmentModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeDeleteAssetDepartmentModal() {
const modal = document.getElementById('deleteAssetDepartmentModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeUserModal() {
const userModalContainer = document.getElementById('userModalContainer');
if (userModalContainer) {

View File

@@ -210,12 +210,15 @@
<form id="assetForm" class="p-6 space-y-4 overflow-y-auto">
<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">Mã tài sản</label>
<input type="text" id="assetCodeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="TS-001">
<label id="assetCodeLabel" class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Mã tài sản</label>
<input type="text" id="assetCodeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="De trong de he thong tu tao">
<p id="assetCodeHint" class="mt-1 text-xs text-slate-500">Để trống khi thêm mới, hệ thống sẽ tự tạo mã.</p>
<p id="assetCodeError" class="mt-1 text-xs font-semibold text-red-600 hidden"></p>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên tài sản</label>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên tài sản <span class="text-red-600">*</span></label>
<input type="text" id="assetNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Laptop Dell Latitude 5440">
<p id="assetNameError" class="mt-1 text-xs font-semibold text-red-600 hidden"></p>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Trạng thái</label>
@@ -256,7 +259,9 @@
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Phòng ban</label>
<input type="text" id="assetDepartmentInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="Kỹ thuật">
<select id="assetDepartmentInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
<option value="">-- Chọn phòng ban --</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Dự án</label>
@@ -377,3 +382,42 @@
</div>
</div>
</div>
<!-- Add/Edit Asset Department Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetDepartmentModal">
<div class="modal-content w-full max-w-md 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" id="assetDepartmentModalTitle">Thêm phòng ban</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetDepartmentModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="assetDepartmentForm" class="p-6 space-y-4">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên phòng ban</label>
<input type="text" id="assetDepartmentNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Ví dụ: Kỹ thuật">
</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="closeAssetDepartmentModal()">Hủy</button>
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Lưu</button>
</div>
</form>
</div>
</div>
<!-- Delete Asset Department Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="deleteAssetDepartmentModal">
<div class="modal-content w-full max-w-md 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 bg-red-50 flex items-center gap-3">
<span class="material-symbols-outlined text-red-600 text-2xl">warning</span>
<h3 class="text-base font-extrabold text-red-700">Xóa phòng ban</h3>
</div>
<div class="p-6">
<p class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa phòng ban <strong id="deleteAssetDepartmentName">-</strong>? Các tài sản đang gắn phòng ban này sẽ được để trống phòng ban.</p>
<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="closeDeleteAssetDepartmentModal()">Hủy</button>
<button type="button" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold confirm-delete-asset-department">Xóa</button>
</div>
</div>
</div>
</div>

View File

@@ -240,6 +240,10 @@
<span class="material-symbols-outlined">inventory_2</span>
<span>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>
</a>
</div>
</div>