add dự án

This commit is contained in:
2026-04-25 21:34:17 +07:00
parent 8bd67200ce
commit 197186eac8
5 changed files with 773 additions and 7 deletions

View File

@@ -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 `
<tr>
<td colspan="4" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dự án nào.</td>
</tr>
`;
}
return projects.map((item, index) => {
const projectId = Number(item?.ProjectId);
const assetCount = Number(item?.AssetCount) || 0;
const projectName = this.escapeHtml(item?.ProjectName || '-');
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">${projectName}</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-project ${canManageAssets ? 'hover:text-primary' : 'opacity-40 cursor-not-allowed'}"
data-project-id="${projectId}"
${canManageAssets ? '' : 'disabled'}
title="${canManageAssets ? 'Sửa dự án' : 'Chỉ xem'}"
>
<span class="material-symbols-outlined text-lg">edit</span>
</button>
<button
class="p-1.5 text-slate-400 transition-colors delete-asset-project ${canManageAssets ? 'hover:text-error' : 'opacity-40 cursor-not-allowed'}"
data-project-id="${projectId}"
${canManageAssets ? '' : 'disabled'}
title="${canManageAssets ? 'Xóa dự án' : 'Chỉ xem'}"
>
<span class="material-symbols-outlined text-lg">delete</span>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
getAssetProjectsContent() {
const filteredProjects = this.getFilteredAssetProjects();
const canManageAssets = this.canCurrentUserManageAssets();
return `
<div class="asset-projects-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ý Dự Án</h1>
<p class="text-sm text-on-surface-variant">Thêm, sửa, xóa danh mục dự án sử dụng trong tài sản.</p>
</div>
<button
id="addAssetProjectBtn"
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">workspaces</span>
Thêm dự án
</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="assetProjectSearch"
value="${this.escapeHtml(this.assetProjectSearchTerm)}"
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 dự án..."
>
</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">Dự án</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Sd 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-projects-table-body">
${this.buildAssetProjectsRowsHtml(filteredProjects)}
</tbody>
</table>
</div>
<div class="px-4 py-2 border-t border-slate-200 bg-slate-50 text-xs text-slate-600">
Tổng dự án: <span id="assetProjectCount">${filteredProjects.length}</span>
</div>
</div>
</div>
`;
}
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 ? `<div class="text-[11px] text-slate-500">${this.escapeHtml(item.AssetCode)}</div>` : '';
@@ -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) {

View File

@@ -265,7 +265,9 @@
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Dự án</label>
<input type="text" id="assetProjectInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="AGV / SS demo">
<select id="assetProjectInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3">
<option value="">-- Chọn dự án --</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Vị trí</label>
@@ -566,3 +568,42 @@
</div>
</div>
</div>
<!-- Add/Edit Asset Project Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetProjectModal">
<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="assetProjectModalTitle">Thêm dự án</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetProjectModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="assetProjectForm" class="p-6 space-y-4">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tên dự án</label>
<input type="text" id="assetProjectNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required placeholder="Ví dụ: AGV">
</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="closeAssetProjectModal()">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 Project Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="deleteAssetProjectModal">
<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 dự án</h3>
</div>
<div class="p-6">
<p class="text-sm text-slate-600 mb-6">Bạn có chắc muốn xóa dự án <strong id="deleteAssetProjectName">-</strong>? Các tài sản đang gắn dự án này sẽ được để trống dự án.</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="closeDeleteAssetProjectModal()">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-project">Xóa</button>
</div>
</div>
</div>
</div>

View File

@@ -248,6 +248,10 @@
<span class="material-symbols-outlined">apartment</span>
<span> Phòng Ban</span>
</a>
<a href="#asset-projects" data-nav="asset-projects" 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">workspaces</span>
<span>Dự án</span>
</a>
</div>
</div>