// VaultSentinel - Account Management Application
// Main JavaScript functionality
class AccountManager {
constructor() {
// Check if user is logged in
const currentUser = this.loadFromStorage('currentUser');
if (!currentUser) {
window.location.href = '../pages/login.html';
return;
}
this.currentUser = currentUser;
this.accounts = [];
this.applications = [];
this.users = [];
this.assets = [];
this.roles = [];
this.accountPage = 1;
this.accountPageSize = 9;
this.appPage = 1;
this.appPageSize = 9;
this.userPage = 1;
this.userPageSize = 9;
this.assetPage = 1;
this.assetPageSize = 10;
this.apiBase = '/api';
this.currentPage = 'dashboard';
this.accountSearchTerm = '';
this.applicationSearchTerm = '';
this.accountServiceFilter = '';
this.userSearchTerm = '';
this.userRoleFilter = '';
this.assetSearchTerm = '';
this.assetStatusFilter = '';
this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900;
this.boundResizeHandler = null;
this.configureNotifications();
this.initPromise = this.init();
this.pendingAccountAppId = undefined;
this.editingAssetBorrowerEntries = [];
this.pendingBorrowAssetId = undefined;
}
configureNotifications() {
if (window.Notiflix?.Notify) {
Notiflix.Notify.init({
position: 'right-top',
timeout: 2500,
clickToClose: true,
pauseOnHover: true,
distance: '12px',
fontSize: '14px'
});
}
}
notifySuccess(message) {
if (window.Notiflix?.Notify) {
Notiflix.Notify.success(message);
} else {
alert(message);
}
}
notifyFailure(message) {
if (window.Notiflix?.Notify) {
Notiflix.Notify.failure(message);
} else {
alert(message);
}
}
notifyWarning(message) {
if (window.Notiflix?.Notify) {
Notiflix.Notify.warning(message);
} else {
alert(message);
}
}
getUserId() {
const u = this.currentUser;
const detected = u?.UserId ?? u?.userId ?? u?.id ?? u?.ID ?? u?.userid ?? u?.user_id ?? u?.user?.UserId ?? u?.user?.userId;
// Fallback: if only username/role exist (no id), use default admin id = 1
return detected ?? 1;
}
getCurrentUserRoleRaw() {
return this.currentUser?.Role
?? this.currentUser?.role
?? this.currentUser?.RoleName
?? this.currentUser?.user?.Role
?? this.currentUser?.user?.role
?? '';
}
getCurrentUserRole() {
return String(this.getCurrentUserRoleRaw() || '').trim().toLowerCase();
}
getCurrentUserDisplayName() {
const fullName = String(
this.currentUser?.FullName
?? this.currentUser?.fullname
?? this.currentUser?.user?.FullName
?? this.currentUser?.user?.fullname
?? ''
).trim();
const username = String(
this.currentUser?.Username
?? this.currentUser?.username
?? this.currentUser?.user?.Username
?? this.currentUser?.user?.username
?? ''
).trim();
return fullName || username || 'Unknown';
}
isCurrentUserAdmin() {
return this.getCurrentUserRole() === 'admin';
}
canCurrentUserManageAssets() {
const role = this.getCurrentUserRole();
return role === 'admin' || role === 'asset';
}
ensureAssetManagePermission(actionLabel = 'thuc hien thao tac nay') {
if (this.canCurrentUserManageAssets()) {
return true;
}
this.notifyWarning(`Ban chi co quyen xem tai san. Chi role Asset/Admin moi duoc ${actionLabel}.`);
return false;
}
getAuthHeaders(includeJson = false) {
const headers = {
'x-user-id': String(this.getUserId()),
'x-user-role': this.getCurrentUserRole()
};
if (includeJson) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
async init() {
await this.fetchApplications();
await this.fetchAccounts();
await this.fetchAssets();
if (this.canCurrentUserManageAssets()) {
await this.fetchUsers();
}
// Check if user is admin and fetch roles
if (this.isCurrentUserAdmin()) {
await this.fetchRoles();
// Show Users menu
const usersNav = document.getElementById('usersNav');
if (usersNav) usersNav.style.display = '';
const usersSection = document.getElementById('usersSection');
if (usersSection) usersSection.style.display = '';
}
this.setupEventListeners();
this.setupResponsiveShell();
this.loadModals(); // Load modals từ file riêng
// Single-page navigation based on hash
this.handleRoute(location.hash || '#dashboard');
window.addEventListener('hashchange', () => this.handleRoute(location.hash));
}
handleRoute(hash) {
const route = (hash || '#dashboard').replace('#', '') || 'dashboard';
if (this.isMobileViewport()) {
this.closeMobileNav();
}
this.renderView(route);
}
renderView(page) {
this.currentPage = page;
const mainContent = document.getElementById('mainContent');
if (!mainContent) return;
if (page === 'applications') {
mainContent.innerHTML = this.getApplicationsContent();
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
this.setupAppPagerListeners();
} else if (page === 'assets') {
mainContent.innerHTML = this.getAssetsContent();
this.setupAssetRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
this.setupAssetPagerListeners();
} else if (page === 'accounts') {
mainContent.innerHTML = this.getAccountsContent();
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
this.setupAccountPagerListeners();
} else if (page === 'users') {
// Check if user is admin
if (!this.isCurrentUserAdmin()) {
mainContent.innerHTML = this.renderDashboard();
} else {
mainContent.innerHTML = this.getUsersContent();
this.setupUsersRowListeners();
this.setupAddButtonListeners();
this.setupUsersPagerListeners();
}
} else {
mainContent.innerHTML = this.renderDashboard();
}
this.restoreSearchFocus();
this.setActiveNav(page);
}
setActiveNav(page) {
document.querySelectorAll('[data-nav]').forEach(link => {
const isActive = link.dataset.nav === page;
link.classList.toggle('border-l-4', isActive);
link.classList.toggle('border-blue-600', isActive);
link.classList.toggle('bg-slate-200/80', isActive);
link.classList.toggle('dark:bg-slate-800', isActive);
link.classList.toggle('text-slate-900', isActive);
link.classList.toggle('dark:text-slate-50', isActive);
link.classList.toggle('font-bold', isActive);
});
}
isMobileViewport() {
return window.matchMedia(`(max-width: ${this.mobileBreakpoint}px)`).matches;
}
setupResponsiveShell() {
const menuBtn = document.getElementById('mobileMenuBtn');
const backdrop = document.getElementById('sidebarBackdrop');
if (menuBtn && !menuBtn.dataset.boundClick) {
menuBtn.addEventListener('click', () => this.toggleMobileNav());
menuBtn.dataset.boundClick = 'true';
}
if (backdrop && !backdrop.dataset.boundClick) {
backdrop.addEventListener('click', () => this.closeMobileNav());
backdrop.dataset.boundClick = 'true';
}
document.querySelectorAll('[data-nav]').forEach(link => {
if (!link.dataset.boundMobileClose) {
link.addEventListener('click', () => {
if (this.isMobileViewport()) {
this.closeMobileNav();
}
});
link.dataset.boundMobileClose = 'true';
}
});
if (!this.boundResizeHandler) {
this.boundResizeHandler = () => {
if (!this.isMobileViewport()) {
this.closeMobileNav();
}
};
window.addEventListener('resize', this.boundResizeHandler);
}
if (!this.isMobileViewport()) {
this.closeMobileNav();
}
}
toggleMobileNav() {
if (document.body.classList.contains('mobile-nav-open')) {
this.closeMobileNav();
return;
}
this.openMobileNav();
}
openMobileNav() {
if (!this.isMobileViewport()) return;
document.body.classList.add('mobile-nav-open');
const menuBtn = document.getElementById('mobileMenuBtn');
if (menuBtn) {
menuBtn.setAttribute('aria-expanded', 'true');
}
}
closeMobileNav() {
document.body.classList.remove('mobile-nav-open');
const menuBtn = document.getElementById('mobileMenuBtn');
if (menuBtn) {
menuBtn.setAttribute('aria-expanded', 'false');
}
}
async fetchApplications() {
const res = await fetch(`${this.apiBase}/applications`);
const data = await res.json();
if (data.success) {
this.applications = data.data;
} else {
console.error('Load applications failed:', data.message);
}
}
async fetchAccounts() {
try {
const res = await fetch(`${this.apiBase}/accounts/all`);
const data = await res.json();
if (data.success) {
this.accounts = data.data;
} else {
console.error('Load accounts failed:', data.message);
}
} catch (err) {
console.error('Fetch accounts error:', err);
}
}
async fetchUsers() {
try {
const res = await fetch(`${this.apiBase}/users`);
const data = await res.json();
if (data.success) {
this.users = data.data;
this.refreshAssetCustodianOptions(document.getElementById('assetCustodianInput')?.value || '');
this.refreshBorrowAssetUserOptions(document.getElementById('borrowAssetUserInput')?.value || '');
} else {
console.error('Load users failed:', data.message);
}
} catch (err) {
console.error('Fetch users error:', err);
}
}
getUserDisplayName(user) {
const fullname = String(user?.FullName || user?.fullname || '').trim();
const username = String(user?.Username || user?.username || '').trim();
return fullname || username || '';
}
getUniqueUserDisplayNames() {
const users = Array.isArray(this.users) ? this.users : [];
const seenNames = new Set();
return users
.map(user => this.getUserDisplayName(user))
.filter(name => {
if (!name) return false;
const key = name.toLowerCase();
if (seenNames.has(key)) return false;
seenNames.add(key);
return true;
})
.sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' }));
}
populateUserSelectOptions(selectId, {
selectedValue = '',
emptyLabel = '-- Chon --'
} = {}) {
const select = document.getElementById(selectId);
if (!select) {
return;
}
const normalizedSelected = String(selectedValue || '').trim();
const userNames = this.getUniqueUserDisplayNames();
select.innerHTML = '';
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = emptyLabel;
select.appendChild(emptyOption);
let hasSelected = false;
userNames.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 = '';
}
}
refreshAssetCustodianOptions(selectedValue = '') {
this.populateUserSelectOptions('assetCustodianInput', {
selectedValue,
emptyLabel: '-- Chon nguoi phu trach --'
});
}
refreshBorrowAssetUserOptions(selectedValue = '') {
this.populateUserSelectOptions('borrowAssetUserInput', {
selectedValue,
emptyLabel: '-- Chon nguoi muon --'
});
}
async fetchAssets() {
try {
const res = await fetch(`${this.apiBase}/assets`);
const data = await res.json();
if (data.success) {
this.assets = data.data.map(asset => this.normalizeAssetComputedFields(asset));
this.syncSelectedAssetIds();
} else {
console.error('Load assets failed:', data.message);
}
} catch (err) {
console.error('Fetch assets error:', err);
}
}
async fetchRoles() {
try {
const res = await fetch(`${this.apiBase}/roles`);
const data = await res.json();
if (data.success) {
this.roles = data.data;
} else {
console.error('Load roles failed:', data.message);
}
} catch (err) {
console.error('Fetch roles error:', err);
}
}
async loadModals() {
try {
const existingContainer = document.getElementById('modalsContainer');
if (existingContainer && existingContainer.children.length) {
return;
}
const response = await fetch('../modals.html');
const modalsHTML = await response.text();
const container = existingContainer || document.createElement('div');
if (!container.id) container.id = 'modalsContainer';
container.innerHTML = modalsHTML;
if (!container.parentElement) {
document.body.appendChild(container);
}
this.setupFormListeners();
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
} catch (error) {
console.error('Lỗi load modals:', error);
}
}
restoreSearchFocus() {
const accountSearch = document.getElementById('accountSearch');
const appSearch = document.getElementById('appSearch');
const assetSearch = document.getElementById('assetSearch');
if (accountSearch && accountSearch.dataset.focused === 'true') {
const pos = accountSearch.selectionStart || accountSearch.value.length;
accountSearch.focus();
accountSearch.setSelectionRange(pos, pos);
}
if (appSearch && appSearch.dataset.focused === 'true') {
const pos = appSearch.selectionStart || appSearch.value.length;
appSearch.focus();
appSearch.setSelectionRange(pos, pos);
}
if (assetSearch && assetSearch.dataset.focused === 'true') {
const pos = assetSearch.selectionStart || assetSearch.value.length;
assetSearch.focus();
assetSearch.setSelectionRange(pos, pos);
}
}
setupEventListeners() {
// Modal close buttons
document.querySelectorAll('[data-close-modal]').forEach(btn => {
btn.addEventListener('click', () => this.closeModals());
});
// Close with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeMobileNav();
this.closeModals();
}
});
// Form submissions
// Logout button
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.handleLogout());
}
const profileBtn = document.getElementById('profileBtn');
if (profileBtn) {
profileBtn.addEventListener('click', () => this.openProfileModal());
}
// Update account display
this.updateAccountDisplay();
// Account table row clicks
this.setupAccountRowListeners();
this.setupFilters();
this.setupResponsiveShell();
}
setupFormListeners() {
const accountForm = document.getElementById('accountForm');
if (accountForm) {
if (!accountForm.dataset.boundSubmit) {
accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e));
accountForm.dataset.boundSubmit = 'true';
}
}
const appForm = document.getElementById('appForm');
if (appForm) {
if (!appForm.dataset.boundSubmit) {
appForm.addEventListener('submit', (e) => this.handleAppSubmit(e));
appForm.dataset.boundSubmit = 'true';
}
}
const assetForm = document.getElementById('assetForm');
if (assetForm) {
if (!assetForm.dataset.boundSubmit) {
assetForm.addEventListener('submit', (e) => this.handleAssetSubmit(e));
assetForm.dataset.boundSubmit = 'true';
}
this.setupAssetStockListeners();
}
const borrowAssetForm = document.getElementById('borrowAssetForm');
if (borrowAssetForm) {
if (!borrowAssetForm.dataset.boundSubmit) {
borrowAssetForm.addEventListener('submit', (e) => this.handleBorrowAssetSubmit(e));
borrowAssetForm.dataset.boundSubmit = 'true';
}
}
// Close when clicking backdrop outside modal content
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.addEventListener('click', (evt) => {
if (evt.target === backdrop) {
this.closeModals();
}
});
});
}
updateAccountDisplay() {
// Use the logged-in user from constructor
const usernameEl = document.getElementById('accountUsername');
const roleEl = document.getElementById('accountRole');
if (usernameEl) usernameEl.textContent = this.currentUser?.username || this.currentUser?.Username || 'User';
if (roleEl) roleEl.textContent = this.getCurrentUserRoleRaw() || 'Guest';
}
getFilteredAccounts() {
const svcFilter = this.accountServiceFilter || '';
const search = (this.accountSearchTerm || '').toLowerCase();
return this.accounts.filter(acc => {
const matchesService = !svcFilter || String(acc.AppId) === String(svcFilter);
if (!matchesService) return false;
if (!search) return true;
const hay = [acc.AccountUsername, acc.Email, acc.AppName, acc.AppType].map(v => (v || '').toLowerCase());
return hay.some(val => val.includes(search));
});
}
getFilteredApplications() {
const search = (this.applicationSearchTerm || '').toLowerCase();
if (!search) return this.applications;
return this.applications.filter(app => {
const hay = [app.Name, app.Type, app.Description, app.Url, app.Icon].map(v => (v || '').toLowerCase());
return hay.some(val => val.includes(search));
});
}
getFilteredAssets() {
const statusFilter = (this.assetStatusFilter || '').toLowerCase();
const search = (this.assetSearchTerm || '').toLowerCase();
return this.assets.filter(asset => {
const status = String(asset.Status || '').toLowerCase();
const matchesStatus = !statusFilter || status === statusFilter;
if (!matchesStatus) {
return false;
}
if (!search) {
return true;
}
const haystack = [
asset.AssetCode,
asset.AssetName,
asset.Model,
asset.SerialNumber,
asset.ImportInPeriod,
asset.ExportInPeriod,
asset.EndingBalance,
asset.Department,
asset.Project,
asset.Location,
asset.Custodian,
asset.Borrower,
asset.ExportedBy,
asset.Notes
].map(v => String(v || '').toLowerCase());
return haystack.some(value => value.includes(search));
});
}
syncSelectedAssetIds() {
if (!(this.selectedAssetIds instanceof Set)) {
this.selectedAssetIds = new Set();
}
const validIds = new Set(
this.assets
.map(asset => Number(asset.AssetId))
.filter(id => Number.isFinite(id))
);
this.selectedAssetIds = new Set(
[...this.selectedAssetIds].filter(id => validIds.has(Number(id)))
);
}
getPaged(items, page, pageSize) {
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const current = Math.min(Math.max(1, page), totalPages);
const start = (current - 1) * pageSize;
return {
current,
total,
totalPages,
data: items.slice(start, start + pageSize),
start: total === 0 ? 0 : start + 1,
end: Math.min(total, start + pageSize)
};
}
maskForeignAccountUsername(username) {
const value = String(username || '').trim();
if (!value) return '-';
if (value.length < 5) {
return `${value.slice(0, 1)}*****`;
}
return `${value.slice(0, 3)}*****`;
}
handleLogout() {
if (confirm('Are you sure you want to logout?')) {
this.saveToStorage('currentUser', null);
localStorage.clear();
window.location.href = '../pages/login.html';
}
}
renderDashboard() {
return `
Applications
${this.applications.length}
${this.applications.filter(a => (a.Status || a.status) === 'online').length} Active
Total Accounts
${this.accounts.length}
Managed
Last Updated
${new Date().toLocaleDateString()}
Status
Operational
check_circle
history
Recent Accounts
${this.accounts.length > 0 ? `
${this.accounts.slice(-5).reverse().map(acc => {
const username = acc.AccountUsername || acc.username || '-';
const service = acc.AppName || acc.service || '-';
const owner = acc.Email || acc.owner || this.currentUser?.Username || this.currentUser?.username || '-';
return `
${username}
${service} • ${owner}
`;}).join('')}
` : `
No accounts yet. Create one
`}
`;
}
getAccountsContent() {
const filteredAccounts = this.getFilteredAccounts();
const currentUserId = this.getUserId();
const pageInfo = this.getPaged(filteredAccounts, this.accountPage, this.accountPageSize);
this.accountPage = pageInfo.current;
return `
${pageInfo.data.length > 0 ? `
| Owner |
Username |
Service |
Created Date |
Last Updated |
Actions |
${pageInfo.data.map(acc => {
const isOwnAccount = acc.UserId == currentUserId;
const accountUsername = acc.AccountUsername || '-';
const displayAccountUsername = isOwnAccount
? accountUsername
: this.maskForeignAccountUsername(accountUsername);
const createdDate = this.formatDateTime(acc.CreatedDate);
const updatedDate = this.formatDateTime(acc.UpdatedDate);
const actionContent = isOwnAccount
? `
`
: '-';
return `
| ${acc.Email || '-'} |
${displayAccountUsername} |
${acc.AppName || '-'}
|
${createdDate} |
${updatedDate} |
${actionContent}
|
`;
}).join('')}
` : `
No accounts yet. Create one to get started.
`}
`;
}
getApplicationsContent() {
const filteredApps = this.getFilteredApplications();
const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize);
this.appPage = pageInfo.current;
return `
lan
Active
${this.applications.filter(a => a.status === 'online').length}
bolt
Total
${this.applications.length}
Search
| Name |
Type |
Description |
URL |
Status |
Actions |
${pageInfo.data.map(app => `
${app.Icon || 'apps'}
${app.Name}
|
${app.Type}
|
${app.Description || '-'} |
${(app.Url || app.url) ? `${app.Url || app.url}` : '-'} |
${(app.Status || app.status) === 'online' ? 'Online' : 'Offline'}
|
|
`).join('')}
`;
}
getAssetStatusMeta(status) {
const normalized = String(status || '').toLowerCase();
if (normalized === 'in_stock') {
return { label: 'Trong kho', className: 'bg-emerald-100 text-emerald-700' };
}
if (normalized === 'maintenance') {
return { label: 'Bảo trì', className: 'bg-amber-100 text-amber-700' };
}
if (normalized === 'disposed') {
return { label: 'Thanh lý', className: 'bg-rose-100 text-rose-700' };
}
return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' };
}
formatDateOnly(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString();
}
toDateInputValue(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
formatBorrowerDisplay(name, quantity = 1) {
const cleanName = String(name || '').trim();
if (!cleanName) return null;
const quantityNumber = Number(quantity);
const safeQuantity = Number.isInteger(quantityNumber) && quantityNumber > 0
? quantityNumber
: 1;
return `${cleanName} - số lượng: ${safeQuantity}`;
}
parseNonNegativeInteger(value, fallback = 0) {
if (value === null || value === undefined || value === '') {
return fallback;
}
const parsed = Number.parseInt(String(value).replace(/,/g, '').trim(), 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
parseBorrowerEntries(rawBorrower) {
if (Array.isArray(rawBorrower)) {
const merged = [];
rawBorrower.forEach(item => {
if (!item) return;
const name = String(item.name || item.Name || '').trim();
const quantity = this.parseNonNegativeInteger(item.quantity ?? item.Quantity, 0);
if (!name || quantity <= 0) return;
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
if (existed) {
existed.quantity += quantity;
} else {
merged.push({ name, quantity });
}
});
return merged;
}
const source = String(rawBorrower || '').trim();
if (!source) {
return [];
}
const chunks = source
.split(/[\n;]+/g)
.map(item => String(item || '').trim())
.filter(Boolean);
const merged = [];
chunks.forEach(chunk => {
let name = chunk;
let quantity = 1;
const labeledMatch = chunk.match(/^(.*?)(?:\s*-\s*[^:]+:\s*(\d+))\s*$/i);
if (labeledMatch) {
name = String(labeledMatch[1] || '').trim();
quantity = this.parseNonNegativeInteger(labeledMatch[2], 1);
} else {
const colonMatch = chunk.match(/^(.*?)\s*:\s*(\d+)\s*$/);
const xMatch = chunk.match(/^(.*?)\s*x\s*(\d+)\s*$/i);
const parenMatch = chunk.match(/^(.*?)\s*\(\s*(\d+)\s*\)\s*$/);
const fallbackMatch = colonMatch || xMatch || parenMatch;
if (fallbackMatch) {
name = String(fallbackMatch[1] || '').trim();
quantity = this.parseNonNegativeInteger(fallbackMatch[2], 1);
}
}
if (!name || quantity <= 0) {
return;
}
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
if (existed) {
existed.quantity += quantity;
} else {
merged.push({ name, quantity });
}
});
return merged;
}
formatBorrowerEntries(entries, separator = '; ') {
if (!Array.isArray(entries) || !entries.length) {
return '';
}
return entries
.map(entry => this.formatBorrowerDisplay(entry?.name, entry?.quantity))
.filter(Boolean)
.join(separator);
}
formatBorrowerSummaryText(rawBorrower) {
const entries = this.parseBorrowerEntries(rawBorrower);
return this.formatBorrowerEntries(entries, '\n');
}
formatBorrowerTableHtml(rawBorrower) {
const entries = this.parseBorrowerEntries(rawBorrower);
if (!entries.length) {
return '-';
}
return entries
.map(entry => this.formatBorrowerDisplay(entry.name, entry.quantity))
.filter(Boolean)
.map(item => `${this.escapeHtml(item)}
`)
.join('');
}
mergeBorrowerEntries(existingEntries, borrowerName, borrowQuantity) {
const merged = this.parseBorrowerEntries(existingEntries);
const name = String(borrowerName || '').trim();
const quantity = this.parseNonNegativeInteger(borrowQuantity, 0);
if (!name || quantity <= 0) {
return merged;
}
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
if (existed) {
existed.quantity += quantity;
} else {
merged.push({ name, quantity });
}
return merged;
}
buildAssetQuantityMetrics(asset, borrowerEntriesOverride = null) {
const quantity = this.parseNonNegativeInteger(asset?.Quantity ?? asset?.quantity, 0);
const importInPeriod = this.parseNonNegativeInteger(asset?.ImportInPeriod ?? asset?.importInPeriod, 0);
const borrowerEntries = Array.isArray(borrowerEntriesOverride)
? this.parseBorrowerEntries(borrowerEntriesOverride)
: this.parseBorrowerEntries(asset?.Borrower ?? asset?.borrower);
const exportInPeriod = borrowerEntries.reduce((sum, entry) => (
sum + this.parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
return {
quantity,
importInPeriod,
exportInPeriod,
endingBalance,
borrowerEntries
};
}
normalizeAssetComputedFields(asset) {
if (!asset || typeof asset !== 'object') {
return asset;
}
const metrics = this.buildAssetQuantityMetrics(asset);
return {
...asset,
Quantity: metrics.quantity,
ImportInPeriod: metrics.importInPeriod,
ExportInPeriod: metrics.exportInPeriod,
EndingBalance: metrics.endingBalance,
Borrower: this.formatBorrowerEntries(metrics.borrowerEntries, '; ') || null
};
}
recalculateAssetStockFields() {
const quantityInput = document.getElementById('assetQuantityInput');
const importInput = document.getElementById('assetImportInPeriodInput');
const exportInput = document.getElementById('assetExportInPeriodInput');
const endingInput = document.getElementById('assetEndingBalanceInput');
if (!quantityInput || !importInput) {
return;
}
const metrics = this.buildAssetQuantityMetrics(
{
Quantity: quantityInput.value,
ImportInPeriod: importInput.value
},
this.editingAssetBorrowerEntries
);
if (exportInput) {
exportInput.value = String(metrics.exportInPeriod);
}
if (endingInput) {
endingInput.value = String(metrics.endingBalance);
}
}
setupAssetStockListeners() {
['assetQuantityInput', 'assetImportInPeriodInput'].forEach(fieldId => {
const input = document.getElementById(fieldId);
if (!input || input.dataset.boundStockListener) {
return;
}
input.addEventListener('input', () => this.recalculateAssetStockFields());
input.addEventListener('change', () => this.recalculateAssetStockFields());
input.dataset.boundStockListener = 'true';
});
}
getAssetsContent() {
this.syncSelectedAssetIds();
const canManageAssets = this.canCurrentUserManageAssets();
const filteredAssets = this.getFilteredAssets();
const pageInfo = this.getPaged(filteredAssets, this.assetPage, this.assetPageSize);
const selectedCount = canManageAssets ? this.selectedAssetIds.size : 0;
const pageAssetIds = pageInfo.data
.map(asset => Number(asset.AssetId))
.filter(id => Number.isFinite(id));
const selectedOnPageCount = pageAssetIds.filter(id => this.selectedAssetIds.has(id)).length;
const allOnPageSelected = canManageAssets && pageAssetIds.length > 0 && selectedOnPageCount === pageAssetIds.length;
this.assetPage = pageInfo.current;
return `
${pageInfo.data.length > 0 ? `
` : `
Chưa có dữ liệu tài sản. Hãy thêm tài sản đầu tiên.
`}
`;
}
renderAssetsTableBody() {
const tbody = document.querySelector('.assets-table-body');
if (!tbody) return;
this.syncSelectedAssetIds();
const canManageAssets = this.canCurrentUserManageAssets();
const pageInfo = this.getPaged(this.getFilteredAssets(), this.assetPage, this.assetPageSize);
this.assetPage = pageInfo.current;
tbody.innerHTML = pageInfo.data.map((asset, index) => {
const statusMeta = this.getAssetStatusMeta(asset.Status);
const assetId = Number(asset.AssetId);
const isSelected = Number.isFinite(assetId) && this.selectedAssetIds.has(assetId);
const rowNumber = pageInfo.start + index;
return `
|
|
${rowNumber} |
${asset.AssetCode || '-'} |
${asset.AssetName || '-'} |
${asset.Model || '-'} |
${asset.SerialNumber || '-'} |
${asset.Quantity || 0} |
${asset.ImportInPeriod ?? 0} |
${asset.ExportInPeriod ?? 0} |
${asset.EndingBalance ?? 0} |
${asset.Unit || '-'} |
${asset.Department || '-'} |
${asset.Project || '-'} |
${asset.Custodian || '-'} |
${statusMeta.label}
|
${asset.Location || '-'} |
${this.formatDateOnly(asset.PurchaseDate)} |
${this.formatBorrowerTableHtml(asset.Borrower)} |
${asset.Notes || '-'} |
${this.formatDateOnly(asset.CreatedDate)} |
${asset.ExportedBy || '-'} |
|
`;
}).join('');
const pager = document.getElementById('assetsPager');
if (pager) {
pager.innerHTML = `
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
Trang ${pageInfo.current} / ${pageInfo.totalPages}
`;
}
this.setupAssetRowListeners();
this.setupAssetPagerListeners();
}
setupAssetSelectionListeners() {
if (!this.canCurrentUserManageAssets()) {
this.selectedAssetIds.clear();
document.querySelectorAll('.asset-row-checkbox').forEach(checkbox => {
checkbox.checked = false;
checkbox.disabled = true;
});
const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.disabled = true;
}
this.updateAssetBulkActionState();
return;
}
document.querySelectorAll('.asset-row-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', () => {
const assetId = Number(checkbox.dataset.assetId);
if (!Number.isFinite(assetId)) {
return;
}
if (checkbox.checked) {
this.selectedAssetIds.add(assetId);
} else {
this.selectedAssetIds.delete(assetId);
}
this.updateAssetBulkActionState();
});
});
const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox');
if (selectAllCheckbox && !selectAllCheckbox.dataset.boundChange) {
selectAllCheckbox.addEventListener('change', () => {
const shouldSelect = selectAllCheckbox.checked;
document.querySelectorAll('.asset-row-checkbox').forEach(checkbox => {
checkbox.checked = shouldSelect;
const assetId = Number(checkbox.dataset.assetId);
if (!Number.isFinite(assetId)) {
return;
}
if (shouldSelect) {
this.selectedAssetIds.add(assetId);
} else {
this.selectedAssetIds.delete(assetId);
}
});
this.updateAssetBulkActionState();
});
selectAllCheckbox.dataset.boundChange = 'true';
}
const bulkDeleteBtn = document.getElementById('bulkDeleteAssetsBtn');
if (bulkDeleteBtn && !bulkDeleteBtn.dataset.boundClick) {
bulkDeleteBtn.addEventListener('click', async () => {
await this.handleBulkDeleteAssets();
});
bulkDeleteBtn.dataset.boundClick = 'true';
}
this.updateAssetBulkActionState();
}
updateAssetBulkActionState() {
const canManageAssets = this.canCurrentUserManageAssets();
const rowCheckboxes = Array.from(document.querySelectorAll('.asset-row-checkbox'));
const selectedOnPage = rowCheckboxes.filter(checkbox => checkbox.checked).length;
if (!canManageAssets) {
this.selectedAssetIds.clear();
}
rowCheckboxes.forEach(checkbox => {
checkbox.disabled = !canManageAssets;
});
const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox');
if (selectAllCheckbox) {
const hasRows = rowCheckboxes.length > 0;
selectAllCheckbox.checked = canManageAssets && hasRows && selectedOnPage === rowCheckboxes.length;
selectAllCheckbox.indeterminate = canManageAssets && selectedOnPage > 0 && selectedOnPage < rowCheckboxes.length;
selectAllCheckbox.disabled = !canManageAssets;
}
const selectedCount = canManageAssets ? this.selectedAssetIds.size : 0;
const selectedCountNode = document.getElementById('selectedAssetCount');
if (selectedCountNode) {
selectedCountNode.textContent = String(selectedCount);
}
const bulkDeleteBtn = document.getElementById('bulkDeleteAssetsBtn');
if (bulkDeleteBtn) {
const disabled = !canManageAssets || selectedCount === 0;
bulkDeleteBtn.disabled = disabled;
bulkDeleteBtn.classList.toggle('opacity-50', disabled);
bulkDeleteBtn.classList.toggle('cursor-not-allowed', disabled);
}
const borrowAssetBtn = document.getElementById('borrowAssetBtn');
if (borrowAssetBtn) {
const disabled = !canManageAssets || selectedCount !== 1;
borrowAssetBtn.disabled = disabled;
borrowAssetBtn.classList.toggle('opacity-50', disabled);
borrowAssetBtn.classList.toggle('cursor-not-allowed', disabled);
}
}
async handleBulkDeleteAssets() {
if (!this.ensureAssetManagePermission('xoa tai san')) {
return;
}
const selectedIds = [...this.selectedAssetIds];
if (!selectedIds.length) {
this.notifyWarning('Vui lòng chọn ít nhất 1 tài sản để xóa');
return;
}
const confirmed = window.confirm(`Bạn có chắc muốn xóa ${selectedIds.length} tài sản đã chọn?`);
if (!confirmed) {
return;
}
let successCount = 0;
let failedCount = 0;
for (const assetId of selectedIds) {
try {
const response = await fetch(`${this.apiBase}/assets/${assetId}`, {
method: 'DELETE',
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!response.ok || !data.success) {
failedCount += 1;
continue;
}
this.selectedAssetIds.delete(assetId);
successCount += 1;
} catch (err) {
console.error(err);
failedCount += 1;
}
}
if (successCount > 0 && failedCount === 0) {
this.notifySuccess(`Đã xóa ${successCount} tài sản`);
} else if (successCount > 0) {
this.notifyWarning(`Đã xóa ${successCount}/${selectedIds.length} tài sản. ${failedCount} dòng xóa thất bại`);
} else {
this.notifyFailure('Xóa tài sản thất bại');
}
await this.refreshAssetsUI();
}
setupAssetPagerListeners() {
document.querySelectorAll('.asset-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetPage = Number(btn.dataset.page);
if (!targetPage || targetPage < 1) return;
this.assetPage = targetPage;
this.renderAssetsTableBody();
});
});
}
renderAssetDetails(asset) {
const detailsContainer = document.getElementById('assetDetailsContent');
if (!detailsContainer) {
return;
}
const borrowerSummary = this.formatBorrowerSummaryText(asset?.Borrower);
const fields = [
['Mã tài sản', asset?.AssetCode],
['Tên tài sản', asset?.AssetName],
['Model', asset?.Model],
['Số serial', asset?.SerialNumber],
['Số lượng (Tồn đầu kỳ)', `${asset?.Quantity || 0} ${asset?.Unit || ''}`.trim()],
['Nhập trong kỳ', asset?.ImportInPeriod ?? 0],
['Xuất trong kỳ', asset?.ExportInPeriod ?? 0],
['Tồn cuối kỳ', asset?.EndingBalance ?? 0],
['Phòng ban', asset?.Department],
['Dự án', asset?.Project],
['Vị trí', asset?.Location],
['Người phụ trách', asset?.Custodian],
['Ngày mua', this.formatDateOnly(asset?.PurchaseDate)],
['Người xuất', asset?.ExportedBy],
['Trạng thái', this.getAssetStatusMeta(asset?.Status).label],
['Ghi chú', asset?.Notes]
];
detailsContainer.innerHTML = `
${borrowerSummary || '-'}
${fields.map(([label, value]) => `
`).join('')}
`;
}
populateAssetForm(asset) {
const sourceAsset = asset || {};
const borrowerEntries = this.parseBorrowerEntries(sourceAsset?.Borrower);
this.editingAssetBorrowerEntries = borrowerEntries;
document.getElementById('assetCodeInput').value = sourceAsset?.AssetCode || '';
document.getElementById('assetNameInput').value = sourceAsset?.AssetName || '';
document.getElementById('assetStatusInput').value = String(sourceAsset?.Status || 'in_use').toLowerCase();
document.getElementById('assetModelInput').value = sourceAsset?.Model || '';
document.getElementById('assetSerialInput').value = sourceAsset?.SerialNumber || '';
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 || '';
document.getElementById('assetProjectInput').value = sourceAsset?.Project || '';
document.getElementById('assetLocationInput').value = sourceAsset?.Location || '';
this.refreshAssetCustodianOptions(sourceAsset?.Custodian || '');
const borrowerSummaryInput = document.getElementById('assetBorrowerSummaryInput');
if (borrowerSummaryInput) {
borrowerSummaryInput.value = this.formatBorrowerSummaryText(sourceAsset?.Borrower) || '(Chua co nguoi muon)';
borrowerSummaryInput.readOnly = true;
}
const exportInput = document.getElementById('assetExportInPeriodInput');
const endingInput = document.getElementById('assetEndingBalanceInput');
if (exportInput) exportInput.readOnly = true;
if (endingInput) endingInput.readOnly = true;
document.getElementById('assetPurchaseDateInput').value = this.toDateInputValue(sourceAsset?.PurchaseDate);
document.getElementById('assetPriceInput').value = sourceAsset?.PurchasePrice || '';
document.getElementById('assetNotesInput').value = sourceAsset?.Notes || '';
this.recalculateAssetStockFields();
}
openAssetModal() {
if (!this.ensureAssetManagePermission('them hoac sua tai san')) {
return;
}
if (this.editingAssetId === undefined) {
this.populateAssetForm(null);
}
if (!this.users.length) {
this.fetchUsers();
}
document.getElementById('assetModal').classList.add('open');
}
collectAssetFormPayload() {
const quantity = this.parseNonNegativeInteger(document.getElementById('assetQuantityInput')?.value ?? 0, 0);
const importInPeriod = this.parseNonNegativeInteger(document.getElementById('assetImportInPeriodInput')?.value ?? 0, 0);
const borrowerEntries = Array.isArray(this.editingAssetBorrowerEntries)
? this.editingAssetBorrowerEntries
: [];
const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null;
const metrics = this.buildAssetQuantityMetrics(
{
Quantity: quantity,
ImportInPeriod: importInPeriod,
Borrower: borrower
},
borrowerEntries
);
const purchasePrice = String(document.getElementById('assetPriceInput')?.value ?? '').trim();
return {
assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '',
assetName: document.getElementById('assetNameInput')?.value?.trim() || '',
status: document.getElementById('assetStatusInput')?.value || 'in_use',
model: document.getElementById('assetModelInput')?.value?.trim() || '',
serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '',
quantity,
importInPeriod,
exportInPeriod: metrics.exportInPeriod,
endingBalance: metrics.endingBalance,
unit: document.getElementById('assetUnitInput')?.value?.trim() || '',
department: document.getElementById('assetDepartmentInput')?.value?.trim() || '',
project: document.getElementById('assetProjectInput')?.value?.trim() || '',
location: document.getElementById('assetLocationInput')?.value?.trim() || '',
custodian: document.getElementById('assetCustodianInput')?.value?.trim() || '',
borrower,
purchaseDate: document.getElementById('assetPurchaseDateInput')?.value || null,
purchasePrice: purchasePrice || null,
notes: document.getElementById('assetNotesInput')?.value?.trim() || ''
};
}
buildAssetPayloadFromAsset(asset, borrowerEntriesOverride = null) {
if (!asset) {
return null;
}
const borrowerEntries = Array.isArray(borrowerEntriesOverride)
? borrowerEntriesOverride
: this.parseBorrowerEntries(asset?.Borrower);
const borrower = this.formatBorrowerEntries(borrowerEntries, '; ') || null;
const metrics = this.buildAssetQuantityMetrics(
{
Quantity: asset?.Quantity,
ImportInPeriod: asset?.ImportInPeriod,
Borrower: borrower
},
borrowerEntries
);
const rawPrice = asset?.PurchasePrice;
const normalizedPrice = rawPrice === undefined || rawPrice === null || String(rawPrice).trim() === ''
? null
: String(rawPrice).trim();
return {
assetCode: String(asset?.AssetCode || '').trim(),
assetName: String(asset?.AssetName || '').trim(),
status: String(asset?.Status || 'in_use'),
model: String(asset?.Model || '').trim(),
serialNumber: String(asset?.SerialNumber || '').trim(),
quantity: metrics.quantity,
importInPeriod: metrics.importInPeriod,
exportInPeriod: metrics.exportInPeriod,
endingBalance: metrics.endingBalance,
unit: String(asset?.Unit || '').trim(),
department: String(asset?.Department || '').trim(),
project: String(asset?.Project || '').trim(),
location: String(asset?.Location || '').trim(),
custodian: String(asset?.Custodian || '').trim(),
borrower,
purchaseDate: this.toDateInputValue(asset?.PurchaseDate) || null,
purchasePrice: normalizedPrice,
notes: String(asset?.Notes || '').trim()
};
}
getSingleSelectedAssetForBorrowing(showWarning = true) {
const selectedIds = [...this.selectedAssetIds];
if (!selectedIds.length) {
if (showWarning) {
this.notifyWarning('Vui lòng chọn 1 tài sản để mượn.');
}
return null;
}
if (selectedIds.length > 1) {
if (showWarning) {
this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần mượn.');
}
return null;
}
const assetId = Number(selectedIds[0]);
const asset = this.assets.find(item => Number(item?.AssetId) === assetId) || null;
if (!asset && showWarning) {
this.notifyFailure('Không tìm thấy tài sản đã chọn.');
}
return asset;
}
async openBorrowAssetModal() {
if (!this.ensureAssetManagePermission('muon tai san')) {
return;
}
const asset = this.getSingleSelectedAssetForBorrowing(true);
if (!asset) {
return;
}
if (!this.users.length) {
await this.fetchUsers();
}
const metrics = this.buildAssetQuantityMetrics(asset);
if (metrics.endingBalance <= 0) {
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể mượn thêm.');
return;
}
this.pendingBorrowAssetId = Number(asset.AssetId);
const assetIdInput = document.getElementById('borrowAssetIdInput');
const assetNameInput = document.getElementById('borrowAssetNameInput');
const endingInput = document.getElementById('borrowCurrentEndingInput');
const quantityInput = document.getElementById('borrowQuantityInput');
const borrowByInput = document.getElementById('borrowByInput');
const borrowRoleInput = document.getElementById('borrowRoleInput');
const modal = document.getElementById('borrowAssetModal');
if (!modal || !assetNameInput || !endingInput || !quantityInput) {
this.notifyFailure('Không tìm thấy biểu mẫu mượn tài sản.');
return;
}
if (assetIdInput) {
assetIdInput.value = String(asset.AssetId || '');
}
assetNameInput.value = `${asset.AssetCode || ''} - ${asset.AssetName || ''}`.trim();
endingInput.value = String(metrics.endingBalance);
quantityInput.value = '1';
quantityInput.min = '1';
quantityInput.max = String(metrics.endingBalance);
this.refreshBorrowAssetUserOptions('');
if (borrowByInput) {
borrowByInput.value = this.getCurrentUserDisplayName();
}
if (borrowRoleInput) {
borrowRoleInput.value = String(this.getCurrentUserRoleRaw() || '').trim() || '-';
}
modal.classList.add('open');
}
async handleBorrowAssetSubmit(e) {
e.preventDefault();
if (!this.ensureAssetManagePermission('muon tai san')) {
return;
}
const assetIdInput = document.getElementById('borrowAssetIdInput');
const borrowerInput = document.getElementById('borrowAssetUserInput');
const quantityInput = document.getElementById('borrowQuantityInput');
const selectedAssetId = Number(assetIdInput?.value || this.pendingBorrowAssetId);
if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) {
this.notifyFailure('Không xác định được tài sản cần mượn.');
return;
}
const asset = this.assets.find(item => Number(item?.AssetId) === selectedAssetId);
if (!asset) {
this.notifyFailure('Không tìm thấy tài sản cần mượn.');
return;
}
const borrowerName = String(borrowerInput?.value || '').trim();
if (!borrowerName) {
this.notifyWarning('Vui lòng chọn người mượn.');
return;
}
const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0);
if (borrowQuantity <= 0) {
this.notifyWarning('Số lượng mượn phải lớn hơn 0.');
return;
}
const currentMetrics = this.buildAssetQuantityMetrics(asset);
if (currentMetrics.endingBalance <= 0) {
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể mượn thêm.');
return;
}
if (borrowQuantity > currentMetrics.endingBalance) {
this.notifyWarning(`Số lượng mượn (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`);
return;
}
const updatedEntries = this.mergeBorrowerEntries(currentMetrics.borrowerEntries, borrowerName, borrowQuantity);
const payload = this.buildAssetPayloadFromAsset(asset, updatedEntries);
if (!payload) {
this.notifyFailure('Không tạo được dữ liệu mượn tài sản.');
return;
}
try {
const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}`, {
method: 'PUT',
headers: this.getAuthHeaders(true),
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Mượn tài sản thất bại');
return;
}
this.pendingBorrowAssetId = undefined;
this.notifySuccess('Mượn tài sản thành công');
this.closeModals();
await this.refreshAssetsUI();
} catch (err) {
console.error(err);
this.notifyFailure('Mượn tài sản thất bại');
}
}
async handleAssetSubmit(e) {
e.preventDefault();
if (!this.ensureAssetManagePermission('them hoac sua tai san')) {
return;
}
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');
return;
}
const isEdit = this.editingAssetId !== undefined;
const url = isEdit ? `${this.apiBase}/assets/${this.editingAssetId}` : `${this.apiBase}/assets`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: this.getAuthHeaders(true),
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Lưu tài sản thất bại');
return;
}
this.editingAssetId = undefined;
this.notifySuccess(isEdit ? 'Cập nhật tài sản thành công' : 'Thêm tài sản thành công');
this.closeModals();
await this.refreshAssetsUI();
} catch (err) {
console.error(err);
this.notifyFailure('Lưu tài sản thất bại');
}
}
async refreshAssetsUI() {
await this.fetchAssets();
if (this.currentPage === 'assets') {
this.renderView('assets');
}
}
setupAssetRowListeners() {
this.setupAssetSelectionListeners();
const canManageAssets = this.canCurrentUserManageAssets();
document.querySelectorAll('.view-asset').forEach(btn => {
btn.addEventListener('click', () => {
const assetId = Number(btn.dataset.assetId);
const asset = this.assets.find(a => a.AssetId === assetId);
this.currentViewAsset = asset;
this.currentViewAssetId = assetId;
this.renderAssetDetails(asset);
document.getElementById('viewAssetModal').classList.add('open');
});
});
document.querySelectorAll('.edit-asset').forEach(btn => {
btn.addEventListener('click', () => {
if (!this.ensureAssetManagePermission('sua tai san')) {
return;
}
const assetId = Number(btn.dataset.assetId);
const asset = this.assets.find(a => a.AssetId === assetId);
this.editingAssetId = asset?.AssetId;
this.populateAssetForm(asset);
this.closeModals();
this.openAssetModal();
});
});
document.querySelectorAll('.delete-asset').forEach(btn => {
btn.addEventListener('click', () => {
if (!this.ensureAssetManagePermission('xoa tai san')) {
return;
}
const assetId = Number(btn.dataset.assetId);
const asset = this.assets.find(a => a.AssetId === assetId);
this.pendingDeleteAssetId = assetId;
document.getElementById('deleteAssetName').textContent = asset?.AssetName || asset?.AssetCode || '-';
document.getElementById('deleteAssetModal').classList.add('open');
});
});
document.querySelectorAll('.confirm-delete-asset').forEach(btn => {
if (btn.dataset.boundClick) {
return;
}
btn.addEventListener('click', async () => {
if (!this.ensureAssetManagePermission('xoa tai san')) {
return;
}
if (this.pendingDeleteAssetId === undefined) {
return;
}
const targetDeleteId = Number(this.pendingDeleteAssetId);
try {
const response = await fetch(`${this.apiBase}/assets/${this.pendingDeleteAssetId}`, {
method: 'DELETE',
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Xóa tài sản thất bại');
return;
}
this.pendingDeleteAssetId = undefined;
this.selectedAssetIds.delete(targetDeleteId);
this.closeModals();
this.notifySuccess('Xóa tài sản thành công');
await this.refreshAssetsUI();
} catch (err) {
console.error(err);
this.notifyFailure('Xóa tài sản thất bại');
}
});
btn.dataset.boundClick = 'true';
});
const editFromViewBtn = document.querySelector('.edit-asset-from-view');
if (editFromViewBtn && !editFromViewBtn.dataset.boundClick) {
editFromViewBtn.disabled = !canManageAssets;
editFromViewBtn.classList.toggle('opacity-50', !canManageAssets);
editFromViewBtn.classList.toggle('cursor-not-allowed', !canManageAssets);
editFromViewBtn.addEventListener('click', () => {
if (!this.ensureAssetManagePermission('sua tai san')) {
return;
}
const asset = this.currentViewAsset;
this.editingAssetId = asset?.AssetId;
this.populateAssetForm(asset);
this.closeModals();
this.openAssetModal();
});
editFromViewBtn.dataset.boundClick = 'true';
}
}
normalizeImportHeader(key) {
return String(key || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[đĐ]/g, 'd')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
isImportHeaderMatch(actualHeader, alias) {
const normalizedHeader = this.normalizeImportHeader(actualHeader);
const normalizedAlias = this.normalizeImportHeader(alias);
if (!normalizedHeader || !normalizedAlias) {
return false;
}
if (normalizedHeader === normalizedAlias) {
return true;
}
// Avoid over-matching very short aliases such as "PN".
if (normalizedAlias.length < 4 || normalizedHeader.length < 4) {
return false;
}
return normalizedHeader.includes(normalizedAlias) || normalizedAlias.includes(normalizedHeader);
}
findImportValue(row, aliases) {
for (const [key, value] of Object.entries(row || {})) {
if (aliases.some(alias => this.isImportHeaderMatch(key, alias))) {
return value;
}
}
return '';
}
isHeaderLikeImportValue(value) {
const normalized = this.normalizeImportHeader(value);
if (!normalized) {
return false;
}
const knownHeaderTokens = new Set([
'stt',
'ngayve',
'mavattu',
'mavt',
'mataisan',
'mats',
'matscd',
'tenlinhkiensp',
'tentaisan',
'tentaisanccdc',
'model',
'dvt',
'donvi',
'tondauky',
'tondauki',
'nhaptrongky',
'nhaptrongki',
'xuattrongky',
'xuattrongki',
'toncuoiky',
'toncuoiki',
'lidoxuat',
'lydoxuat',
'tinhtrang',
'vitri',
'duan',
'assetcode',
'assetname',
'quantity',
'importinperiod',
'exportinperiod',
'endingbalance',
'unit',
'location',
'department',
'project',
'status',
'notes'
]);
return knownHeaderTokens.has(normalized);
}
isLikelyHeaderArtifactAssetRow(mappedRow) {
const row = mappedRow || {};
const fields = [
row.assetCode,
row.assetName,
row.model,
row.unit,
row.status,
row.location,
row.department,
row.project,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance,
row.notes
];
const headerLikeCount = fields.reduce((count, value) => {
return count + (this.isHeaderLikeImportValue(value) ? 1 : 0);
}, 0);
if (headerLikeCount >= 2) {
return true;
}
return this.isHeaderLikeImportValue(row.assetName) && this.isHeaderLikeImportValue(row.model);
}
hasImportAliasInRow(row, aliases) {
const normalizedRow = (Array.isArray(row) ? row : [])
.map(cell => this.normalizeImportHeader(cell))
.filter(Boolean);
if (!normalizedRow.length) {
return false;
}
return aliases.some(alias => {
const normalizedAlias = this.normalizeImportHeader(alias);
return normalizedRow.some(headerValue => this.isImportHeaderMatch(headerValue, normalizedAlias));
});
}
isLikelyAssetHeaderRow(row) {
const codeAliases = ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'];
const nameAliases = ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC'];
const modelAliases = ['Model', 'Dong may'];
const quantityAliases = ['Ton dau ky', 'Ton dau ki', 'Quantity', 'So luong', 'SL', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki'];
const unitAliases = ['Unit', 'Don vi', 'DVT'];
const sttAliases = ['STT', 'So thu tu'];
const hasCode = this.hasImportAliasInRow(row, codeAliases);
const hasName = this.hasImportAliasInRow(row, nameAliases);
const hasModel = this.hasImportAliasInRow(row, modelAliases);
const hasQty = this.hasImportAliasInRow(row, quantityAliases);
const hasUnit = this.hasImportAliasInRow(row, unitAliases);
const hasStt = this.hasImportAliasInRow(row, sttAliases);
if (hasStt && hasName && (hasModel || hasQty || hasUnit || hasCode)) {
return true;
}
if (hasCode && hasName) {
return true;
}
return hasName && (hasModel || hasQty || hasUnit);
}
findAssetImportHeaderRowIndex(matrixRows) {
const codeAliases = ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'];
const nameAliases = ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC'];
const modelAliases = ['Model', 'Dong may'];
const quantityAliases = ['Ton dau ky', 'Ton dau ki', 'Quantity', 'So luong', 'SL', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki'];
const unitAliases = ['Unit', 'Don vi', 'DVT'];
const sttAliases = ['STT', 'So thu tu'];
const maxScanRows = Math.min(Array.isArray(matrixRows) ? matrixRows.length : 0, 50);
let bestIndex = -1;
let bestScore = 0;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(matrixRows[rowIndex]) ? matrixRows[rowIndex] : [];
const normalizedRow = row
.map(cell => this.normalizeImportHeader(cell))
.filter(Boolean);
if (!normalizedRow.length) {
continue;
}
const normalizedSet = new Set(normalizedRow);
const hasAnyAlias = aliasList => aliasList.some(alias => {
const normalizedAlias = this.normalizeImportHeader(alias);
for (const headerValue of normalizedSet) {
if (this.isImportHeaderMatch(headerValue, normalizedAlias)) {
return true;
}
}
return false;
});
const hasCode = hasAnyAlias(codeAliases);
const hasName = hasAnyAlias(nameAliases);
const hasModel = hasAnyAlias(modelAliases);
const hasQty = hasAnyAlias(quantityAliases);
const hasUnit = hasAnyAlias(unitAliases);
const hasStt = hasAnyAlias(sttAliases);
if (hasStt && hasName && (hasModel || hasQty || hasUnit || hasCode)) {
return rowIndex;
}
if (hasCode && hasName) {
return rowIndex;
}
let score = 0;
if (hasName) score += 4;
if (hasCode) score += 3;
if (hasStt) score += 3;
if (hasModel) score += 2;
if (hasQty) score += 1;
if (hasUnit) score += 1;
if (score > bestScore) {
bestScore = score;
bestIndex = rowIndex;
}
}
// Fallback for inventory templates that omit one canonical column name.
if (bestScore >= 4) {
return bestIndex;
}
return -1;
}
mapImportedAssetRowsFromMatrix(matrixRows, headerRowIndex) {
const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : [];
if (!headerRow.length) {
return [];
}
const sttAliases = ['STT', 'So thu tu'];
return matrixRows
.slice(headerRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const rowObject = {};
headerRow.forEach((header, index) => {
const headerText = String(header ?? '').trim();
if (!headerText) {
return;
}
rowObject[headerText] = row[index] ?? '';
});
const sttValue = String(this.findImportValue(rowObject, sttAliases)).trim();
if (sttValue && Number.isNaN(Number(sttValue))) {
return null;
}
if (sttValue === '' && this.findImportValue(rowObject, sttAliases) !== '') {
return null;
}
return this.mapImportedAssetRow(rowObject, headerRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !this.isLikelyHeaderArtifactAssetRow(row))
.filter(row => row.assetCode && row.assetName);
}
inferImportColumnIndex(headerRow, aliases) {
const headers = Array.isArray(headerRow) ? headerRow : [];
for (let index = 0; index < headers.length; index += 1) {
if (aliases.some(alias => this.isImportHeaderMatch(headers[index], alias))) {
return index;
}
}
return -1;
}
parseImportNumericValue(value, fallback = 1) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const normalized = String(value).trim().replace(/,/g, '');
if (!normalized) {
return fallback;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : fallback;
}
buildAssetImportIndexMap(headerRow) {
const indexMap = {
stt: this.inferImportColumnIndex(headerRow, ['STT', 'So thu tu']),
assetCode: this.inferImportColumnIndex(headerRow, ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC']),
assetName: this.inferImportColumnIndex(headerRow, ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC']),
model: this.inferImportColumnIndex(headerRow, ['Model', 'Dong may']),
serialNumber: this.inferImportColumnIndex(headerRow, ['Serial Number', 'Serial', 'So serial', 'So seri']),
quantity: this.inferImportColumnIndex(headerRow, ['Ton dau ky', 'Ton dau ki', 'Opening Balance', 'Quantity', 'So luong', 'SL']),
importInPeriod: this.inferImportColumnIndex(headerRow, ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap']),
exportInPeriod: this.inferImportColumnIndex(headerRow, ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat']),
endingBalance: this.inferImportColumnIndex(headerRow, ['Ton cuoi ki', 'Ton cuoi ky', 'Ton cuoi', 'Ending Balance']),
unit: this.inferImportColumnIndex(headerRow, ['Unit', 'Don vi', 'DVT']),
department: this.inferImportColumnIndex(headerRow, ['Department', 'Bo phan', 'Phong ban']),
project: this.inferImportColumnIndex(headerRow, ['Project', 'Du an', 'Cong trinh']),
location: this.inferImportColumnIndex(headerRow, ['Location', 'Vi tri', 'Noi dat']),
custodian: this.inferImportColumnIndex(headerRow, ['Custodian', 'Nguoi quan ly', 'Nguoi su dung']),
purchaseDate: this.inferImportColumnIndex(headerRow, ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve']),
purchasePrice: this.inferImportColumnIndex(headerRow, ['Purchase Price', 'Gia mua', 'Don gia']),
status: this.inferImportColumnIndex(headerRow, ['Status', 'Trang thai', 'Tinh trang']),
notes: this.inferImportColumnIndex(headerRow, ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat'])
};
// Fallback by relative offsets for common inventory sheets:
// STT | Ngay ve | Ma vat tu | Ten linh kien/sp | Model | DVT | ... | Ton cuoi ki | Ly do xuat | Tinh trang | Vi tri | Du an
if (indexMap.stt >= 0) {
if (indexMap.purchaseDate < 0) indexMap.purchaseDate = indexMap.stt + 1;
if (indexMap.assetCode < 0) indexMap.assetCode = indexMap.stt + 2;
if (indexMap.assetName < 0) indexMap.assetName = indexMap.stt + 3;
if (indexMap.model < 0) indexMap.model = indexMap.stt + 4;
if (indexMap.unit < 0) indexMap.unit = indexMap.stt + 5;
if (indexMap.quantity < 0) indexMap.quantity = indexMap.stt + 6;
if (indexMap.importInPeriod < 0) indexMap.importInPeriod = indexMap.stt + 7;
if (indexMap.exportInPeriod < 0) indexMap.exportInPeriod = indexMap.stt + 8;
if (indexMap.endingBalance < 0) indexMap.endingBalance = indexMap.stt + 9;
if (indexMap.notes < 0) indexMap.notes = indexMap.stt + 10;
if (indexMap.status < 0) indexMap.status = indexMap.stt + 11;
if (indexMap.location < 0) indexMap.location = indexMap.stt + 12;
if (indexMap.project < 0) indexMap.project = indexMap.stt + 13;
}
return indexMap;
}
mapImportedAssetRowsByColumnIndex(matrixRows, headerRowIndex) {
const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : [];
if (!headerRow.length) {
return [];
}
const indexMap = this.buildAssetImportIndexMap(headerRow);
if (indexMap.assetCode < 0 && indexMap.assetName < 0 && indexMap.model < 0 && indexMap.stt < 0) {
return [];
}
const pick = (row, index) => {
if (index < 0 || !Array.isArray(row)) {
return '';
}
return row[index] ?? '';
};
return matrixRows
.slice(headerRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const sttRaw = String(pick(row, indexMap.stt)).trim();
if (indexMap.stt >= 0) {
const normalizedStt = sttRaw.replace(/\.$/, '');
if (!normalizedStt || Number.isNaN(Number(normalizedStt))) {
return null;
}
}
const endingBalance = this.parseImportNumericValue(
pick(row, indexMap.endingBalance),
0
);
const mapped = {
assetCode: String(pick(row, indexMap.assetCode)).trim(),
assetName: String(pick(row, indexMap.assetName)).trim(),
model: String(pick(row, indexMap.model)).trim(),
serialNumber: String(pick(row, indexMap.serialNumber)).trim(),
quantity: this.parseImportNumericValue(pick(row, indexMap.quantity), 0),
importInPeriod: this.parseImportNumericValue(pick(row, indexMap.importInPeriod), 0),
exportInPeriod: this.parseImportNumericValue(pick(row, indexMap.exportInPeriod), 0),
endingBalance,
unit: String(pick(row, indexMap.unit)).trim(),
department: String(pick(row, indexMap.department)).trim(),
project: String(pick(row, indexMap.project)).trim(),
location: String(pick(row, indexMap.location)).trim(),
custodian: String(pick(row, indexMap.custodian)).trim(),
purchaseDate: pick(row, indexMap.purchaseDate),
purchasePrice: pick(row, indexMap.purchasePrice),
status: String(pick(row, indexMap.status)).trim(),
notes: String(pick(row, indexMap.notes)).trim()
};
return this.finalizeImportedAssetRow(mapped, headerRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !this.isLikelyHeaderArtifactAssetRow(row))
.filter(row => row.assetCode && row.assetName);
}
findBestAssetImportRowsFromMatrix(matrixRows) {
const maxScanRows = Math.min(Array.isArray(matrixRows) ? matrixRows.length : 0, 60);
let bestRows = [];
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(matrixRows[rowIndex]) ? matrixRows[rowIndex] : [];
if (!this.isLikelyAssetHeaderRow(row)) {
continue;
}
const candidateRows = this.mapImportedAssetRowsByColumnIndex(matrixRows, rowIndex);
if (candidateRows.length > bestRows.length) {
bestRows = candidateRows;
}
if (bestRows.length >= 10) {
break;
}
}
return bestRows;
}
mapImportedAssetRowsBySttPattern(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxScanRows = Math.min(rows.length, 60);
// Prefer dynamic header detection with STT, then parse by resolved column indexes.
let bestRows = [];
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
const hasStt = this.hasImportAliasInRow(row, ['STT', 'So thu tu']);
const hasName = this.hasImportAliasInRow(row, ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC']);
const hasModel = this.hasImportAliasInRow(row, ['Model', 'Dong may']);
const hasQty = this.hasImportAliasInRow(row, ['Ton dau ky', 'Ton dau ki', 'Quantity', 'So luong', 'SL', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki']);
if (!hasStt || (!hasName && !hasModel && !hasQty)) {
continue;
}
const candidateRows = this.mapImportedAssetRowsByColumnIndex(rows, rowIndex);
if (candidateRows.length > bestRows.length) {
bestRows = candidateRows;
}
}
if (bestRows.length >= 3) {
return bestRows;
}
// Last-resort fallback for shifted templates where columns are still in STT-order.
let detectedSttCol = -1;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
const sttCol = row.findIndex(cell => this.isImportHeaderMatch(cell, 'STT') || this.isImportHeaderMatch(cell, 'So thu tu'));
if (sttCol >= 0) {
detectedSttCol = sttCol;
break;
}
}
if (detectedSttCol < 0) {
detectedSttCol = 0;
}
const sttDataRows = rows.filter(row => {
if (!Array.isArray(row)) {
return false;
}
const stt = String(row[detectedSttCol] ?? '').trim().replace(/\.$/, '');
if (!/^\d+$/.test(stt)) {
return false;
}
const hasCoreValue = [2, 3, 4, 5, 9, 12]
.map(offset => detectedSttCol + offset)
.some(index => String(row[index] ?? '').trim() !== '');
return hasCoreValue;
});
if (sttDataRows.length < 3) {
return [];
}
return sttDataRows
.map((row, idx) => {
const endingBalance = this.parseImportNumericValue(row[detectedSttCol + 9] ?? '', 0);
const mapped = {
assetCode: String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(),
assetName: String(row[detectedSttCol + 3] ?? '').trim() || String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(),
model: String(row[detectedSttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: this.parseImportNumericValue(row[detectedSttCol + 6] ?? '', 0),
importInPeriod: this.parseImportNumericValue(row[detectedSttCol + 7] ?? '', 0),
exportInPeriod: this.parseImportNumericValue(row[detectedSttCol + 8] ?? '', 0),
endingBalance,
unit: String(row[detectedSttCol + 5] ?? '').trim(),
department: '',
project: String(row[detectedSttCol + 13] ?? '').trim(),
location: String(row[detectedSttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[detectedSttCol + 1] ?? '',
purchasePrice: '',
status: String(row[detectedSttCol + 11] ?? '').trim(),
notes: String(row[detectedSttCol + 10] ?? '').trim()
};
return this.finalizeImportedAssetRow(mapped, idx + 2);
})
.filter(row => !this.isLikelyHeaderArtifactAssetRow(row))
.filter(row => row.assetCode && row.assetName);
}
sanitizeAssetCodeToken(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
generateImportAssetCode(mapped, rowNumber = 0) {
const fromModel = this.sanitizeAssetCodeToken(mapped.model);
const fromSerial = this.sanitizeAssetCodeToken(mapped.serialNumber);
const fromName = this.sanitizeAssetCodeToken(mapped.assetName);
const base = fromModel || fromSerial || fromName || 'ASSET';
const suffix = String(rowNumber || 0).padStart(4, '0');
return `IMP-${base}-${suffix}`;
}
finalizeImportedAssetRow(mapped, rowNumber = 0) {
const result = { ...mapped };
if (!result.assetName) {
const fallbackName = String(result.model || result.serialNumber || result.assetCode || '').trim();
result.assetName = fallbackName;
}
if (!result.assetCode && result.assetName) {
result.assetCode = this.generateImportAssetCode(result, rowNumber);
}
return result;
}
mapImportedAssetRow(row, rowNumber = 0) {
const endingBalance = this.parseImportNumericValue(
this.findImportValue(row, ['Ton cuoi ki', 'Ton cuoi ky', 'Ton cuoi', 'Ending Balance']),
this.parseImportNumericValue(this.findImportValue(row, ['Quantity', 'So luong', 'SL']), 0)
);
const mapped = {
assetCode: String(this.findImportValue(row, ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'])).trim(),
assetName: String(this.findImportValue(row, ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC'])).trim(),
model: String(this.findImportValue(row, ['Model', 'Dong may'])).trim(),
serialNumber: String(this.findImportValue(row, ['Serial Number', 'Serial', 'So serial', 'So seri'])).trim(),
quantity: this.parseImportNumericValue(this.findImportValue(row, ['Ton dau ky', 'Ton dau ki', 'Opening Balance', 'Quantity', 'So luong', 'SL']), 0),
importInPeriod: this.parseImportNumericValue(this.findImportValue(row, ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap']), 0),
exportInPeriod: this.parseImportNumericValue(this.findImportValue(row, ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat']), 0),
endingBalance,
unit: String(this.findImportValue(row, ['Unit', 'Don vi', 'DVT'])).trim(),
department: String(this.findImportValue(row, ['Department', 'Bo phan', 'Phong ban'])).trim(),
project: String(this.findImportValue(row, ['Project', 'Du an', 'Cong trinh'])).trim(),
location: String(this.findImportValue(row, ['Location', 'Vi tri', 'Noi dat'])).trim(),
custodian: String(this.findImportValue(row, ['Custodian', 'Nguoi quan ly', 'Nguoi su dung'])).trim(),
purchaseDate: this.findImportValue(row, ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve']),
purchasePrice: this.findImportValue(row, ['Purchase Price', 'Gia mua', 'Don gia']),
status: String(this.findImportValue(row, ['Status', 'Trang thai', 'Tinh trang'])).trim(),
notes: String(this.findImportValue(row, ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat'])).trim()
};
const finalized = this.finalizeImportedAssetRow(mapped, rowNumber);
return this.isLikelyHeaderArtifactAssetRow(finalized) ? null : finalized;
}
shouldFallbackToClientAssetImport(statusCode, message = '') {
const normalizedMessage = String(message || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const parserErrorHints = [
'khong tim thay dong du lieu hop le',
'khong tim thay dong hop le',
'khong tim thay dong',
'cannot parse import file',
'excel file does not contain a worksheet',
'import data is empty',
'no valid rows found'
];
const isParserRelatedError = parserErrorHints.some(hint => normalizedMessage.includes(hint));
return (statusCode === 400 || statusCode === 422) && isParserRelatedError;
}
async importAssetsByFileUpload(file) {
if (!this.ensureAssetManagePermission('nhap du lieu tai san')) {
return {
uploaded: false,
shouldFallback: false,
message: 'No permission'
};
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(`${this.apiBase}/assets/import`, {
method: 'POST',
headers: this.getAuthHeaders(false),
body: formData
});
let data = null;
try {
data = await response.json();
} catch (parseErr) {
data = null;
}
if (!response.ok || !data?.success) {
const message = data?.message || 'Nhập Excel thất bại';
const shouldFallback = this.shouldFallbackToClientAssetImport(response.status, message);
console.warn('Asset file-upload import failed', {
status: response.status,
message,
diagnostics: data?.diagnostics || null,
shouldFallback
});
if (!shouldFallback) {
this.notifyFailure(message);
}
return {
uploaded: false,
shouldFallback,
message
};
}
this.notifySuccess(data.message || 'Nhập Excel thành công');
await this.refreshAssetsUI();
return {
uploaded: true,
shouldFallback: false
};
} catch (err) {
console.warn('Asset file-upload import network error, fallback to client parser', err);
return {
uploaded: false,
shouldFallback: true
};
}
}
async processAssetImportFile(event) {
if (!this.ensureAssetManagePermission('nhap du lieu tai san')) {
event.target.value = '';
return;
}
const file = event.target?.files?.[0];
if (!file) {
return;
}
try {
const uploadResult = await this.importAssetsByFileUpload(file);
if (uploadResult.uploaded || !uploadResult.shouldFallback) {
event.target.value = '';
return;
}
} catch (uploadErr) {
// Ignore and continue with client-side parser fallback.
}
if (!window.XLSX) {
this.notifyFailure('Chưa tải được thư viện xử lý Excel');
event.target.value = '';
return;
}
try {
const buffer = await file.arrayBuffer();
const workbook = window.XLSX.read(buffer, { type: 'array' });
const sheetNames = Array.isArray(workbook.SheetNames) ? workbook.SheetNames : [];
if (!sheetNames.length) {
this.notifyFailure('Tệp Excel không có sheet dữ liệu');
return;
}
let mappedRows = [];
let debugSheetName = '';
let debugHeaderRowIndex = -1;
let debugMatrixRows = [];
for (const sheetName of sheetNames) {
const sheet = workbook.Sheets[sheetName];
if (!sheet) {
continue;
}
const matrixRows = window.XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false
});
const headerRowIndex = this.findAssetImportHeaderRowIndex(matrixRows);
if (!debugMatrixRows.length) {
debugSheetName = sheetName;
debugHeaderRowIndex = headerRowIndex;
debugMatrixRows = matrixRows;
}
let candidateRows = [];
if (headerRowIndex >= 0) {
candidateRows = this.mapImportedAssetRowsFromMatrix(matrixRows, headerRowIndex);
if (!candidateRows.length) {
candidateRows = this.mapImportedAssetRowsByColumnIndex(matrixRows, headerRowIndex);
}
}
const bestRowsFromMatrix = this.findBestAssetImportRowsFromMatrix(matrixRows);
if (bestRowsFromMatrix.length > candidateRows.length) {
candidateRows = bestRowsFromMatrix;
}
const sttPatternRows = this.mapImportedAssetRowsBySttPattern(matrixRows);
if (sttPatternRows.length > candidateRows.length) {
candidateRows = sttPatternRows;
console.info('Asset import switched to STT-pattern parser', {
sheetName,
parsedRows: sttPatternRows.length
});
}
if (candidateRows.length > mappedRows.length) {
mappedRows = candidateRows;
debugSheetName = sheetName;
debugHeaderRowIndex = headerRowIndex;
debugMatrixRows = matrixRows;
}
}
if (!mappedRows.length) {
const headerPreview = debugMatrixRows
.slice(0, 8)
.map((row, rowIndex) => ({
rowIndex,
values: (Array.isArray(row) ? row : []).slice(0, 14).map(cell => String(cell ?? '').trim())
}))
.filter(item => item.values.some(value => value));
console.warn('Asset import parser could not find valid rows', {
sheetName: debugSheetName || sheetNames[0] || '',
headerRowIndex: debugHeaderRowIndex,
headerPreview
});
this.notifyWarning('Không tìm thấy dòng hợp lệ. Vui lòng kiểm tra dòng tiêu đề có cột mã/tên tài sản hoặc mã/tên vật tư.');
return;
}
const response = await fetch(`${this.apiBase}/assets/import`, {
method: 'POST',
headers: this.getAuthHeaders(true),
body: JSON.stringify({ rows: mappedRows })
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Nhập Excel thất bại');
return;
}
this.notifySuccess(data.message || 'Nhập Excel thành công');
await this.refreshAssetsUI();
} catch (err) {
console.error(err);
this.notifyFailure('Nhập Excel thất bại');
} finally {
event.target.value = '';
}
}
exportAssetsToExcel() {
if (!window.XLSX) {
this.notifyFailure('Chưa tải được thư viện xử lý Excel');
return;
}
const exportRows = this.assets.map(asset => ({
'Asset Code': asset.AssetCode || '',
'Asset Name': asset.AssetName || '',
'Model': asset.Model || '',
'Serial Number': asset.SerialNumber || '',
'Quantity': asset.Quantity || 0,
'Import In Period': asset.ImportInPeriod ?? 0,
'Export In Period': asset.ExportInPeriod ?? 0,
'Ending Balance': asset.EndingBalance ?? 0,
'Unit': asset.Unit || '',
'Department': asset.Department || '',
'Project': asset.Project || '',
'Location': asset.Location || '',
'Custodian': asset.Custodian || '',
'Borrower': asset.Borrower || '',
'Exported By': asset.ExportedBy || '',
'Purchase Date': this.toDateInputValue(asset.PurchaseDate),
'Purchase Price': asset.PurchasePrice || '',
'Status': asset.Status || '',
'Notes': asset.Notes || ''
}));
const worksheet = window.XLSX.utils.json_to_sheet(exportRows);
const workbook = window.XLSX.utils.book_new();
window.XLSX.utils.book_append_sheet(workbook, worksheet, 'TaiSan');
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
window.XLSX.writeFile(workbook, `danh-sach-tai-san-${timestamp}.xlsx`);
this.notifySuccess('Xuất Excel thành công');
}
renderAccountsTableBody() {
const tbody = document.querySelector('.accounts-table-body');
if (!tbody) return;
const currentUserId = this.getUserId();
const pageInfo = this.getPaged(this.getFilteredAccounts(), this.accountPage, this.accountPageSize);
this.accountPage = pageInfo.current;
tbody.innerHTML = pageInfo.data.map(acc => {
const isOwnAccount = acc.UserId == currentUserId;
const accountUsername = acc.AccountUsername || '-';
const displayAccountUsername = isOwnAccount
? accountUsername
: this.maskForeignAccountUsername(accountUsername);
const createdDate = this.formatDateTime(acc.CreatedDate);
const updatedDate = this.formatDateTime(acc.UpdatedDate);
const actionContent = isOwnAccount
? `
`
: '-';
return `
| ${acc.Email || '-'} |
${displayAccountUsername} |
${acc.AppName || '-'}
|
${createdDate} |
${updatedDate} |
${actionContent}
|
`;
}).join('');
const pager = document.getElementById('accountsPager');
if (pager) {
pager.innerHTML = `
Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`;
}
this.setupAccountRowListeners();
this.setupAccountPagerListeners();
}
renderApplicationsTableBody() {
const tbody = document.querySelector('.apps-table-body');
if (!tbody) return;
const pageInfo = this.getPaged(this.getFilteredApplications(), this.appPage, this.appPageSize);
this.appPage = pageInfo.current;
tbody.innerHTML = pageInfo.data.map(app => `
${app.Icon || 'apps'}
${app.Name}
|
${app.Type}
|
${app.Description || '-'} |
${(app.Url || app.url) ? `${app.Url || app.url}` : '-'} |
${(app.Status || app.status) === 'online' ? 'Online' : 'Offline'}
|
|
`).join('');
const pager = document.getElementById('appsPager');
if (pager) {
pager.innerHTML = `
Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`;
}
this.setupAccountRowListeners();
this.setupAppPagerListeners();
}
setupAccountPagerListeners() {
document.querySelectorAll('.account-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetPage = Number(btn.dataset.page);
if (!targetPage || targetPage < 1) return;
this.accountPage = targetPage;
this.renderAccountsTableBody();
});
});
}
setupAppPagerListeners() {
document.querySelectorAll('.app-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetPage = Number(btn.dataset.page);
if (!targetPage || targetPage < 1) return;
this.appPage = targetPage;
this.renderApplicationsTableBody();
});
});
}
setupAccountRowListeners() {
// View Account listeners
document.querySelectorAll('.view-account').forEach(btn => {
btn.addEventListener('click', (e) => {
if (btn.disabled) return; // Only view own accounts
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
this.currentViewAccountId = accountId;
this.currentViewAccount = account;
document.getElementById('viewAccountService').textContent = account?.AppName || '-';
document.getElementById('viewAccountOwner').textContent = account?.Email || '-';
document.getElementById('viewAccountUsername').textContent = account?.AccountUsername || '-';
const passwordEl = document.getElementById('viewAccountPassword');
const toggleBtn = document.querySelector('.toggle-password');
const toggleIcon = document.getElementById('toggleIcon');
const storedPwd = account?.AccountPassword || '';
passwordEl.dataset.password = storedPwd;
passwordEl.textContent = storedPwd ? '••••••••' : '(no password stored)';
passwordEl.dataset.visible = 'false';
if (toggleIcon) toggleIcon.textContent = 'visibility';
// Rebind toggle each time modal opens to keep state fresh
if (toggleBtn) {
toggleBtn.onclick = () => {
const currentPwd = passwordEl.dataset.password || '';
const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) {
passwordEl.textContent = currentPwd ? '••••••••' : '(no password stored)';
passwordEl.dataset.visible = 'false';
if (toggleIcon) toggleIcon.textContent = 'visibility';
} else {
passwordEl.textContent = currentPwd || '(no password stored)';
passwordEl.dataset.visible = 'true';
if (toggleIcon) toggleIcon.textContent = 'visibility_off';
}
};
}
document.getElementById('viewAccountModal').classList.add('open');
});
});
// Delete Account listeners - show confirmation modal
document.querySelectorAll('.delete-account').forEach(btn => {
btn.addEventListener('click', (e) => {
if (btn.disabled) return; // Don't delete others' accounts
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
this.pendingDeleteAccountId = accountId;
document.getElementById('deleteAccountUsername').textContent = account?.AccountUsername || '';
document.getElementById('deleteAccountModal').classList.add('open');
});
});
// Confirm Delete Account
document.querySelectorAll('.confirm-delete-account').forEach(btn => {
btn.addEventListener('click', () => {
if (this.pendingDeleteAccountId !== undefined) {
fetch(`${this.apiBase}/accounts/${this.pendingDeleteAccountId}`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
this.notifySuccess('Account deleted successfully');
this.closeModals();
this.refreshAccountsUI();
} else {
this.notifyFailure(data.message || 'Delete account failed');
}
})
.catch(err => {
console.error(err);
this.notifyFailure('Delete account failed');
});
}
});
});
// Edit Account listeners
document.querySelectorAll('.edit-account').forEach(btn => {
btn.addEventListener('click', (e) => {
if (btn.disabled) return; // Don't edit others' accounts
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
// Populate form with existing data
const form = document.getElementById('accountForm');
if (form) {
const userInput = form.querySelector('#accountUsername');
const passInput = form.querySelector('#accountPassword');
const ownerInput = form.querySelector('#accountOwner');
const serviceSelect = form.querySelector('#accountService');
if (userInput) userInput.value = account?.AccountUsername || '';
if (passInput) passInput.value = account?.AccountPassword || '';
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (serviceSelect) serviceSelect.value = account?.AppId || '';
}
this.pendingAccountAppId = account?.AppId;
this.editingAccountId = account?.AccountId;
this.closeModals();
this.openAccountModal();
});
});
// Edit from View modal
document.querySelectorAll('.edit-account-from-view').forEach(btn => {
btn.addEventListener('click', () => {
const account = this.currentViewAccount;
const form = document.getElementById('accountForm');
if (form) {
const userInput = form.querySelector('#accountUsername');
const passInput = form.querySelector('#accountPassword');
const ownerInput = form.querySelector('#accountOwner');
const serviceSelect = form.querySelector('#accountService');
if (userInput) userInput.value = account?.AccountUsername || '';
if (passInput) passInput.value = account?.AccountPassword || '';
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (serviceSelect) serviceSelect.value = account?.AppId || '';
}
this.pendingAccountAppId = account?.AppId;
this.editingAccountId = account?.AccountId;
this.closeModals();
this.openAccountModal();
});
});
// View App listeners
document.querySelectorAll('.view-app').forEach(btn => {
btn.addEventListener('click', (e) => {
const appId = Number(btn.dataset.appId);
const app = this.applications.find(a => a.AppId === appId);
this.currentViewAppId = appId;
document.getElementById('viewAppName').textContent = app?.Name || '-';
document.getElementById('viewAppType').textContent = app?.Type || '-';
const iconVal = app?.Icon || app?.icon || 'apps';
const iconSymbolEl = document.getElementById('viewAppIconSymbol');
const iconNameEl = document.getElementById('viewAppIconName');
if (iconSymbolEl) iconSymbolEl.textContent = iconVal;
if (iconNameEl) iconNameEl.textContent = iconVal;
document.getElementById('viewAppDescription').textContent = app?.Description || '-';
const urlEl = document.getElementById('viewAppUrl');
const urlVal = app?.Url || app?.url;
if (urlEl) {
if (urlVal) {
urlEl.innerHTML = `${urlVal}`;
} else {
urlEl.textContent = '-';
}
}
const statusValue = app?.Status || app?.status;
document.getElementById('viewAppStatus').textContent = statusValue === 'online' ? 'Online' : 'Offline';
document.getElementById('viewAppModal').classList.add('open');
});
});
// Delete App listeners - show confirmation modal
document.querySelectorAll('.delete-app').forEach(btn => {
btn.addEventListener('click', (e) => {
const appId = Number(btn.dataset.appId);
const app = this.applications.find(a => a.AppId === appId);
this.pendingDeleteAppId = appId;
document.getElementById('deleteAppName').textContent = app?.Name || '';
document.getElementById('deleteAppModal').classList.add('open');
});
});
// Confirm Delete App
document.querySelectorAll('.confirm-delete-app').forEach(btn => {
btn.addEventListener('click', () => {
if (this.pendingDeleteAppId !== undefined) {
fetch(`${this.apiBase}/applications/${this.pendingDeleteAppId}`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
this.notifySuccess('Application deleted successfully');
this.closeModals();
this.refreshApplicationsUI();
} else {
this.notifyFailure(data.message || 'Delete application failed');
}
})
.catch(err => {
console.error(err);
this.notifyFailure('Delete application failed');
});
}
});
});
// Edit App listeners
document.querySelectorAll('.edit-app').forEach(btn => {
btn.addEventListener('click', (e) => {
const appId = Number(btn.dataset.appId);
const app = this.applications.find(a => a.AppId === appId);
document.getElementById('appName').value = app?.Name || '';
document.getElementById('appType').value = app?.Type || '';
document.getElementById('appStatus').value = app?.Status || 'online';
document.getElementById('appDescription').value = app?.Description || '';
document.getElementById('appIcon').value = app?.Icon || app?.icon || '';
document.getElementById('appUrl').value = app?.Url || app?.url || '';
this.editingAppId = app?.AppId;
this.closeModals();
this.openAppModal();
});
});
// Edit App from View modal
document.querySelectorAll('.edit-app-from-view').forEach(btn => {
btn.addEventListener('click', () => {
const appId = this.currentViewAppId;
const app = this.applications.find(a => a.AppId === appId);
document.getElementById('appName').value = app?.Name || '';
document.getElementById('appType').value = app?.Type || '';
document.getElementById('appStatus').value = app?.Status || 'online';
document.getElementById('appDescription').value = app?.Description || '';
document.getElementById('appIcon').value = app?.Icon || app?.icon || '';
document.getElementById('appUrl').value = app?.Url || '';
this.editingAppId = app?.AppId;
this.closeModals();
this.openAppModal();
});
});
}
setupAddButtonListeners() {
// Add Account button
document.querySelectorAll('#addAccountBtn').forEach(btn => {
btn.addEventListener('click', () => {
this.editingAccountId = undefined;
this.pendingAccountAppId = undefined;
this.openAccountModal();
});
});
// Add Application button
document.querySelectorAll('#addAppBtn').forEach(btn => {
btn.addEventListener('click', () => {
this.editingAppId = undefined;
this.openAppModal();
});
});
// Add Asset button
document.querySelectorAll('#addAssetBtn').forEach(btn => {
btn.addEventListener('click', () => {
this.editingAssetId = undefined;
this.openAssetModal();
});
});
const borrowAssetBtn = document.getElementById('borrowAssetBtn');
if (borrowAssetBtn && !borrowAssetBtn.dataset.boundClick) {
borrowAssetBtn.addEventListener('click', () => this.openBorrowAssetModal());
borrowAssetBtn.dataset.boundClick = 'true';
}
const importAssetBtn = document.getElementById('importAssetBtn');
const assetImportInput = document.getElementById('assetImportInput');
const exportAssetBtn = document.getElementById('exportAssetBtn');
if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) {
importAssetBtn.addEventListener('click', () => {
if (!this.ensureAssetManagePermission('nhap du lieu tai san')) {
return;
}
assetImportInput.click();
});
importAssetBtn.dataset.boundClick = 'true';
}
if (assetImportInput && !assetImportInput.dataset.boundChange) {
assetImportInput.addEventListener('change', (event) => this.processAssetImportFile(event));
assetImportInput.dataset.boundChange = 'true';
}
if (exportAssetBtn && !exportAssetBtn.dataset.boundClick) {
exportAssetBtn.addEventListener('click', () => this.exportAssetsToExcel());
exportAssetBtn.dataset.boundClick = 'true';
}
}
setupFilters() {
const serviceFilter = document.getElementById('serviceFilter');
if (serviceFilter) {
serviceFilter.value = this.accountServiceFilter || '';
serviceFilter.addEventListener('change', (e) => {
this.accountServiceFilter = e.target.value;
this.renderAccountsTableBody();
});
}
const accountSearch = document.getElementById('accountSearch');
if (accountSearch) {
accountSearch.value = this.accountSearchTerm;
const handleAccountSearch = event => {
this.accountSearchTerm = event.target.value.toLowerCase();
this.renderAccountsTableBody();
};
accountSearch.addEventListener('input', handleAccountSearch);
// Restore focus/selection after renders to avoid typing interruptions
accountSearch.addEventListener('focus', () => {
accountSearch.dataset.focused = 'true';
});
accountSearch.addEventListener('blur', () => {
accountSearch.dataset.focused = 'false';
});
}
const appSearch = document.getElementById('appSearch');
if (appSearch) {
appSearch.value = this.applicationSearchTerm;
const handleApplicationSearch = event => {
this.applicationSearchTerm = event.target.value.toLowerCase();
this.renderApplicationsTableBody();
};
appSearch.addEventListener('input', handleApplicationSearch);
// Restore focus/selection after renders to avoid typing interruptions
appSearch.addEventListener('focus', () => {
appSearch.dataset.focused = 'true';
});
appSearch.addEventListener('blur', () => {
appSearch.dataset.focused = 'false';
});
}
const assetStatusFilter = document.getElementById('assetStatusFilter');
if (assetStatusFilter) {
assetStatusFilter.value = this.assetStatusFilter || '';
assetStatusFilter.addEventListener('change', (e) => {
this.assetStatusFilter = String(e.target.value || '').toLowerCase();
this.assetPage = 1;
this.renderAssetsTableBody();
});
}
const assetSearch = document.getElementById('assetSearch');
if (assetSearch) {
assetSearch.value = this.assetSearchTerm;
const handleAssetSearch = event => {
this.assetSearchTerm = event.target.value.toLowerCase();
this.assetPage = 1;
this.renderAssetsTableBody();
};
assetSearch.addEventListener('input', handleAssetSearch);
assetSearch.addEventListener('focus', () => {
assetSearch.dataset.focused = 'true';
});
assetSearch.addEventListener('blur', () => {
assetSearch.dataset.focused = 'false';
});
}
}
async handleAccountSubmit(e) {
e.preventDefault();
const accountForm = document.getElementById('accountForm');
const userId = this.getUserId();
const appId = Number(accountForm?.querySelector('#accountService')?.value || 0);
const accountUsername = (accountForm?.querySelector('#accountUsername')?.value || '').trim();
const accountPassword = (accountForm?.querySelector('#accountPassword')?.value || '').trim();
const accountEmail = ((accountForm?.querySelector('#accountOwner')?.value || '').trim()) || this.currentUser?.Username || this.currentUser?.username || '';
if (!accountForm) {
this.notifyFailure('Account form not found.');
return;
}
if (!userId) {
this.notifyFailure('User is not authenticated. Please login again.');
return;
}
if (!appId) {
this.notifyWarning('Please select a service.');
return;
}
if (!accountUsername) {
this.notifyWarning('Please enter a username.');
return;
}
if (!accountPassword) {
this.notifyWarning('Please enter a password.');
return;
}
const payload = {
userId,
appId,
accountUsername,
accountPassword,
email: accountEmail,
accessLevel: 'user',
notes: ''
};
const isEdit = this.editingAccountId !== undefined;
const url = isEdit ? `${this.apiBase}/accounts/${this.editingAccountId}` : `${this.apiBase}/accounts`;
const method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(res => res.json()).then(data => {
if (data.success) {
this.editingAccountId = undefined;
this.pendingAccountAppId = undefined;
this.notifySuccess(isEdit ? 'Account updated successfully' : 'Account created successfully');
this.closeModals();
this.refreshAccountsUI();
} else {
this.notifyFailure(data.message || 'Save account failed');
}
}).catch(err => {
console.error(err);
this.notifyFailure('Save account failed');
});
}
async refreshAccountsUI() {
await this.fetchAccounts();
if (this.currentPage === 'accounts') {
this.renderView('accounts');
}
}
async handleAppSubmit(e) {
e.preventDefault();
const payload = {
name: document.getElementById('appName').value,
type: document.getElementById('appType').value,
status: document.getElementById('appStatus').value,
icon: (document.getElementById('appIcon')?.value || 'apps').trim() || 'apps',
description: document.getElementById('appDescription')?.value || '',
url: (document.getElementById('appUrl')?.value || '').trim()
};
const isEdit = this.editingAppId !== undefined;
const url = isEdit ? `${this.apiBase}/applications/${this.editingAppId}` : `${this.apiBase}/applications`;
const method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(res => res.json()).then(data => {
if (data.success) {
this.editingAppId = undefined;
this.notifySuccess(isEdit ? 'Application updated successfully' : 'Application created successfully');
this.closeModals();
this.refreshApplicationsUI();
} else {
this.notifyFailure(data.message || 'Save application failed');
}
}).catch(err => {
console.error(err);
this.notifyFailure('Save application failed');
});
}
async refreshApplicationsUI() {
await this.fetchApplications();
if (this.currentPage === 'applications') {
this.renderView('applications');
}
}
openAccountModal() {
// Refresh service options so newly added applications appear
const serviceSelect = document.getElementById('accountService');
if (serviceSelect) {
serviceSelect.innerHTML = `` +
this.applications.map(app => ``).join('');
if (this.editingAccountId !== undefined && this.pendingAccountAppId) {
serviceSelect.value = this.pendingAccountAppId;
}
}
if (this.editingAccountId === undefined) {
const form = document.getElementById('accountForm');
if (form) {
const serviceSelect = form.querySelector('#accountService');
const ownerInput = form.querySelector('#accountOwner');
const userInput = form.querySelector('#accountUsername');
const passInput = form.querySelector('#accountPassword');
if (serviceSelect) serviceSelect.value = '';
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (userInput) userInput.value = '';
if (passInput) passInput.value = '';
}
}
document.getElementById('accountModal').classList.add('open');
}
openAppModal() {
if (this.editingAppId === undefined) {
document.getElementById('appName').value = '';
document.getElementById('appType').value = '';
document.getElementById('appStatus').value = 'online';
const iconInput = document.getElementById('appIcon');
const desc = document.getElementById('appDescription');
const url = document.getElementById('appUrl');
if (iconInput) iconInput.value = '';
if (desc) desc.value = '';
if (url) url.value = '';
}
document.getElementById('appModal').classList.add('open');
}
closeModals() {
document.querySelectorAll('.modal-backdrop').forEach(modal => {
modal.classList.remove('open');
});
}
async openProfileModal() {
try {
const response = await fetch(`${this.apiBase}/users/me`, {
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!data.success || !data.data) {
this.notifyFailure(data.message || 'Cannot load profile');
return;
}
this.renderProfileModal(data.data);
} catch (err) {
console.error(err);
this.notifyFailure('Cannot load profile');
}
}
renderProfileModal(profile) {
const isVerified = profile?.EmailVerified === true || profile?.EmailVerified === 1;
const html = `
My Profile
Update personal info and password in one place.
`;
const containerId = 'profileModalContainer';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
container.innerHTML = html;
const form = document.getElementById('profileForm');
if (form) {
form.addEventListener('submit', (e) => this.saveProfile(e));
}
this.setupProfilePasswordToggles();
const modal = document.getElementById('profileModal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeProfileModal();
}
});
}
}
setupProfilePasswordToggles() {
document.querySelectorAll('[data-password-toggle]').forEach((toggleBtn) => {
if (toggleBtn.dataset.bound === 'true') {
return;
}
toggleBtn.addEventListener('click', () => {
const inputId = toggleBtn.dataset.passwordToggle;
if (!inputId) return;
const input = document.getElementById(inputId);
const icon = document.getElementById(`${inputId}Icon`);
if (!input) return;
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
if (icon) {
icon.textContent = isHidden ? 'visibility_off' : 'visibility';
}
});
toggleBtn.dataset.bound = 'true';
});
}
async saveProfile(e) {
e.preventDefault();
const fullname = document.getElementById('profileFullName')?.value.trim() || '';
const email = document.getElementById('profileEmail')?.value.trim() || '';
const currentPassword = document.getElementById('profileCurrentPassword')?.value || '';
const newPassword = document.getElementById('profileNewPassword')?.value || '';
const confirmPassword = document.getElementById('profileConfirmPassword')?.value || '';
if (!fullname || !email) {
this.notifyFailure('Full name and email are required');
return;
}
if (newPassword && newPassword !== confirmPassword) {
this.notifyFailure('New password and confirm password do not match');
return;
}
if (newPassword && !currentPassword) {
this.notifyFailure('Current password is required to change password');
return;
}
try {
const response = await fetch(`${this.apiBase}/users/me`, {
method: 'PUT',
headers: this.getAuthHeaders(true),
body: JSON.stringify({
fullname,
email,
currentPassword,
newPassword
})
});
const data = await response.json();
if (!data.success) {
this.notifyFailure(data.message || 'Update profile failed');
return;
}
if (data.user) {
this.currentUser = {
...this.currentUser,
...data.user,
role: data.user.role || data.user.Role || this.currentUser.role || this.currentUser.Role
};
this.saveToStorage('currentUser', this.currentUser);
this.updateAccountDisplay();
}
closeProfileModal();
this.notifySuccess(data.message || 'Profile updated');
if (data.verificationRequired && data.emailSent === false) {
if (data.verificationPreviewUrl) {
this.notifyWarning(`Email confirmation link (dev): ${data.verificationPreviewUrl}`);
} else {
this.notifyWarning('Email changed but verification email could not be sent.');
}
}
} catch (err) {
console.error(err);
this.notifyFailure('Update profile failed');
}
}
loadFromStorage(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
saveToStorage(key, data) {
localStorage.setItem(key, JSON.stringify(data));
}
formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString();
}
// ========== Users Management ==========
getUsersContent() {
const filteredUsers = this.getFilteredUsers();
const pageInfo = this.getPaged(filteredUsers, this.userPage, this.userPageSize);
this.userPage = pageInfo.current;
return `
| Username |
Full Name |
Email |
Role |
Status |
Actions |
${pageInfo.data.length === 0 ? `
| No users found |
` : pageInfo.data.map(user => `
| ${user.Username} |
${user.FullName || '-'} |
${user.Email || '-'} |
${user.RoleName || user.Role || 'N/A'}
|
${user.IsActive ? 'Active' : 'Inactive'}
|
|
`).join('')}
`;
}
setupUsersRowListeners() {
const userRows = document.querySelectorAll('.user-row');
userRows.forEach(row => {
const viewBtn = row.querySelector('.view-user-btn');
const editBtn = row.querySelector('.edit-user-btn');
const deleteBtn = row.querySelector('.delete-user-btn');
const userId = row.dataset.userId;
if (viewBtn) {
viewBtn.addEventListener('click', () => this.viewUserDetails(userId));
}
if (editBtn) {
editBtn.addEventListener('click', () => this.editUser(userId));
}
if (deleteBtn && !deleteBtn.disabled) {
deleteBtn.addEventListener('click', () => this.deleteUserConfirm(userId));
}
});
// Search and Filter
const searchInput = document.getElementById('userSearch');
const roleFilter = document.getElementById('roleFilter');
if (searchInput) {
searchInput.oninput = (e) => {
this.userSearchTerm = (e.target.value || '').toLowerCase();
this.userPage = 1;
this.renderUsersTableBody();
};
}
if (roleFilter) {
roleFilter.value = this.userRoleFilter || '';
roleFilter.onchange = (e) => {
this.userRoleFilter = e.target.value;
this.userPage = 1;
this.renderUsersTableBody();
};
}
// Add User Button
const addBtn = document.getElementById('addUserBtn');
if (addBtn) {
addBtn.onclick = () => this.openUserModal();
}
const addRoleBtn = document.getElementById('addRoleBtn');
if (addRoleBtn) {
addRoleBtn.onclick = () => this.openRoleModal();
}
}
getFilteredUsers() {
const search = (this.userSearchTerm || '').toLowerCase();
const roleId = this.userRoleFilter || '';
return this.users.filter(user => {
const matchesSearch = !search || [user.Username, user.FullName, user.Email].some(val => (val || '').toLowerCase().includes(search));
const matchesRole = !roleId || String(user.RoleId) === String(roleId) || String(user.RoleID) === String(roleId);
return matchesSearch && matchesRole;
});
}
renderUsersTableBody() {
const tbody = document.querySelector('.users-table-body');
if (!tbody) return;
const pageInfo = this.getPaged(this.getFilteredUsers(), this.userPage, this.userPageSize);
this.userPage = pageInfo.current;
tbody.innerHTML = pageInfo.data.length === 0 ? `
| No users found |
` : pageInfo.data.map(user => `
| ${user.Username} |
${user.FullName || '-'} |
${user.Email || '-'} |
${user.RoleName || user.Role || 'N/A'}
|
${user.IsActive ? 'Active' : 'Inactive'}
|
|
`).join('');
const pager = document.getElementById('usersPager');
if (pager) {
pager.innerHTML = `
Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total}
Page ${pageInfo.current} / ${pageInfo.totalPages}
`;
}
this.setupUsersRowListeners();
this.setupUsersPagerListeners();
}
setupUsersPagerListeners() {
document.querySelectorAll('.user-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetPage = Number(btn.dataset.page);
if (!targetPage || targetPage < 1) return;
this.userPage = targetPage;
this.renderUsersTableBody();
});
});
}
openUserModal() {
this.showUserFormModal(null);
}
openRoleModal() {
this.showRoleFormModal();
}
showRoleFormModal() {
const html = `
`;
const editingContainer = document.getElementById('roleModalContainer');
if (editingContainer) {
editingContainer.innerHTML = html;
} else {
const container = document.createElement('div');
container.id = 'roleModalContainer';
document.body.appendChild(container);
container.innerHTML = html;
}
const form = document.getElementById('roleForm');
if (form) {
form.addEventListener('submit', (e) => this.saveRole(e));
}
const modal = document.getElementById('roleModal');
if (modal) {
modal.addEventListener('click', function(e) {
if (e.target === this) {
closeRoleModal();
}
});
}
}
async saveRole(e) {
e.preventDefault();
const roleName = document.getElementById('roleName')?.value.trim();
const description = document.getElementById('roleDescription')?.value.trim();
if (!roleName) {
this.notifyFailure('Role name is required');
return;
}
const roleExists = this.roles.some(role =>
String(role.RoleName || '').trim().toLowerCase() === roleName.toLowerCase()
);
if (roleExists) {
this.notifyWarning('Role already exists');
return;
}
try {
const response = await fetch(`${this.apiBase}/roles`, {
method: 'POST',
headers: this.getAuthHeaders(true),
body: JSON.stringify({ roleName, description: description || null })
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Create role failed');
return;
}
this.notifySuccess('Role created');
closeRoleModal();
await this.fetchRoles();
if (this.currentPage === 'users') {
this.renderView('users');
}
} catch (err) {
console.error(err);
this.notifyFailure('Create role failed');
}
}
showUserFormModal(userId) {
const user = userId ? this.users.find(u => u.UserId == userId) : null;
const html = `
`;
// Insert modal in DOM
const editingContainer = document.getElementById('userModalContainer');
if (editingContainer) {
editingContainer.innerHTML = html;
} else {
const container = document.createElement('div');
container.id = 'userModalContainer';
document.body.appendChild(container);
container.innerHTML = html;
}
// Add form submit listener
const form = document.getElementById('userForm');
if (form) {
form.addEventListener('submit', (e) => this.saveUser(e, userId));
}
const passwordInput = document.getElementById('userPassword');
const passwordToggleBtn = document.getElementById('userPasswordToggle');
const passwordToggleIcon = document.getElementById('userPasswordToggleIcon');
if (passwordInput && passwordToggleBtn) {
passwordToggleBtn.addEventListener('click', () => {
const isHidden = passwordInput.type === 'password';
passwordInput.type = isHidden ? 'text' : 'password';
if (passwordToggleIcon) {
passwordToggleIcon.textContent = isHidden ? 'visibility_off' : 'visibility';
}
});
}
// Close on backdrop click
const modal = document.getElementById('userModal');
if (modal) {
modal.addEventListener('click', function(e) {
if (e.target === this) {
closeUserModal();
}
});
}
}
async saveUser(e, userId) {
e.preventDefault();
const username = document.getElementById('userUsername').value.trim();
const fullname = document.getElementById('userFullName').value.trim();
const email = document.getElementById('userEmail').value.trim();
const password = document.getElementById('userPassword')?.value.trim();
const roleId = document.getElementById('userRole').value;
const isActive = document.getElementById('userActive').checked;
// Validate required fields
if (!username || !fullname) {
this.notifyFailure('Username and Full Name are required');
return;
}
// Password required for new user
if (!userId && !password) {
this.notifyFailure('Password is required for new user');
return;
}
const method = userId ? 'PUT' : 'POST';
const url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`;
const payload = userId
? {
email: email || null,
fullname,
roleId: parseInt(roleId),
status: 'Active',
isActive,
...(password ? { password } : {})
}
: { username, password, email: email || null, fullname, roleId: parseInt(roleId) };
try {
const response = await fetch(url, {
method,
headers: this.getAuthHeaders(true),
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
this.notifySuccess(userId ? 'User updated' : 'User created');
closeUserModal();
this.refreshUsersUI();
} else {
this.notifyFailure(data.message || 'Save failed');
}
} catch (err) {
console.error(err);
this.notifyFailure('Save failed');
}
}
async editUser(userId) {
this.showUserFormModal(userId);
}
async viewUserDetails(userId) {
try {
const response = await fetch(`${this.apiBase}/users/${userId}`, {
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!data.success || !data.data) {
this.notifyFailure(data.message || 'Cannot load user details');
return;
}
this.showUserDetailsModal(data.data);
} catch (err) {
console.error(err);
this.notifyFailure('Cannot load user details');
}
}
showUserDetailsModal(user) {
const html = `
User Details
Mật khẩu text thường (không phải hash) nếu tài khoản đã được lưu theo chuẩn mới.
`;
const detailsContainer = document.getElementById('userDetailsModalContainer');
if (detailsContainer) {
detailsContainer.innerHTML = html;
} else {
const container = document.createElement('div');
container.id = 'userDetailsModalContainer';
container.innerHTML = html;
document.body.appendChild(container);
}
const passwordValue = user?.Password || '';
const hasReadablePassword = user?.PasswordAvailable === true || Boolean(passwordValue);
const usernameEl = document.getElementById('userDetailUsername');
const fullNameEl = document.getElementById('userDetailFullName');
const emailEl = document.getElementById('userDetailEmail');
const roleEl = document.getElementById('userDetailRole');
const statusEl = document.getElementById('userDetailStatus');
const createdDateEl = document.getElementById('userDetailCreatedDate');
const lastLoginEl = document.getElementById('userDetailLastLogin');
const passwordEl = document.getElementById('userDetailPassword');
const passwordToggleBtn = document.getElementById('userDetailPasswordToggle');
const passwordToggleIcon = document.getElementById('userDetailPasswordToggleIcon');
const editBtn = document.getElementById('userDetailEditBtn');
const detailsModal = document.getElementById('userDetailsModal');
if (usernameEl) usernameEl.textContent = user?.Username || '-';
if (fullNameEl) fullNameEl.textContent = user?.FullName || '-';
if (emailEl) emailEl.textContent = user?.Email || '-';
if (roleEl) roleEl.textContent = user?.RoleName || user?.Role || '-';
if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive';
if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate);
if (lastLoginEl) lastLoginEl.textContent = this.formatDateTime(user?.LastLogin);
if (passwordEl) {
passwordEl.dataset.password = passwordValue;
passwordEl.dataset.visible = 'false';
passwordEl.textContent = hasReadablePassword ? '••••••••' : '(khong the hien thi - can reset password 1 lan)';
}
if (passwordToggleBtn && passwordEl) {
passwordToggleBtn.addEventListener('click', () => {
if (!hasReadablePassword) {
return;
}
const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) {
passwordEl.textContent = '••••••••';
passwordEl.dataset.visible = 'false';
if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility';
} else {
passwordEl.textContent = passwordValue;
passwordEl.dataset.visible = 'true';
if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility_off';
}
});
}
if (passwordToggleBtn && !hasReadablePassword) {
passwordToggleBtn.disabled = true;
passwordToggleBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
if (editBtn) {
editBtn.addEventListener('click', () => {
closeUserDetailsModal();
this.editUser(user?.UserId);
});
}
if (detailsModal) {
detailsModal.addEventListener('click', function(e) {
if (e.target === this) {
closeUserDetailsModal();
}
});
}
}
async deleteUserConfirm(userId) {
const user = this.users.find(u => u.UserId == userId);
if (!user) return;
if (confirm(`Are you sure you want to delete user "${user.Username}"?`)) {
await this.deleteUser(userId);
}
}
async deleteUser(userId) {
try {
const response = await fetch(`${this.apiBase}/users/${userId}`, {
method: 'DELETE',
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (data.success) {
this.notifySuccess('User deleted');
this.refreshUsersUI();
} else {
this.notifyFailure(data.message || 'Delete failed');
}
} catch (err) {
console.error(err);
this.notifyFailure('Delete failed');
}
}
async refreshUsersUI() {
await this.fetchUsers();
if (this.currentPage === 'users') {
this.renderView('users');
}
}
}
// Global modal close functions
function closeAllModals() {
document.querySelectorAll('.modal-backdrop').forEach(modal => {
modal.classList.remove('open');
});
}
function closeAccountModal() {
document.getElementById('accountModal').classList.remove('open');
}
function closeViewAccountModal() {
document.getElementById('viewAccountModal').classList.remove('open');
}
function closeDeleteAccountModal() {
document.getElementById('deleteAccountModal').classList.remove('open');
}
function closeAppModal() {
document.getElementById('appModal').classList.remove('open');
}
function closeViewAppModal() {
document.getElementById('viewAppModal').classList.remove('open');
}
function closeDeleteAppModal() {
document.getElementById('deleteAppModal').classList.remove('open');
}
function closeAssetModal() {
document.getElementById('assetModal').classList.remove('open');
}
function closeViewAssetModal() {
document.getElementById('viewAssetModal').classList.remove('open');
}
function closeDeleteAssetModal() {
document.getElementById('deleteAssetModal').classList.remove('open');
}
function closeBorrowAssetModal() {
const modal = document.getElementById('borrowAssetModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeUserModal() {
const userModalContainer = document.getElementById('userModalContainer');
if (userModalContainer) {
userModalContainer.innerHTML = '';
}
}
function closeRoleModal() {
const roleModalContainer = document.getElementById('roleModalContainer');
if (roleModalContainer) {
roleModalContainer.innerHTML = '';
}
}
function closeUserDetailsModal() {
const detailsContainer = document.getElementById('userDetailsModalContainer');
if (detailsContainer) {
detailsContainer.innerHTML = '';
}
}
function closeProfileModal() {
const profileContainer = document.getElementById('profileModalContainer');
if (profileContainer) {
profileContainer.innerHTML = '';
}
}
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new AccountManager();
});