From ba2e449c8859b1a599d910aa6c863fcd65f4502e Mon Sep 17 00:00:00 2001 From: DungTT Date: Wed, 22 Apr 2026 10:09:37 +0700 Subject: [PATCH] import --- backend/server.js | 1143 +++++++++++++++++++++++++++++- database/setup.sql | 49 +- package-lock.json | 178 ++++- package.json | 4 +- public/js/app.js | 1487 +++++++++++++++++++++++++++++++++++++++ public/modals.html | 123 ++++ public/pages/index.html | 88 ++- 7 files changed, 3040 insertions(+), 32 deletions(-) diff --git a/backend/server.js b/backend/server.js index f9fb9bd..20299db 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,6 +7,8 @@ const cors = require('cors'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const nodemailer = require('nodemailer'); +const multer = require('multer'); +const XLSX = require('xlsx'); const dotenv = require('dotenv'); const app = express(); @@ -184,9 +186,796 @@ function getUserIdFromRequest(req) { return Number.isInteger(userId) && userId > 0 ? userId : null; } +function parsePositiveInteger(value, fallback = 1) { + const parsed = Number(value); + if (Number.isInteger(parsed) && parsed > 0) { + return parsed; + } + return fallback; +} + +function parseNullableDecimal(value) { + if (value === undefined || value === null) { + return null; + } + + const normalized = String(value).trim().replace(/,/g, ''); + if (!normalized) { + return null; + } + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseNullableDate(value) { + if (value === undefined || value === null) { + return null; + } + + const raw = String(value).trim(); + if (!raw) { + return null; + } + + const directDate = new Date(raw); + if (!Number.isNaN(directDate.getTime())) { + return directDate; + } + + const localDateParts = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$/); + if (localDateParts) { + const day = Number(localDateParts[1]); + const month = Number(localDateParts[2]); + let year = Number(localDateParts[3]); + if (year < 100) { + year += 2000; + } + + const localizedDate = new Date(year, month - 1, day); + if (!Number.isNaN(localizedDate.getTime())) { + return localizedDate; + } + } + + return null; +} + +function normalizeAssetStatus(value) { + const normalized = String(value || '').trim().toLowerCase(); + + if (['in_use', 'in use', 'dang su dung', 'đang sử dụng', 'active'].includes(normalized)) { + return 'in_use'; + } + + if (['maintenance', 'bao tri', 'bảo trì'].includes(normalized)) { + return 'maintenance'; + } + + if (['disposed', 'thanh ly', 'thanh lý', 'retired'].includes(normalized)) { + return 'disposed'; + } + + if (['in_stock', 'in stock', 'ton kho', 'tồn kho', 'warehouse'].includes(normalized)) { + return 'in_stock'; + } + + return 'in_use'; +} + +function normalizeAssetPayload(payload = {}) { + return { + assetCode: String(payload.assetCode || '').trim(), + assetName: String(payload.assetName || '').trim(), + category: String(payload.category || '').trim() || null, + brand: String(payload.brand || '').trim() || null, + model: String(payload.model || '').trim() || null, + serialNumber: String(payload.serialNumber || '').trim() || null, + quantity: parsePositiveInteger(payload.quantity, 1), + unit: String(payload.unit || '').trim() || null, + department: String(payload.department || '').trim() || null, + location: String(payload.location || '').trim() || null, + custodian: String(payload.custodian || '').trim() || null, + purchaseDate: parseNullableDate(payload.purchaseDate), + purchasePrice: parseNullableDecimal(payload.purchasePrice), + warrantyUntil: parseNullableDate(payload.warrantyUntil), + status: normalizeAssetStatus(payload.status), + notes: String(payload.notes || '').trim() || null + }; +} + +function normalizeImportToken(value) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[đĐ]/g, 'd') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +} + +function isHeaderLikeAssetImportRow(row = {}) { + const headerTokens = new Set([ + 'stt', + 'ngayve', + 'mavattu', + 'mavt', + 'mataisan', + 'mats', + 'matscd', + 'tentaisan', + 'tenlinhkiensp', + 'model', + 'dvt', + 'donvi', + 'tondauky', + 'tondauki', + 'nhaptrongky', + 'nhaptrongki', + 'xuattrongky', + 'xuattrongki', + 'toncuoiky', + 'toncuoiki', + 'lidoxuat', + 'lydoxuat', + 'tinhtrang', + 'vitri', + 'duan', + 'assetcode', + 'assetname', + 'quantity', + 'unit', + 'location', + 'department', + 'status', + 'notes' + ]); + + const fields = [ + row.assetCode, + row.assetName, + row.model, + row.unit, + row.status, + row.location, + row.department, + row.notes + ]; + + const headerLikeCount = fields.reduce((count, value) => { + const token = normalizeImportToken(value); + return count + (token && headerTokens.has(token) ? 1 : 0); + }, 0); + + return headerLikeCount >= 2; +} + +const ASSET_IMPORT_ALIASES = { + stt: ['STT', 'So thu tu'], + assetCode: ['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: ['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'], + category: ['Category', 'Nhom', 'Loai', 'Loai tai san', 'Nhom vat tu'], + brand: ['Brand', 'Hang', 'Nhan hieu'], + model: ['Model', 'Dong may'], + serialNumber: ['Serial Number', 'Serial', 'So serial', 'So seri'], + quantity: ['Ton cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki'], + unit: ['Unit', 'Don vi', 'DVT'], + department: ['Department', 'Bo phan', 'Phong ban', 'Du an'], + location: ['Location', 'Vi tri', 'Noi dat'], + custodian: ['Custodian', 'Nguoi quan ly', 'Nguoi su dung'], + purchaseDate: ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve'], + purchasePrice: ['Purchase Price', 'Gia mua', 'Don gia'], + warrantyUntil: ['Warranty Until', 'Bao hanh den', 'Han bao hanh'], + status: ['Status', 'Trang thai', 'Tinh trang'], + notes: ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat'] +}; + +function inferAssetFieldFromHeaderToken(headerToken) { + const token = String(headerToken || ''); + if (!token) { + return null; + } + + if (token.includes('model')) return 'model'; + if (token.includes('serial') || token.includes('seri')) return 'serialNumber'; + if (token.includes('donvi') || token.includes('dvt') || token === 'unit') return 'unit'; + if (token.includes('vitri') || token.includes('location')) return 'location'; + if (token.includes('tinhtrang') || token === 'status') return 'status'; + if (token.includes('duan') || token.includes('phongban') || token.includes('bophan') || token.includes('department')) return 'department'; + if (token.includes('lydoxuat') || token.includes('lidoxuat') || token.includes('ghichu') || token === 'notes') return 'notes'; + if (token.includes('toncuoi') || token.includes('soluong') || token === 'sl' || token === 'quantity') return 'quantity'; + + const hasTen = token.includes('ten'); + const hasMa = token.includes('ma'); + const hasAssetLike = token.includes('linhkien') || token.includes('vattu') || token.includes('sanpham') || token.includes('taisan') || token.includes('sp'); + + if (hasTen && hasAssetLike) return 'assetName'; + if (hasMa && hasAssetLike) return 'assetCode'; + + return null; +} + +function resolveAssetImportFieldByHeader(headerCell) { + const token = normalizeImportToken(headerCell); + if (!token) { + return null; + } + + let bestField = null; + let bestScore = 0; + + for (const [field, aliases] of Object.entries(ASSET_IMPORT_ALIASES)) { + for (const alias of aliases) { + const aliasToken = normalizeImportToken(alias); + if (!aliasToken) { + continue; + } + + let score = 0; + if (token === aliasToken) { + score = 5; + } else if (token.includes(aliasToken) || aliasToken.includes(token)) { + score = 3; + } + + if (score > bestScore) { + bestScore = score; + bestField = field; + } + } + } + + if (bestField) { + return bestField; + } + + return inferAssetFieldFromHeaderToken(token); +} + +function buildAssetImportFieldMapFromHeaderRow(headerRow) { + const row = Array.isArray(headerRow) ? headerRow : []; + const fieldMap = {}; + + for (let index = 0; index < row.length; index += 1) { + const field = resolveAssetImportFieldByHeader(row[index]); + if (!field) { + continue; + } + + if (fieldMap[field] === undefined) { + fieldMap[field] = index; + } + } + + return fieldMap; +} + +function scoreAssetImportFieldMap(fieldMap = {}) { + let score = 0; + const keys = Object.keys(fieldMap); + score += keys.length; + if (fieldMap.assetName !== undefined) score += 5; + if (fieldMap.assetCode !== undefined) score += 4; + if (fieldMap.model !== undefined) score += 3; + if (fieldMap.quantity !== undefined) score += 2; + if (fieldMap.unit !== undefined) score += 1; + if (fieldMap.location !== undefined) score += 1; + return score; +} + +function parseAssetImportRowsByHeaderMap(matrixRows) { + const rows = Array.isArray(matrixRows) ? matrixRows : []; + const maxScanRows = Math.min(rows.length, 300); + let bestHeaderRowIndex = -1; + let bestFieldMap = {}; + let bestScore = 0; + + for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { + const headerRow = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : []; + if (!headerRow.some(cell => String(cell ?? '').trim() !== '')) { + continue; + } + + const candidateMap = buildAssetImportFieldMapFromHeaderRow(headerRow); + const score = scoreAssetImportFieldMap(candidateMap); + + if (score > bestScore) { + bestScore = score; + bestHeaderRowIndex = rowIndex; + bestFieldMap = candidateMap; + } + } + + if (bestHeaderRowIndex < 0 || bestScore < 2) { + return []; + } + + const pick = (row, index) => { + if (!Array.isArray(row) || index === undefined || index < 0) { + return ''; + } + return row[index] ?? ''; + }; + + const parsed = rows + .slice(bestHeaderRowIndex + 1) + .filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '')) + .map((row, rowOffset) => { + const mapped = { + assetCode: String(pick(row, bestFieldMap.assetCode)).trim(), + assetName: String(pick(row, bestFieldMap.assetName)).trim(), + category: String(pick(row, bestFieldMap.category)).trim(), + brand: String(pick(row, bestFieldMap.brand)).trim(), + model: String(pick(row, bestFieldMap.model)).trim(), + serialNumber: String(pick(row, bestFieldMap.serialNumber)).trim(), + quantity: parseAssetImportNumericValue(pick(row, bestFieldMap.quantity), 1), + unit: String(pick(row, bestFieldMap.unit)).trim(), + department: String(pick(row, bestFieldMap.department)).trim(), + location: String(pick(row, bestFieldMap.location)).trim(), + custodian: String(pick(row, bestFieldMap.custodian)).trim(), + purchaseDate: pick(row, bestFieldMap.purchaseDate), + purchasePrice: pick(row, bestFieldMap.purchasePrice), + warrantyUntil: pick(row, bestFieldMap.warrantyUntil), + status: String(pick(row, bestFieldMap.status)).trim(), + notes: String(pick(row, bestFieldMap.notes)).trim() + }; + + const hasAnyCoreValue = [mapped.assetCode, mapped.assetName, mapped.model, mapped.location, mapped.notes] + .some(value => String(value || '').trim() !== ''); + if (!hasAnyCoreValue) { + return null; + } + + return finalizeImportedAssetPayload(mapped, bestHeaderRowIndex + rowOffset + 2); + }) + .filter(Boolean) + .filter(row => !isHeaderLikeAssetImportRow(row)) + .filter(row => row.assetCode && row.assetName); + + return parsed; +} + +function isAssetImportHeaderMatch(actualHeader, alias) { + const normalizedHeader = normalizeImportToken(actualHeader); + const normalizedAlias = normalizeImportToken(alias); + + if (!normalizedHeader || !normalizedAlias) { + return false; + } + + if (normalizedHeader === normalizedAlias) { + return true; + } + + if (normalizedAlias.length < 4 || normalizedHeader.length < 4) { + return false; + } + + return normalizedHeader.includes(normalizedAlias) || normalizedAlias.includes(normalizedHeader); +} + +function inferAssetImportColumnIndex(headerRow, aliases = []) { + const row = Array.isArray(headerRow) ? headerRow : []; + + for (let index = 0; index < row.length; index += 1) { + if (aliases.some(alias => isAssetImportHeaderMatch(row[index], alias))) { + return index; + } + } + + return -1; +} + +function parseAssetImportNumericValue(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; +} + +function parseAssetImportSttNumber(value) { + const raw = String(value ?? '') + .trim() + .replace(/\.$/, '') + .replace(',', '.'); + + if (!raw) { + return null; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + + const rounded = Math.round(parsed); + return Math.abs(parsed - rounded) < 1e-9 ? rounded : null; +} + +function sanitizeAssetCodeToken(value) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[đĐ]/g, 'd') + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +function generateImportAssetCodeFromRow(mapped, rowNumber = 0) { + const fromModel = sanitizeAssetCodeToken(mapped.model); + const fromSerial = sanitizeAssetCodeToken(mapped.serialNumber); + const fromName = sanitizeAssetCodeToken(mapped.assetName); + const base = fromModel || fromSerial || fromName || 'ASSET'; + const suffix = String(rowNumber || 0).padStart(4, '0'); + return `IMP-${base}-${suffix}`; +} + +function finalizeImportedAssetPayload(mapped, rowNumber = 0) { + const result = { ...mapped }; + if (!result.assetName) { + result.assetName = String(result.model || result.serialNumber || result.assetCode || '').trim(); + } + + if (!result.assetCode && result.assetName) { + result.assetCode = generateImportAssetCodeFromRow(result, rowNumber); + } + + return result; +} + +function buildAssetImportIndexMap(headerRow) { + const indexMap = { + stt: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.stt), + assetCode: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetCode), + assetName: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetName), + category: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.category), + brand: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.brand), + model: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.model), + serialNumber: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.serialNumber), + quantity: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.quantity), + unit: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.unit), + department: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.department), + location: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.location), + custodian: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.custodian), + purchaseDate: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchaseDate), + purchasePrice: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchasePrice), + warrantyUntil: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.warrantyUntil), + status: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.status), + notes: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.notes) + }; + + 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 + 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.department < 0) indexMap.department = indexMap.stt + 13; + } + + return indexMap; +} + +function mapAssetImportMatrixRowsByIndex(matrixRows, headerRowIndex) { + const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : []; + if (!headerRow.length) { + return []; + } + + const indexMap = 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 sttValue = parseAssetImportSttNumber(pick(row, indexMap.stt)); + if (indexMap.stt >= 0 && sttValue === null) { + return null; + } + + const mapped = { + assetCode: String(pick(row, indexMap.assetCode)).trim(), + assetName: String(pick(row, indexMap.assetName)).trim(), + category: String(pick(row, indexMap.category)).trim(), + brand: String(pick(row, indexMap.brand)).trim(), + model: String(pick(row, indexMap.model)).trim(), + serialNumber: String(pick(row, indexMap.serialNumber)).trim(), + quantity: parseAssetImportNumericValue(pick(row, indexMap.quantity), 1), + unit: String(pick(row, indexMap.unit)).trim(), + department: String(pick(row, indexMap.department)).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), + warrantyUntil: pick(row, indexMap.warrantyUntil), + status: String(pick(row, indexMap.status)).trim(), + notes: String(pick(row, indexMap.notes)).trim() + }; + + return finalizeImportedAssetPayload(mapped, headerRowIndex + rowOffset + 2); + }) + .filter(Boolean) + .filter(row => !isHeaderLikeAssetImportRow(row)) + .filter(row => row.assetCode && row.assetName); +} + +function parseAssetImportRowsFromMatrix(matrixRows) { + const rows = Array.isArray(matrixRows) ? matrixRows : []; + const maxScanRows = Math.min(rows.length, 300); + let bestRows = []; + + for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { + const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : []; + if (!row.length) { + continue; + } + + const hasStt = row.some(cell => ASSET_IMPORT_ALIASES.stt.some(alias => isAssetImportHeaderMatch(cell, alias))); + const hasName = row.some(cell => ASSET_IMPORT_ALIASES.assetName.some(alias => isAssetImportHeaderMatch(cell, alias))); + const hasModel = row.some(cell => ASSET_IMPORT_ALIASES.model.some(alias => isAssetImportHeaderMatch(cell, alias))); + const hasQty = row.some(cell => ASSET_IMPORT_ALIASES.quantity.some(alias => isAssetImportHeaderMatch(cell, alias))); + + if (!hasStt || (!hasName && !hasModel && !hasQty)) { + continue; + } + + const candidateRows = mapAssetImportMatrixRowsByIndex(rows, rowIndex); + if (candidateRows.length > bestRows.length) { + bestRows = candidateRows; + } + } + + if (bestRows.length >= 3) { + return bestRows; + } + + let detectedSttCol = -1; + for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) { + const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : []; + const col = inferAssetImportColumnIndex(row, ASSET_IMPORT_ALIASES.stt); + if (col >= 0) { + detectedSttCol = col; + break; + } + } + + if (detectedSttCol < 0) { + detectedSttCol = 0; + } + + const sttRows = rows.filter(row => { + if (!Array.isArray(row)) { + return false; + } + + const sttValue = parseAssetImportSttNumber(row[detectedSttCol]); + if (sttValue === null) { + return false; + } + + return [2, 3, 4, 5, 9, 12] + .map(offset => detectedSttCol + offset) + .some(index => String(row[index] ?? '').trim() !== ''); + }); + + if (sttRows.length < 3) { + return bestRows; + } + + return sttRows + .map((row, idx) => { + 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(), + category: '', + brand: '', + model: String(row[detectedSttCol + 4] ?? '').trim(), + serialNumber: '', + quantity: parseAssetImportNumericValue(row[detectedSttCol + 9] ?? row[detectedSttCol + 6] ?? '', 1), + unit: String(row[detectedSttCol + 5] ?? '').trim(), + department: String(row[detectedSttCol + 13] ?? '').trim(), + location: String(row[detectedSttCol + 12] ?? '').trim(), + custodian: '', + purchaseDate: row[detectedSttCol + 1] ?? '', + purchasePrice: '', + warrantyUntil: '', + status: String(row[detectedSttCol + 11] ?? '').trim(), + notes: String(row[detectedSttCol + 10] ?? '').trim() + }; + + return finalizeImportedAssetPayload(mapped, idx + 2); + }) + .filter(row => !isHeaderLikeAssetImportRow(row)) + .filter(row => row.assetCode && row.assetName); +} + +function detectLikelySttColumn(matrixRows) { + const rows = Array.isArray(matrixRows) ? matrixRows : []; + const maxCols = Math.min( + rows.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0), + 40 + ); + + let bestColumn = -1; + let bestScore = 0; + + const scoreColumn = col => { + let validCount = 0; + let sequentialHits = 0; + let prev = null; + + for (const row of rows.slice(0, 500)) { + if (!Array.isArray(row)) { + continue; + } + + const value = parseAssetImportSttNumber(row[col]); + if (value === null) { + continue; + } + + validCount += 1; + if (prev !== null && value === prev + 1) { + sequentialHits += 1; + } + prev = value; + } + + return (validCount * 2) + (sequentialHits * 5); + }; + + for (let col = 0; col < maxCols; col += 1) { + const score = scoreColumn(col); + + if (score > bestScore) { + bestScore = score; + bestColumn = col; + } + } + + return bestScore >= 12 ? bestColumn : -1; +} + +function parseAssetImportRowsLoose(matrixRows) { + const rows = Array.isArray(matrixRows) ? matrixRows : []; + const sttCol = detectLikelySttColumn(rows); + if (sttCol < 0) { + return []; + } + + const dataRows = rows.filter(row => { + if (!Array.isArray(row)) { + return false; + } + + const stt = parseAssetImportSttNumber(row[sttCol]); + if (stt === null) { + return false; + } + + const hasCoreValue = [2, 3, 4, 5, 9, 12] + .map(offset => sttCol + offset) + .some(index => String(row[index] ?? '').trim() !== ''); + return hasCoreValue; + }); + + return dataRows + .map((row, idx) => { + const mapped = { + assetCode: String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(), + assetName: String(row[sttCol + 3] ?? '').trim() || String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(), + category: '', + brand: '', + model: String(row[sttCol + 4] ?? '').trim(), + serialNumber: '', + quantity: parseAssetImportNumericValue(row[sttCol + 9] ?? row[sttCol + 6] ?? '', 1), + unit: String(row[sttCol + 5] ?? '').trim(), + department: String(row[sttCol + 13] ?? '').trim(), + location: String(row[sttCol + 12] ?? '').trim(), + custodian: '', + purchaseDate: row[sttCol + 1] ?? '', + purchasePrice: '', + warrantyUntil: '', + status: String(row[sttCol + 11] ?? '').trim(), + notes: String(row[sttCol + 10] ?? '').trim() + }; + + return finalizeImportedAssetPayload(mapped, idx + 2); + }) + .filter(row => !isHeaderLikeAssetImportRow(row)) + .filter(row => row.assetCode && row.assetName); +} + +function parseAssetImportRows(matrixRows) { + const genericRows = parseAssetImportRowsByHeaderMap(matrixRows); + if (genericRows.length > 0) { + return genericRows; + } + + const strictRows = parseAssetImportRowsFromMatrix(matrixRows); + if (strictRows.length > 0) { + return strictRows; + } + + return parseAssetImportRowsLoose(matrixRows); +} + +function countNonEmptyMatrixRows(matrixRows) { + return (Array.isArray(matrixRows) ? matrixRows : []).filter( + row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '') + ).length; +} + +function parseAssetImportRowsFromWorkbook(workbook) { + const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : []; + let bestRows = []; + let bestSheetName = ''; + let bestNonEmptyRows = 0; + const diagnostics = []; + + for (const sheetName of sheetNames) { + const sheet = workbook.Sheets?.[sheetName]; + if (!sheet) { + continue; + } + + const matrixRows = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: '', + raw: false + }); + + const parsedRows = parseAssetImportRows(matrixRows); + const nonEmptyRows = countNonEmptyMatrixRows(matrixRows); + diagnostics.push({ + sheetName, + parsedRows: parsedRows.length, + nonEmptyRows + }); + + if (parsedRows.length > bestRows.length || (parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)) { + bestRows = parsedRows; + bestSheetName = sheetName; + bestNonEmptyRows = nonEmptyRows; + } + } + + return { + rows: bestRows, + sheetName: bestSheetName, + diagnostics + }; +} + // Middleware app.use(cors()); app.use(express.json()); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 15 * 1024 * 1024 } +}); // Serve static files from /public const path = require('path'); @@ -352,6 +1141,34 @@ async function createTables() { FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE ) END`, + + // Asset Inventory Table + `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory') + BEGIN + CREATE TABLE AssetInventory ( + AssetId INT PRIMARY KEY IDENTITY(1,1), + AssetCode NVARCHAR(100) NOT NULL UNIQUE, + AssetName NVARCHAR(255) NOT NULL, + Category NVARCHAR(100), + Brand NVARCHAR(100), + Model NVARCHAR(255), + SerialNumber NVARCHAR(100), + Quantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50), + Department NVARCHAR(100), + Location NVARCHAR(150), + Custodian NVARCHAR(100), + PurchaseDate DATE NULL, + PurchasePrice DECIMAL(18,2) NULL, + WarrantyUntil DATE NULL, + Status NVARCHAR(30) NOT NULL DEFAULT 'in_use', + Notes NVARCHAR(MAX), + CreatedBy INT NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE(), + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ) + END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') @@ -378,8 +1195,25 @@ async function createTables() { } } + // Ensure AssetInventory indexes exist for lookup/filter performance + try { + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`); + await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`); + } catch (err) { + console.error('AssetInventory index creation error:', err.message); + } + // Ensure new columns exist on Applications for migrations try { + await pool.request().query(`IF EXISTS ( + SELECT 1 + FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.AssetInventory') + AND name = 'Model' + AND max_length < 510 + ) + ALTER TABLE AssetInventory ALTER COLUMN Model NVARCHAR(255) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`); await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`); await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`); @@ -1400,6 +2234,310 @@ app.delete('/api/accounts/:id', async (req, res) => { } }); +// ========================================== +// API ROUTES - Asset Inventory +// ========================================== + +app.get('/api/assets', async (req, res) => { + try { + const result = await pool.request().query(` + SELECT AssetId, AssetCode, AssetName, Category, Brand, Model, SerialNumber, + Quantity, Unit, Department, Location, Custodian, PurchaseDate, + PurchasePrice, WarrantyUntil, Status, Notes, CreatedBy, CreatedDate, UpdatedDate + FROM AssetInventory + ORDER BY UpdatedDate DESC, AssetName ASC + `); + + res.json({ success: true, data: result.recordset }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.get('/api/assets/:id', async (req, res) => { + try { + const result = await pool.request() + .input('assetId', sql.Int, req.params.id) + .query(` + SELECT AssetId, AssetCode, AssetName, Category, Brand, Model, SerialNumber, + Quantity, Unit, Department, Location, Custodian, PurchaseDate, + PurchasePrice, WarrantyUntil, Status, Notes, CreatedBy, CreatedDate, UpdatedDate + FROM AssetInventory + WHERE AssetId = @assetId + `); + + if (result.recordset.length === 0) { + return res.status(404).json({ success: false, message: 'Asset not found' }); + } + + res.json({ success: true, data: result.recordset[0] }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/assets', async (req, res) => { + try { + const payload = normalizeAssetPayload(req.body); + const createdBy = getUserIdFromRequest(req); + + if (!payload.assetCode || !payload.assetName) { + return res.status(400).json({ success: false, message: 'Asset code and asset name are required' }); + } + + const result = await pool.request() + .input('assetCode', sql.NVarChar, payload.assetCode) + .input('assetName', sql.NVarChar, payload.assetName) + .input('category', sql.NVarChar, payload.category) + .input('brand', sql.NVarChar, payload.brand) + .input('model', sql.NVarChar, payload.model) + .input('serialNumber', sql.NVarChar, payload.serialNumber) + .input('quantity', sql.Int, payload.quantity) + .input('unit', sql.NVarChar, payload.unit) + .input('department', sql.NVarChar, payload.department) + .input('location', sql.NVarChar, payload.location) + .input('custodian', sql.NVarChar, payload.custodian) + .input('purchaseDate', sql.Date, payload.purchaseDate) + .input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice) + .input('warrantyUntil', sql.Date, payload.warrantyUntil) + .input('status', sql.NVarChar, payload.status) + .input('notes', sql.NVarChar, payload.notes) + .input('createdBy', sql.Int, createdBy) + .query(` + INSERT INTO AssetInventory ( + AssetCode, AssetName, Category, Brand, Model, SerialNumber, + Quantity, Unit, Department, Location, Custodian, PurchaseDate, + PurchasePrice, WarrantyUntil, Status, Notes, CreatedBy + ) VALUES ( + @assetCode, @assetName, @category, @brand, @model, @serialNumber, + @quantity, @unit, @department, @location, @custodian, @purchaseDate, + @purchasePrice, @warrantyUntil, @status, @notes, @createdBy + ); + SELECT SCOPE_IDENTITY() AS AssetId; + `); + + res.json({ success: true, message: 'Asset created', assetId: result.recordset[0].AssetId }); + } catch (err) { + if (String(err.message || '').includes('UNIQUE')) { + return res.status(409).json({ success: false, message: 'Asset code already exists' }); + } + + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.put('/api/assets/:id', async (req, res) => { + try { + const payload = normalizeAssetPayload(req.body); + + if (!payload.assetCode || !payload.assetName) { + return res.status(400).json({ success: false, message: 'Asset code and asset name are required' }); + } + + await pool.request() + .input('assetId', sql.Int, req.params.id) + .input('assetCode', sql.NVarChar, payload.assetCode) + .input('assetName', sql.NVarChar, payload.assetName) + .input('category', sql.NVarChar, payload.category) + .input('brand', sql.NVarChar, payload.brand) + .input('model', sql.NVarChar, payload.model) + .input('serialNumber', sql.NVarChar, payload.serialNumber) + .input('quantity', sql.Int, payload.quantity) + .input('unit', sql.NVarChar, payload.unit) + .input('department', sql.NVarChar, payload.department) + .input('location', sql.NVarChar, payload.location) + .input('custodian', sql.NVarChar, payload.custodian) + .input('purchaseDate', sql.Date, payload.purchaseDate) + .input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice) + .input('warrantyUntil', sql.Date, payload.warrantyUntil) + .input('status', sql.NVarChar, payload.status) + .input('notes', sql.NVarChar, payload.notes) + .query(` + UPDATE AssetInventory + SET AssetCode = @assetCode, + AssetName = @assetName, + Category = @category, + Brand = @brand, + Model = @model, + SerialNumber = @serialNumber, + Quantity = @quantity, + Unit = @unit, + Department = @department, + Location = @location, + Custodian = @custodian, + PurchaseDate = @purchaseDate, + PurchasePrice = @purchasePrice, + WarrantyUntil = @warrantyUntil, + Status = @status, + Notes = @notes, + UpdatedDate = GETDATE() + WHERE AssetId = @assetId + `); + + res.json({ success: true, message: 'Asset updated' }); + } catch (err) { + if (String(err.message || '').includes('UNIQUE')) { + return res.status(409).json({ success: false, message: 'Asset code already exists' }); + } + + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.delete('/api/assets/:id', async (req, res) => { + try { + await pool.request() + .input('assetId', sql.Int, req.params.id) + .query('DELETE FROM AssetInventory WHERE AssetId = @assetId'); + + res.json({ success: true, message: 'Asset deleted' }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +app.post('/api/assets/import', upload.single('file'), async (req, res) => { + let incomingRows = []; + let source = 'rows'; + let parseDiagnostics = []; + + try { + if (req.file?.buffer) { + const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); + if (!workbook.SheetNames?.length) { + return res.status(400).json({ success: false, message: 'Excel file does not contain a worksheet' }); + } + + const parsed = parseAssetImportRowsFromWorkbook(workbook); + incomingRows = parsed.rows; + parseDiagnostics = parsed.diagnostics; + source = parsed.sheetName ? `file:${parsed.sheetName}` : 'file'; + } else { + incomingRows = Array.isArray(req.body?.rows) ? req.body.rows : []; + } + } catch (err) { + return res.status(400).json({ success: false, message: `Cannot parse import file: ${err.message}` }); + } + + if (!incomingRows.length) { + if (req.file) { + console.warn('Asset import parse returned 0 rows', { + diagnostics: parseDiagnostics + }); + } + + return res.status(400).json({ + success: false, + message: req.file + ? 'Khong tim thay dong du lieu hop le trong file Excel. Vui long kiem tra dong STT va du lieu cot ten/model/ton cuoi ky.' + : 'Import data is empty', + diagnostics: req.file ? parseDiagnostics : undefined + }); + } + + const createdBy = getUserIdFromRequest(req); + const normalizedRows = incomingRows + .map(row => normalizeAssetPayload(row)) + .filter(row => !isHeaderLikeAssetImportRow(row)) + .filter(row => row.assetCode && row.assetName); + + if (!normalizedRows.length) { + return res.status(400).json({ success: false, message: 'No valid rows found. Asset code and name are required.' }); + } + + const transaction = new sql.Transaction(pool); + let inserted = 0; + let updated = 0; + + try { + await transaction.begin(); + + for (const row of normalizedRows) { + const mergeResult = await new sql.Request(transaction) + .input('assetCode', sql.NVarChar, row.assetCode) + .input('assetName', sql.NVarChar, row.assetName) + .input('category', sql.NVarChar, row.category) + .input('brand', sql.NVarChar, row.brand) + .input('model', sql.NVarChar, row.model) + .input('serialNumber', sql.NVarChar, row.serialNumber) + .input('quantity', sql.Int, row.quantity) + .input('unit', sql.NVarChar, row.unit) + .input('department', sql.NVarChar, row.department) + .input('location', sql.NVarChar, row.location) + .input('custodian', sql.NVarChar, row.custodian) + .input('purchaseDate', sql.Date, row.purchaseDate) + .input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice) + .input('warrantyUntil', sql.Date, row.warrantyUntil) + .input('status', sql.NVarChar, row.status) + .input('notes', sql.NVarChar, row.notes) + .input('createdBy', sql.Int, createdBy) + .query(` + MERGE AssetInventory AS target + USING (SELECT @assetCode AS AssetCode) AS source + ON target.AssetCode = source.AssetCode + WHEN MATCHED THEN + UPDATE SET + AssetName = @assetName, + Category = @category, + Brand = @brand, + Model = @model, + SerialNumber = @serialNumber, + Quantity = @quantity, + Unit = @unit, + Department = @department, + Location = @location, + Custodian = @custodian, + PurchaseDate = @purchaseDate, + PurchasePrice = @purchasePrice, + WarrantyUntil = @warrantyUntil, + Status = @status, + Notes = @notes, + UpdatedDate = GETDATE() + WHEN NOT MATCHED THEN + INSERT ( + AssetCode, AssetName, Category, Brand, Model, SerialNumber, + Quantity, Unit, Department, Location, Custodian, + PurchaseDate, PurchasePrice, WarrantyUntil, Status, Notes, CreatedBy + ) + VALUES ( + @assetCode, @assetName, @category, @brand, @model, @serialNumber, + @quantity, @unit, @department, @location, @custodian, + @purchaseDate, @purchasePrice, @warrantyUntil, @status, @notes, @createdBy + ) + OUTPUT $action AS MergeAction; + `); + + const mergeAction = String(mergeResult.recordset?.[0]?.MergeAction || '').toUpperCase(); + if (mergeAction === 'INSERT') { + inserted += 1; + } else if (mergeAction === 'UPDATE') { + updated += 1; + } + } + + await transaction.commit(); + + res.json({ + success: true, + message: `Import completed. Inserted: ${inserted}, Updated: ${updated}`, + data: { + source, + totalReceived: incomingRows.length, + processed: normalizedRows.length, + inserted, + updated + } + }); + } catch (err) { + try { + await transaction.rollback(); + } catch (rollbackErr) { + // Ignore rollback errors if transaction is already completed. + } + res.status(500).json({ success: false, message: err.message }); + } +}); + // ========================================== // API ROUTES - Database Info // ========================================== @@ -1418,6 +2556,7 @@ app.get('/api/database/info', async (req, res) => { const users = await pool.request().query('SELECT COUNT(*) as Count FROM Users'); const apps = await pool.request().query('SELECT COUNT(*) as Count FROM Applications'); const accounts = await pool.request().query('SELECT COUNT(*) as Count FROM Accounts'); + const assets = await pool.request().query('SELECT COUNT(*) as Count FROM AssetInventory'); res.json({ success: true, @@ -1427,7 +2566,8 @@ app.get('/api/database/info', async (req, res) => { statistics: { users: users.recordset[0].Count, applications: apps.recordset[0].Count, - accounts: accounts.recordset[0].Count + accounts: accounts.recordset[0].Count, + assets: assets.recordset[0].Count } }); } catch (err) { @@ -1470,6 +2610,7 @@ async function startServer() { console.log(` GET /api/users`); console.log(` GET /api/applications`); console.log(` GET /api/accounts/user/:userId`); + console.log(` GET /api/assets`); console.log(`========================================\n`); }); } catch (err) { diff --git a/database/setup.sql b/database/setup.sql index 7f834b2..575ea11 100644 --- a/database/setup.sql +++ b/database/setup.sql @@ -81,7 +81,38 @@ BEGIN END -- =========================================== --- 4. CREATE AUDIT LOG TABLE +-- 4. CREATE ASSET INVENTORY TABLE +-- =========================================== +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory') +BEGIN + CREATE TABLE AssetInventory ( + AssetId INT PRIMARY KEY IDENTITY(1,1), + AssetCode NVARCHAR(100) NOT NULL UNIQUE, + AssetName NVARCHAR(255) NOT NULL, + Category NVARCHAR(100), + Brand NVARCHAR(100), + Model NVARCHAR(255), + SerialNumber NVARCHAR(100), + Quantity INT NOT NULL DEFAULT 1, + Unit NVARCHAR(50), + Department NVARCHAR(100), + Location NVARCHAR(150), + Custodian NVARCHAR(100), + PurchaseDate DATE NULL, + PurchasePrice DECIMAL(18,2) NULL, + WarrantyUntil DATE NULL, + Status NVARCHAR(30) NOT NULL DEFAULT 'in_use', + Notes NVARCHAR(MAX), + CreatedBy INT NULL, + CreatedDate DATETIME DEFAULT GETDATE(), + UpdatedDate DATETIME DEFAULT GETDATE(), + FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL + ); + PRINT 'Table AssetInventory created successfully.'; +END + +-- =========================================== +-- 5. CREATE AUDIT LOG TABLE -- =========================================== IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN @@ -100,7 +131,7 @@ BEGIN END -- =========================================== --- 5. CREATE INDEXES +-- 6. CREATE INDEXES -- =========================================== IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') BEGIN @@ -117,10 +148,20 @@ BEGIN CREATE INDEX IX_Accounts_AppId ON Accounts(AppId); END +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') +BEGIN + CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode); +END + +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') +BEGIN + CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status); +END + PRINT 'Indexes created successfully.'; -- =========================================== --- 6. INSERT INITIAL DATA +-- 7. INSERT INITIAL DATA -- =========================================== -- Check if admin user exists @@ -144,7 +185,7 @@ BEGIN END -- =========================================== --- 7. DISPLAY DATABASE INFORMATION +-- 8. DISPLAY DATABASE INFORMATION -- =========================================== PRINT ''; PRINT '========================================'; diff --git a/package-lock.json b/package-lock.json index 7e20733..f1ed615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "mssql": "^9.1.1", - "nodemailer": "^8.0.4" + "multer": "^2.1.1", + "nodemailer": "^8.0.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", @@ -569,6 +571,15 @@ "node": ">= 0.6" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -599,6 +610,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -906,6 +923,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -993,6 +1027,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1018,6 +1065,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -1027,6 +1083,21 @@ "node": ">=16" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1080,6 +1151,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1600,6 +1683,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -2743,6 +2835,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -3769,6 +3880,18 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3801,6 +3924,14 @@ "npm": ">=6" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4223,6 +4354,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -4406,6 +4543,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/package.json b/package.json index 513d655..1705ec0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "mssql": "^9.1.1", - "nodemailer": "^8.0.4" + "multer": "^2.1.1", + "nodemailer": "^8.0.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", diff --git a/public/js/app.js b/public/js/app.js index 30126cb..394dc3d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -14,6 +14,7 @@ class AccountManager { this.accounts = []; this.applications = []; this.users = []; + this.assets = []; this.roles = []; this.accountPage = 1; this.accountPageSize = 9; @@ -21,6 +22,8 @@ class AccountManager { this.appPageSize = 9; this.userPage = 1; this.userPageSize = 9; + this.assetPage = 1; + this.assetPageSize = 10; this.apiBase = '/api'; this.currentPage = 'dashboard'; this.accountSearchTerm = ''; @@ -28,6 +31,9 @@ class AccountManager { this.accountServiceFilter = ''; this.userSearchTerm = ''; this.userRoleFilter = ''; + this.assetSearchTerm = ''; + this.assetStatusFilter = ''; + this.selectedAssetIds = new Set(); this.mobileBreakpoint = 900; this.boundResizeHandler = null; this.configureNotifications(); @@ -112,6 +118,7 @@ class AccountManager { async init() { await this.fetchApplications(); await this.fetchAccounts(); + await this.fetchAssets(); // Check if user is admin and fetch users/roles if (this.isCurrentUserAdmin()) { @@ -120,6 +127,8 @@ class AccountManager { // 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(); @@ -149,6 +158,12 @@ class AccountManager { 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(); @@ -292,6 +307,21 @@ class AccountManager { } } + async fetchAssets() { + try { + const res = await fetch(`${this.apiBase}/assets`); + const data = await res.json(); + if (data.success) { + this.assets = data.data; + 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`); @@ -335,6 +365,7 @@ class AccountManager { 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; @@ -347,6 +378,12 @@ class AccountManager { 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() { @@ -401,6 +438,14 @@ class AccountManager { } } + const assetForm = document.getElementById('assetForm'); + if (assetForm) { + if (!assetForm.dataset.boundSubmit) { + assetForm.addEventListener('submit', (e) => this.handleAssetSubmit(e)); + assetForm.dataset.boundSubmit = 'true'; + } + } + // Close when clicking backdrop outside modal content document.querySelectorAll('.modal-backdrop').forEach(backdrop => { backdrop.addEventListener('click', (evt) => { @@ -441,6 +486,54 @@ class AccountManager { }); } + 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.Category, + asset.Brand, + asset.Model, + asset.SerialNumber, + asset.Department, + asset.Location, + asset.Custodian, + 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)); @@ -787,6 +880,1333 @@ class AccountManager { `; } + 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}`; + } + + getAssetsContent() { + this.syncSelectedAssetIds(); + const filteredAssets = this.getFilteredAssets(); + const pageInfo = this.getPaged(filteredAssets, this.assetPage, this.assetPageSize); + const selectedCount = this.selectedAssetIds.size; + 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 = pageAssetIds.length > 0 && selectedOnPageCount === pageAssetIds.length; + this.assetPage = pageInfo.current; + + return ` +
+ + +
+
+ Trạng thái + +
+
+ Tìm kiếm + +
+ +
+ +
+ ${pageInfo.data.length > 0 ? ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + ${pageInfo.data.map(asset => { + const statusMeta = this.getAssetStatusMeta(asset.Status); + const purchasePrice = Number(asset.PurchasePrice || 0); + const assetId = Number(asset.AssetId); + const isSelected = Number.isFinite(assetId) && this.selectedAssetIds.has(assetId); + return ` + + + + + + + + + + + + + + + + + + + + + + + + `; + }).join('')} + +
+ + IDTên tài sảnDanh mụcHãngModelSerialSố lượngĐơn vịPhòng banNgười phụ tráchTrạng tháiVị tríNgày muaGiá muaHạn bảo hànhGhi chúNgày tạoCập nhậtThao tác
+ + ${asset.AssetId || '-'}${asset.AssetCode || '-'}${asset.AssetName || '-'}${asset.Category || '-'}${asset.Brand || '-'}${asset.Model || '-'}${asset.SerialNumber || '-'}${asset.Quantity || 0}${asset.Unit || '-'}${asset.Department || '-'}${asset.Custodian || '-'}${statusMeta.label}${asset.Location || '-'}${this.formatDateOnly(asset.PurchaseDate)}${purchasePrice > 0 ? purchasePrice.toLocaleString() : '-'}${this.formatDateOnly(asset.WarrantyUntil)}${asset.Notes || '-'}${this.formatDateOnly(asset.CreatedDate)}${this.formatDateOnly(asset.UpdatedDate)} +
+ + + +
+
+
+
+ Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total} +
+ + Trang ${pageInfo.current} / ${pageInfo.totalPages} + +
+
+ ` : ` +
+
+

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 pageInfo = this.getPaged(this.getFilteredAssets(), this.assetPage, this.assetPageSize); + this.assetPage = pageInfo.current; + + tbody.innerHTML = pageInfo.data.map(asset => { + const statusMeta = this.getAssetStatusMeta(asset.Status); + const purchasePrice = Number(asset.PurchasePrice || 0); + const assetId = Number(asset.AssetId); + const isSelected = Number.isFinite(assetId) && this.selectedAssetIds.has(assetId); + return ` + + + + + ${asset.AssetId || '-'} + ${asset.AssetCode || '-'} + ${asset.AssetName || '-'} + ${asset.Category || '-'} + ${asset.Brand || '-'} + ${asset.Model || '-'} + ${asset.SerialNumber || '-'} + ${asset.Quantity || 0} + ${asset.Unit || '-'} + ${asset.Department || '-'} + ${asset.Custodian || '-'} + ${statusMeta.label} + ${asset.Location || '-'} + ${this.formatDateOnly(asset.PurchaseDate)} + ${purchasePrice > 0 ? purchasePrice.toLocaleString() : '-'} + ${this.formatDateOnly(asset.WarrantyUntil)} + ${asset.Notes || '-'} + ${this.formatDateOnly(asset.CreatedDate)} + ${this.formatDateOnly(asset.UpdatedDate)} + +
+ + + +
+ + + `; + }).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() { + 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 rowCheckboxes = Array.from(document.querySelectorAll('.asset-row-checkbox')); + const selectedOnPage = rowCheckboxes.filter(checkbox => checkbox.checked).length; + + const selectAllCheckbox = document.getElementById('selectAllAssetsCheckbox'); + if (selectAllCheckbox) { + const hasRows = rowCheckboxes.length > 0; + selectAllCheckbox.checked = hasRows && selectedOnPage === rowCheckboxes.length; + selectAllCheckbox.indeterminate = selectedOnPage > 0 && selectedOnPage < rowCheckboxes.length; + } + + const selectedCount = this.selectedAssetIds.size; + const selectedCountNode = document.getElementById('selectedAssetCount'); + if (selectedCountNode) { + selectedCountNode.textContent = String(selectedCount); + } + + const bulkDeleteBtn = document.getElementById('bulkDeleteAssetsBtn'); + if (bulkDeleteBtn) { + const disabled = selectedCount === 0; + bulkDeleteBtn.disabled = disabled; + bulkDeleteBtn.classList.toggle('opacity-50', disabled); + bulkDeleteBtn.classList.toggle('cursor-not-allowed', disabled); + } + } + + async handleBulkDeleteAssets() { + 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 fields = [ + ['Mã tài sản', asset?.AssetCode], + ['Tên tài sản', asset?.AssetName], + ['Danh mục', asset?.Category], + ['Hãng', asset?.Brand], + ['Model', asset?.Model], + ['Số serial', asset?.SerialNumber], + ['Số lượng', `${asset?.Quantity || 0} ${asset?.Unit || ''}`.trim()], + ['Phòng ban', asset?.Department], + ['Vị trí', asset?.Location], + ['Người phụ trách', asset?.Custodian], + ['Ngày mua', this.formatDateOnly(asset?.PurchaseDate)], + ['Giá mua', asset?.PurchasePrice ? Number(asset.PurchasePrice).toLocaleString() : '-'], + ['Hạn bảo hành', this.formatDateOnly(asset?.WarrantyUntil)], + ['Trạng thái', this.getAssetStatusMeta(asset?.Status).label], + ['Ghi chú', asset?.Notes], + ['Cập nhật lúc', this.formatDateTime(asset?.UpdatedDate)] + ]; + + detailsContainer.innerHTML = fields.map(([label, value]) => ` +
+ +
${value || '-'}
+
+ `).join(''); + } + + populateAssetForm(asset) { + document.getElementById('assetCodeInput').value = asset?.AssetCode || ''; + document.getElementById('assetNameInput').value = asset?.AssetName || ''; + document.getElementById('assetCategoryInput').value = asset?.Category || ''; + document.getElementById('assetStatusInput').value = String(asset?.Status || 'in_use').toLowerCase(); + document.getElementById('assetBrandInput').value = asset?.Brand || ''; + document.getElementById('assetModelInput').value = asset?.Model || ''; + document.getElementById('assetSerialInput').value = asset?.SerialNumber || ''; + document.getElementById('assetQuantityInput').value = asset?.Quantity || 1; + document.getElementById('assetUnitInput').value = asset?.Unit || ''; + document.getElementById('assetDepartmentInput').value = asset?.Department || ''; + document.getElementById('assetLocationInput').value = asset?.Location || ''; + document.getElementById('assetCustodianInput').value = asset?.Custodian || ''; + document.getElementById('assetPurchaseDateInput').value = this.toDateInputValue(asset?.PurchaseDate); + document.getElementById('assetPriceInput').value = asset?.PurchasePrice || ''; + document.getElementById('assetWarrantyUntilInput').value = this.toDateInputValue(asset?.WarrantyUntil); + document.getElementById('assetNotesInput').value = asset?.Notes || ''; + } + + openAssetModal() { + if (this.editingAssetId === undefined) { + this.populateAssetForm(null); + } + document.getElementById('assetModal').classList.add('open'); + } + + collectAssetFormPayload() { + return { + assetCode: document.getElementById('assetCodeInput')?.value?.trim() || '', + assetName: document.getElementById('assetNameInput')?.value?.trim() || '', + category: document.getElementById('assetCategoryInput')?.value?.trim() || '', + status: document.getElementById('assetStatusInput')?.value || 'in_use', + brand: document.getElementById('assetBrandInput')?.value?.trim() || '', + model: document.getElementById('assetModelInput')?.value?.trim() || '', + serialNumber: document.getElementById('assetSerialInput')?.value?.trim() || '', + quantity: Number(document.getElementById('assetQuantityInput')?.value || 1), + unit: document.getElementById('assetUnitInput')?.value?.trim() || '', + department: document.getElementById('assetDepartmentInput')?.value?.trim() || '', + location: document.getElementById('assetLocationInput')?.value?.trim() || '', + custodian: document.getElementById('assetCustodianInput')?.value?.trim() || '', + purchaseDate: document.getElementById('assetPurchaseDateInput')?.value || null, + purchasePrice: document.getElementById('assetPriceInput')?.value || null, + warrantyUntil: document.getElementById('assetWarrantyUntilInput')?.value || null, + notes: document.getElementById('assetNotesInput')?.value?.trim() || '' + }; + } + + async handleAssetSubmit(e) { + e.preventDefault(); + + 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(); + + 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', () => { + 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', () => { + 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.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.addEventListener('click', () => { + 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', + 'unit', + 'location', + 'department', + '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.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 cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', '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 cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', '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']), + category: this.inferImportColumnIndex(headerRow, ['Category', 'Nhom', 'Loai', 'Loai tai san', 'Nhom vat tu']), + brand: this.inferImportColumnIndex(headerRow, ['Brand', 'Hang', 'Nhan hieu']), + model: this.inferImportColumnIndex(headerRow, ['Model', 'Dong may']), + serialNumber: this.inferImportColumnIndex(headerRow, ['Serial Number', 'Serial', 'So serial', 'So seri']), + quantity: this.inferImportColumnIndex(headerRow, ['Ton cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki']), + unit: this.inferImportColumnIndex(headerRow, ['Unit', 'Don vi', 'DVT']), + department: this.inferImportColumnIndex(headerRow, ['Department', 'Bo phan', 'Phong ban', 'Du an']), + 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']), + warrantyUntil: this.inferImportColumnIndex(headerRow, ['Warranty Until', 'Bao hanh den', 'Han bao hanh']), + 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 + 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.department < 0) indexMap.department = 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 mapped = { + assetCode: String(pick(row, indexMap.assetCode)).trim(), + assetName: String(pick(row, indexMap.assetName)).trim(), + category: String(pick(row, indexMap.category)).trim(), + brand: String(pick(row, indexMap.brand)).trim(), + model: String(pick(row, indexMap.model)).trim(), + serialNumber: String(pick(row, indexMap.serialNumber)).trim(), + quantity: this.parseImportNumericValue(pick(row, indexMap.quantity), 1), + unit: String(pick(row, indexMap.unit)).trim(), + department: String(pick(row, indexMap.department)).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), + warrantyUntil: pick(row, indexMap.warrantyUntil), + 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 cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', '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 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(), + category: '', + brand: '', + model: String(row[detectedSttCol + 4] ?? '').trim(), + serialNumber: '', + quantity: this.parseImportNumericValue(row[detectedSttCol + 9] ?? row[detectedSttCol + 6] ?? '', 1), + unit: String(row[detectedSttCol + 5] ?? '').trim(), + department: String(row[detectedSttCol + 13] ?? '').trim(), + location: String(row[detectedSttCol + 12] ?? '').trim(), + custodian: '', + purchaseDate: row[detectedSttCol + 1] ?? '', + purchasePrice: '', + warrantyUntil: '', + 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 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(), + category: String(this.findImportValue(row, ['Category', 'Nhom', 'Loai', 'Loai tai san', 'Nhom vat tu'])).trim(), + brand: String(this.findImportValue(row, ['Brand', 'Hang', 'Nhan hieu'])).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 cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki']), 1), + unit: String(this.findImportValue(row, ['Unit', 'Don vi', 'DVT'])).trim(), + department: String(this.findImportValue(row, ['Department', 'Bo phan', 'Phong ban', 'Du an'])).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']), + warrantyUntil: this.findImportValue(row, ['Warranty Until', 'Bao hanh den', 'Han bao hanh']), + 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; + } + + async importAssetsByFileUpload(file) { + 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'; + this.notifyFailure(message); + console.warn('Asset file-upload import failed', { + status: response.status, + message, + diagnostics: data?.diagnostics || null + }); + return { + uploaded: false, + shouldFallback: false + }; + } + + 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) { + 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 sheetName = workbook.SheetNames[0]; + if (!sheetName) { + this.notifyFailure('Tệp Excel không có sheet dữ liệu'); + return; + } + + const sheet = workbook.Sheets[sheetName]; + const matrixRows = window.XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: '', + raw: false + }); + const headerRowIndex = this.findAssetImportHeaderRowIndex(matrixRows); + + let mappedRows = []; + if (headerRowIndex >= 0) { + mappedRows = this.mapImportedAssetRowsFromMatrix(matrixRows, headerRowIndex); + if (!mappedRows.length) { + mappedRows = this.mapImportedAssetRowsByColumnIndex(matrixRows, headerRowIndex); + } + } + + const bestRowsFromMatrix = this.findBestAssetImportRowsFromMatrix(matrixRows); + if (bestRowsFromMatrix.length > mappedRows.length) { + mappedRows = bestRowsFromMatrix; + } + + const sttPatternRows = this.mapImportedAssetRowsBySttPattern(matrixRows); + if (sttPatternRows.length > mappedRows.length) { + mappedRows = sttPatternRows; + console.info('Asset import switched to STT-pattern parser', { + parsedRows: sttPatternRows.length + }); + } + + if (!mappedRows.length) { + const headerPreview = matrixRows + .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, + headerRowIndex, + 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 || '', + 'Category': asset.Category || '', + 'Brand': asset.Brand || '', + 'Model': asset.Model || '', + 'Serial Number': asset.SerialNumber || '', + 'Quantity': asset.Quantity || 0, + 'Unit': asset.Unit || '', + 'Department': asset.Department || '', + 'Location': asset.Location || '', + 'Custodian': asset.Custodian || '', + 'Purchase Date': this.toDateInputValue(asset.PurchaseDate), + 'Purchase Price': asset.PurchasePrice || '', + 'Warranty Until': this.toDateInputValue(asset.WarrantyUntil), + '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; @@ -1162,6 +2582,33 @@ class AccountManager { this.openAppModal(); }); }); + + // Add Asset button + document.querySelectorAll('#addAssetBtn').forEach(btn => { + btn.addEventListener('click', () => { + this.editingAssetId = undefined; + this.openAssetModal(); + }); + }); + + const importAssetBtn = document.getElementById('importAssetBtn'); + const assetImportInput = document.getElementById('assetImportInput'); + const exportAssetBtn = document.getElementById('exportAssetBtn'); + + if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) { + importAssetBtn.addEventListener('click', () => 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() { @@ -1211,6 +2658,34 @@ class AccountManager { 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) { @@ -2302,6 +3777,18 @@ 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 closeUserModal() { const userModalContainer = document.getElementById('userModalContainer'); if (userModalContainer) { diff --git a/public/modals.html b/public/modals.html index 74fe02c..03907d9 100644 --- a/public/modals.html +++ b/public/modals.html @@ -197,3 +197,126 @@ + + + + + + + + + diff --git a/public/pages/index.html b/public/pages/index.html index afd0070..0ac238e 100644 --- a/public/pages/index.html +++ b/public/pages/index.html @@ -3,7 +3,7 @@ - Robot Manager Account - Account Management System + Robot Manager Account - Hệ thống quản lý @@ -12,6 +12,7 @@ +