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 ` +
Theo dõi tài sản, kho và trạng thái sử dụng.
+| + + | +ID | +Mã | +Tên tài sản | +Danh mục | +Hãng | +Model | +Serial | +Số lượng | +Đơn vị | +Phòng ban | +Người phụ trách | +Trạng thái | +Vị trí | +Ngày mua | +Giá mua | +Hạn bảo hành | +Ghi chú | +Ngày tạo | +Cập nhật | +Thao 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)} | +
+
+
+
+
+
+ |
+
Chưa có dữ liệu tài sản. Hãy thêm tài sản đầu tiên.
+ +