diff --git a/backend/server.js b/backend/server.js index e203585..2d5e255 100644 --- a/backend/server.js +++ b/backend/server.js @@ -263,6 +263,10 @@ function normalizeDepartmentName(value) { return String(value || '').trim(); } +function normalizeProjectName(value) { + return String(value || '').trim(); +} + async function syncAssetDepartmentsFromInventory() { if (!pool) { return; @@ -1547,6 +1551,17 @@ async function createTables() { ) END`, + // Asset Projects Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetProjects') + BEGIN + CREATE TABLE AssetProjects ( + ProjectId INT PRIMARY KEY IDENTITY(1,1), + ProjectName NVARCHAR(150) NOT NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE() + ) + END`, + // Asset Borrow Requests Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests') BEGIN @@ -1614,6 +1629,13 @@ async function createTables() { console.error('AssetDepartments index creation error:', err.message); } + // Ensure AssetProjects indexes exist + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetProjects_ProjectName') CREATE UNIQUE INDEX UX_AssetProjects_ProjectName ON AssetProjects(ProjectName);`); + } catch (err) { + console.error('AssetProjects 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);`); @@ -3058,6 +3080,216 @@ app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) = } }); +// ========================================== +// API ROUTES - Asset Projects +// ========================================== + +app.get('/api/asset-projects', async (req, res) => { + try { + const result = await pool.request().query(` + SELECT + p.ProjectId, + p.ProjectName, + p.CreatedDate, + p.UpdatedDate, + COUNT(ai.AssetId) AS AssetCount + FROM AssetProjects p + LEFT JOIN AssetInventory ai + ON LOWER(LTRIM(RTRIM(ai.Project))) = LOWER(LTRIM(RTRIM(p.ProjectName))) + GROUP BY p.ProjectId, p.ProjectName, p.CreatedDate, p.UpdatedDate + ORDER BY p.ProjectName ASC + `); + + res.json({ success: true, data: result.recordset }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/asset-projects', requireAssetOrAdmin, async (req, res) => { + try { + const projectName = normalizeProjectName(req.body?.projectName); + if (!projectName) { + return res.status(400).json({ success: false, message: 'Ten du an la bat buoc' }); + } + + const existed = await pool.request() + .input('projectName', sql.NVarChar, projectName) + .query(` + SELECT TOP 1 ProjectId + FROM AssetProjects + WHERE LOWER(LTRIM(RTRIM(ProjectName))) = LOWER(@projectName) + `); + + if (existed.recordset.length > 0) { + return res.status(409).json({ success: false, message: 'Du an da ton tai' }); + } + + const inserted = await pool.request() + .input('projectName', sql.NVarChar, projectName) + .query(` + INSERT INTO AssetProjects (ProjectName) + VALUES (@projectName); + SELECT SCOPE_IDENTITY() AS ProjectId; + `); + + res.json({ + success: true, + message: 'Da them du an', + projectId: inserted.recordset[0]?.ProjectId + }); + } catch (err) { + if (String(err.message || '').includes('UX_AssetProjects_ProjectName')) { + return res.status(409).json({ success: false, message: 'Du an da ton tai' }); + } + + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.put('/api/asset-projects/:id', requireAssetOrAdmin, async (req, res) => { + try { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, message: 'Ma du an khong hop le' }); + } + + const projectName = normalizeProjectName(req.body?.projectName); + if (!projectName) { + return res.status(400).json({ success: false, message: 'Ten du an la bat buoc' }); + } + + const currentResult = await pool.request() + .input('projectId', sql.Int, projectId) + .query(` + SELECT ProjectId, ProjectName + FROM AssetProjects + WHERE ProjectId = @projectId + `); + + if (currentResult.recordset.length === 0) { + return res.status(404).json({ success: false, message: 'Khong tim thay du an' }); + } + + const currentProject = currentResult.recordset[0]; + const currentName = String(currentProject.ProjectName || '').trim(); + + if (currentName.toLowerCase() === projectName.toLowerCase()) { + return res.json({ success: true, message: 'Da cap nhat du an' }); + } + + const duplicated = await pool.request() + .input('projectName', sql.NVarChar, projectName) + .input('projectId', sql.Int, projectId) + .query(` + SELECT TOP 1 ProjectId + FROM AssetProjects + WHERE ProjectId <> @projectId + AND LOWER(LTRIM(RTRIM(ProjectName))) = LOWER(@projectName) + `); + + if (duplicated.recordset.length > 0) { + return res.status(409).json({ success: false, message: 'Du an da ton tai' }); + } + + const transaction = new sql.Transaction(pool); + await transaction.begin(); + + try { + await new sql.Request(transaction) + .input('projectId', sql.Int, projectId) + .input('projectName', sql.NVarChar, projectName) + .query(` + UPDATE AssetProjects + SET ProjectName = @projectName, + UpdatedDate = GETDATE() + WHERE ProjectId = @projectId + `); + + await new sql.Request(transaction) + .input('oldProjectName', sql.NVarChar, currentName) + .input('newProjectName', sql.NVarChar, projectName) + .query(` + UPDATE AssetInventory + SET Project = @newProjectName, + UpdatedDate = GETDATE() + WHERE LOWER(LTRIM(RTRIM(Project))) = LOWER(@oldProjectName) + `); + + await transaction.commit(); + res.json({ success: true, message: 'Da cap nhat du an' }); + } 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_AssetProjects_ProjectName')) { + return res.status(409).json({ success: false, message: 'Du an da ton tai' }); + } + + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.delete('/api/asset-projects/:id', requireAssetOrAdmin, async (req, res) => { + try { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, message: 'Ma du an khong hop le' }); + } + + const currentResult = await pool.request() + .input('projectId', sql.Int, projectId) + .query(` + SELECT ProjectId, ProjectName + FROM AssetProjects + WHERE ProjectId = @projectId + `); + + if (currentResult.recordset.length === 0) { + return res.status(404).json({ success: false, message: 'Khong tim thay du an' }); + } + + const projectName = String(currentResult.recordset[0].ProjectName || '').trim(); + const transaction = new sql.Transaction(pool); + await transaction.begin(); + + try { + await new sql.Request(transaction) + .input('projectName', sql.NVarChar, projectName) + .query(` + UPDATE AssetInventory + SET Project = NULL, + UpdatedDate = GETDATE() + WHERE LOWER(LTRIM(RTRIM(Project))) = LOWER(@projectName) + `); + + await new sql.Request(transaction) + .input('projectId', sql.Int, projectId) + .query(` + DELETE FROM AssetProjects + WHERE ProjectId = @projectId + `); + + await transaction.commit(); + res.json({ success: true, message: 'Da xoa du an' }); + } 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 // ========================================== diff --git a/database/setup.sql b/database/setup.sql index 90eb7ae..cb8a1ce 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -154,7 +154,21 @@ WHERE NOT EXISTS ( ); -- =========================================== --- 6. CREATE ASSET BORROW REQUESTS TABLE +-- 6. CREATE ASSET PROJECTS TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetProjects') +BEGIN + CREATE TABLE AssetProjects ( + ProjectId INT PRIMARY KEY IDENTITY(1,1), + ProjectName NVARCHAR(150) NOT NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE() + ); + PRINT 'Table AssetProjects created successfully.'; +END + +-- =========================================== +-- 7. CREATE ASSET BORROW REQUESTS TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests') BEGIN @@ -246,7 +260,7 @@ UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved'); -- =========================================== --- 7. CREATE AUDIT LOG TABLE +-- 8. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -265,7 +279,7 @@ BEGIN END -- =========================================== --- 8. CREATE INDEXES +-- 9. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -302,6 +316,11 @@ BEGIN CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetProjects_ProjectName') +BEGIN + CREATE UNIQUE INDEX UX_AssetProjects_ProjectName ON AssetProjects(ProjectName); +END + IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_AssetId') BEGIN CREATE INDEX IX_AssetBorrowRequests_AssetId ON AssetBorrowRequests(AssetId); @@ -325,7 +344,7 @@ END PRINT 'Indexes created successfully.'; -- =========================================== --- 9. INSERT INITIAL DATA +-- 10. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists @@ -349,7 +368,7 @@ BEGIN END -- =========================================== --- 10. DISPLAY DATABASE INFORMATION +-- 11. DISPLAY DATABASE INFORMATION -- =========================================== PRINT ''; PRINT '========================================'; diff --git a/public/js/app.js b/public/js/app.js index be55593..5b3d780 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -46,6 +46,8 @@ class AccountManager { this.assetBorrowProductLoading = false; this.assetDepartments = []; this.assetDepartmentSearchTerm = ''; + this.assetProjects = []; + this.assetProjectSearchTerm = ''; this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; @@ -56,6 +58,8 @@ class AccountManager { this.pendingBorrowAssetId = undefined; this.editingAssetDepartmentId = undefined; this.pendingDeleteAssetDepartmentId = undefined; + this.editingAssetProjectId = undefined; + this.pendingDeleteAssetProjectId = undefined; this.assetBorrowRequestType = 'borrow'; this.pendingAssetRequestRejectId = undefined; this.assetBorrowAutoRefreshTimer = undefined; @@ -174,6 +178,7 @@ class AccountManager { await this.fetchAssets(); await this.fetchAssetBorrows(); await this.fetchAssetDepartments(); + await this.fetchAssetProjects(); if (this.canCurrentUserManageAssets()) { await this.fetchUsers(); @@ -230,6 +235,10 @@ class AccountManager { mainContent.innerHTML = this.getAssetDepartmentsContent(); this.setupAssetDepartmentListeners(); this.setupAddButtonListeners(); + } else if (page === 'asset-projects') { + mainContent.innerHTML = this.getAssetProjectsContent(); + this.setupAssetProjectListeners(); + this.setupAddButtonListeners(); } else if (page === 'accounts') { mainContent.innerHTML = this.getAccountsContent(); this.setupAccountRowListeners(); @@ -784,6 +793,59 @@ class AccountManager { } } + getUniqueAssetProjectNames() { + const rows = Array.isArray(this.assetProjects) ? this.assetProjects : []; + const seen = new Set(); + return rows + .map(item => String(item?.ProjectName || '').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' })); + } + + refreshAssetProjectOptions(selectedValue = '') { + const select = document.getElementById('assetProjectInput'); + if (!select) { + return; + } + + const normalizedSelected = String(selectedValue || select.value || '').trim(); + const projectNames = this.getUniqueAssetProjectNames(); + select.innerHTML = ''; + + const emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = '-- Chọn dự án --'; + select.appendChild(emptyOption); + + let hasSelected = false; + projectNames.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`); @@ -838,6 +900,21 @@ class AccountManager { } } + async fetchAssetProjects() { + try { + const res = await fetch(`${this.apiBase}/asset-projects`); + const data = await res.json(); + if (data.success) { + this.assetProjects = Array.isArray(data.data) ? data.data : []; + this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); + } else { + console.error('Load asset projects failed:', data.message); + } + } catch (err) { + console.error('Fetch asset projects error:', err); + } + } + async fetchRoles() { try { const res = await fetch(`${this.apiBase}/roles`); @@ -874,6 +951,7 @@ class AccountManager { this.setupAddButtonListeners(); this.setupFilters(); this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); + this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); } catch (error) { console.error('Lỗi load modals:', error); } @@ -885,6 +963,7 @@ class AccountManager { const assetSearch = document.getElementById('assetSearch'); const assetBorrowSearch = document.getElementById('assetBorrowSearch'); const assetDepartmentSearch = document.getElementById('assetDepartmentSearch'); + const assetProjectSearch = document.getElementById('assetProjectSearch'); if (accountSearch && accountSearch.dataset.focused === 'true') { const pos = accountSearch.selectionStart || accountSearch.value.length; @@ -915,6 +994,12 @@ class AccountManager { assetDepartmentSearch.focus(); assetDepartmentSearch.setSelectionRange(pos, pos); } + + if (assetProjectSearch && assetProjectSearch.dataset.focused === 'true') { + const pos = assetProjectSearch.selectionStart || assetProjectSearch.value.length; + assetProjectSearch.focus(); + assetProjectSearch.setSelectionRange(pos, pos); + } } setupEventListeners() { @@ -981,6 +1066,7 @@ class AccountManager { assetForm.dataset.boundSubmit = 'true'; } this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); + this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); this.setupAssetStockListeners(); this.setupAssetFormValidationListeners(); } @@ -1043,6 +1129,23 @@ class AccountManager { btn.dataset.boundClick = 'true'; }); + const assetProjectForm = document.getElementById('assetProjectForm'); + if (assetProjectForm) { + if (!assetProjectForm.dataset.boundSubmit) { + assetProjectForm.addEventListener('submit', (e) => this.handleAssetProjectSubmit(e)); + assetProjectForm.dataset.boundSubmit = 'true'; + } + } + + document.querySelectorAll('.confirm-delete-asset-project').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => this.confirmDeleteAssetProject()); + btn.dataset.boundClick = 'true'; + }); + // Close when clicking backdrop outside modal content document.querySelectorAll('.modal-backdrop').forEach(backdrop => { backdrop.addEventListener('click', (evt) => { @@ -2160,6 +2263,351 @@ class AccountManager { } } + getFilteredAssetProjects() { + const search = String(this.assetProjectSearchTerm || '').toLowerCase(); + const source = Array.isArray(this.assetProjects) ? this.assetProjects : []; + + return source.filter(item => { + if (!search) { + return true; + } + + const name = String(item?.ProjectName || '').toLowerCase(); + return name.includes(search); + }); + } + + buildAssetProjectsRowsHtml(projects = []) { + const canManageAssets = this.canCurrentUserManageAssets(); + if (!projects.length) { + return ` + + Chưa có dự án nào. + + `; + } + + return projects.map((item, index) => { + const projectId = Number(item?.ProjectId); + const assetCount = Number(item?.AssetCount) || 0; + const projectName = this.escapeHtml(item?.ProjectName || '-'); + + return ` + + ${index + 1} + ${projectName} + ${assetCount} + +
+ + +
+ + + `; + }).join(''); + } + + getAssetProjectsContent() { + const filteredProjects = this.getFilteredAssetProjects(); + const canManageAssets = this.canCurrentUserManageAssets(); + + return ` +
+ + +
+
+ Tìm kiếm + +
+
+ +
+
+ + + + + + + + + + + ${this.buildAssetProjectsRowsHtml(filteredProjects)} + +
STTDự ánSd tài sảnThao tác
+
+
+ Tổng dự án: ${filteredProjects.length} +
+
+
+ `; + } + + renderAssetProjectsTableBody() { + const tbody = document.querySelector('.asset-projects-table-body'); + if (!tbody) { + return; + } + + const filteredProjects = this.getFilteredAssetProjects(); + tbody.innerHTML = this.buildAssetProjectsRowsHtml(filteredProjects); + + const countElement = document.getElementById('assetProjectCount'); + if (countElement) { + countElement.textContent = String(filteredProjects.length); + } + + this.setupAssetProjectActionListeners(); + } + + setupAssetProjectActionListeners() { + document.querySelectorAll('.edit-asset-project').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => { + const projectId = Number(btn.dataset.projectId); + if (!Number.isFinite(projectId)) { + return; + } + this.handleUpdateAssetProject(projectId); + }); + + btn.dataset.boundClick = 'true'; + }); + + document.querySelectorAll('.delete-asset-project').forEach(btn => { + if (btn.dataset.boundClick === 'true') { + return; + } + + btn.addEventListener('click', () => { + const projectId = Number(btn.dataset.projectId); + if (!Number.isFinite(projectId)) { + return; + } + this.handleDeleteAssetProject(projectId); + }); + + btn.dataset.boundClick = 'true'; + }); + } + + setupAssetProjectListeners() { + const searchInput = document.getElementById('assetProjectSearch'); + if (searchInput && searchInput.dataset.boundInput !== 'true') { + searchInput.addEventListener('input', (event) => { + this.assetProjectSearchTerm = String(event.target.value || '').trim(); + this.renderAssetProjectsTableBody(); + }); + searchInput.addEventListener('focus', () => { + searchInput.dataset.focused = 'true'; + }); + searchInput.addEventListener('blur', () => { + searchInput.dataset.focused = 'false'; + }); + searchInput.dataset.boundInput = 'true'; + } + + this.setupAssetProjectActionListeners(); + } + + async refreshAssetProjectsUI() { + await this.fetchAssetProjects(); + if (this.currentPage === 'asset-projects') { + this.renderAssetProjectsTableBody(); + } + } + + getAssetProjectById(projectId) { + return this.assetProjects.find(item => Number(item?.ProjectId) === Number(projectId)) || null; + } + + openAssetProjectModal(project = null) { + const modal = document.getElementById('assetProjectModal'); + const titleNode = document.getElementById('assetProjectModalTitle'); + const nameInput = document.getElementById('assetProjectNameInput'); + if (!modal || !nameInput) { + this.notifyFailure('Không mở được biểu mẫu dự án'); + return; + } + + const editing = project && Number.isFinite(Number(project.ProjectId)); + this.editingAssetProjectId = editing ? Number(project.ProjectId) : undefined; + + if (titleNode) { + titleNode.textContent = editing ? 'Sửa dự án' : 'Thêm dự án'; + } + nameInput.value = editing ? String(project.ProjectName || '') : ''; + modal.classList.add('open'); + nameInput.focus(); + nameInput.select(); + } + + openDeleteAssetProjectModal(project) { + const modal = document.getElementById('deleteAssetProjectModal'); + const nameNode = document.getElementById('deleteAssetProjectName'); + if (!modal) { + this.notifyFailure('Không mở được hộp thoại xóa dự án'); + return; + } + + this.pendingDeleteAssetProjectId = Number(project?.ProjectId); + if (nameNode) { + nameNode.textContent = String(project?.ProjectName || '-'); + } + modal.classList.add('open'); + } + + async handleCreateAssetProject() { + if (!this.ensureAssetManagePermission('thêm dự án')) { + return; + } + + this.openAssetProjectModal(null); + } + + async handleAssetProjectSubmit(event) { + event.preventDefault(); + + if (!this.ensureAssetManagePermission('thêm hoặc sửa dự án')) { + return; + } + + const nameInput = document.getElementById('assetProjectNameInput'); + const projectName = String(nameInput?.value || '').trim(); + if (!projectName) { + this.notifyWarning('Tên dự án là bắt buộc'); + return; + } + + const isEdit = Number.isFinite(Number(this.editingAssetProjectId)); + const endpoint = isEdit + ? `${this.apiBase}/asset-projects/${this.editingAssetProjectId}` + : `${this.apiBase}/asset-projects`; + const method = isEdit ? 'PUT' : 'POST'; + + try { + const response = await fetch(endpoint, { + method, + headers: this.getAuthHeaders(true), + body: JSON.stringify({ projectName }) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + this.notifyFailure(data.message || 'Lưu dự án thất bại'); + return; + } + + this.editingAssetProjectId = undefined; + closeAssetProjectModal(); + this.notifySuccess(isEdit ? 'Cập nhật dự án thành công' : 'Thêm dự án thành công'); + await this.refreshAssetProjectsUI(); + await this.refreshAssetsUI(); + } catch (err) { + console.error(err); + this.notifyFailure('Lưu dự án thất bại'); + } + } + + async handleUpdateAssetProject(projectId) { + if (!this.ensureAssetManagePermission('sửa dự án')) { + return; + } + + const targetProject = this.getAssetProjectById(projectId); + if (!targetProject) { + this.notifyWarning('Không tìm thấy dự án'); + return; + } + + this.openAssetProjectModal(targetProject); + } + + async handleDeleteAssetProject(projectId) { + if (!this.ensureAssetManagePermission('xóa dự án')) { + return; + } + + const targetProject = this.getAssetProjectById(projectId); + if (!targetProject) { + this.notifyWarning('Không tìm thấy dự án'); + return; + } + + this.openDeleteAssetProjectModal(targetProject); + } + + async confirmDeleteAssetProject() { + if (!this.ensureAssetManagePermission('xóa dự án')) { + return; + } + + if (!Number.isFinite(Number(this.pendingDeleteAssetProjectId))) { + return; + } + + try { + const response = await fetch(`${this.apiBase}/asset-projects/${this.pendingDeleteAssetProjectId}`, { + method: 'DELETE', + headers: this.getAuthHeaders(false) + }); + const data = await response.json(); + + if (!response.ok || !data.success) { + this.notifyFailure(data.message || 'Xóa dự án thất bại'); + return; + } + + this.pendingDeleteAssetProjectId = undefined; + closeDeleteAssetProjectModal(); + this.notifySuccess('Xóa dự án thành công'); + await this.refreshAssetProjectsUI(); + await this.refreshAssetsUI(); + } catch (err) { + console.error(err); + this.notifyFailure('Xóa dự án thất bại'); + } + } + buildAssetBorrowRowHtml(item, rowNumber) { const assetName = item.AssetName || '-'; const assetCode = item.AssetCode ? `
${this.escapeHtml(item.AssetCode)}
` : ''; @@ -3351,7 +3799,7 @@ class AccountManager { document.getElementById('assetImportInPeriodInput').value = this.parseNonNegativeInteger(sourceAsset?.ImportInPeriod, 0); document.getElementById('assetUnitInput').value = sourceAsset?.Unit || ''; this.refreshAssetDepartmentOptions(sourceAsset?.Department || ''); - document.getElementById('assetProjectInput').value = sourceAsset?.Project || ''; + this.refreshAssetProjectOptions(sourceAsset?.Project || ''); document.getElementById('assetLocationInput').value = sourceAsset?.Location || ''; this.refreshAssetCustodianOptions(sourceAsset?.Custodian || ''); @@ -3383,6 +3831,7 @@ class AccountManager { } this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); + this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); if (!this.users.length) { this.fetchUsers(); @@ -3796,6 +4245,7 @@ class AccountManager { async refreshAssetsUI() { await this.fetchAssets(); await this.fetchAssetDepartments(); + await this.fetchAssetProjects(); if (this.currentPage === 'assets') { this.renderView('assets'); } @@ -5110,6 +5560,12 @@ class AccountManager { addAssetDepartmentBtn.dataset.boundClick = 'true'; } + const addAssetProjectBtn = document.getElementById('addAssetProjectBtn'); + if (addAssetProjectBtn && !addAssetProjectBtn.dataset.boundClick) { + addAssetProjectBtn.addEventListener('click', () => this.handleCreateAssetProject()); + addAssetProjectBtn.dataset.boundClick = 'true'; + } + const addAssetBorrowRequestBtn = document.getElementById('addAssetBorrowRequestBtn'); if (addAssetBorrowRequestBtn && !addAssetBorrowRequestBtn.dataset.boundClick) { addAssetBorrowRequestBtn.addEventListener('click', () => this.openAssetBorrowRequestModal('borrow')); @@ -6406,6 +6862,20 @@ function closeDeleteAssetDepartmentModal() { } } +function closeAssetProjectModal() { + const modal = document.getElementById('assetProjectModal'); + if (modal) { + modal.classList.remove('open'); + } +} + +function closeDeleteAssetProjectModal() { + const modal = document.getElementById('deleteAssetProjectModal'); + if (modal) { + modal.classList.remove('open'); + } +} + function closeUserModal() { const userModalContainer = document.getElementById('userModalContainer'); if (userModalContainer) { diff --git a/public/modals.html b/public/modals.html index e47c28f..85451dc 100644 --- a/public/modals.html +++ b/public/modals.html @@ -265,7 +265,9 @@
- +
@@ -566,3 +568,42 @@
+ + + + + + diff --git a/public/pages/index.html b/public/pages/index.html index 5b33159..713d17d 100644 --- a/public/pages/index.html +++ b/public/pages/index.html @@ -248,6 +248,10 @@ apartment Phòng Ban + + workspaces + Dự án +