const { assetCategoryManager } = require('../../datasets/assetData'); const assetApiConfig = require('../configs/assetApiConfig.js'); class AssetMenu { constructor({ manager = assetCategoryManager, softwareType = null } = {}) { this.manager = manager; this.softwareType = softwareType; this.categories = this.manager .listCategories({ withMeta: true }) .reduce((map, meta) => { map[meta.softwareType] = this.manager.getCategory(meta.softwareType); return map; }, {}); } normalizeCategory(key) { const category = this.categories[key]; if (!category) { return null; } return { ...category, label: category.label || category.softwareType || key, suppliers: (category.suppliers || []).map((supplier) => ({ ...supplier, id: supplier.id || supplier.name, types: (supplier.types || []).map((type) => ({ ...type, id: type.id || type.name, models: (type.models || []).map((model) => ({ ...model, id: model.id || model.name, units: model.units || [] })) })) })) }; } resolveCategoryForNode(nodeName) { const keys = Object.keys(this.categories); if (keys.length === 0) { return null; } if (this.softwareType && this.categories[this.softwareType]) { return this.softwareType; } if (nodeName) { const normalized = typeof nodeName === 'string' ? nodeName.toLowerCase() : nodeName; if (normalized && this.categories[normalized]) { return normalized; } } return keys[0]; } getAllMenuData(nodeName) { const categoryKey = this.resolveCategoryForNode(nodeName); const selectedCategories = {}; if (categoryKey && this.categories[categoryKey]) { selectedCategories[categoryKey] = this.normalizeCategory(categoryKey); } return { categories: selectedCategories, defaultCategory: categoryKey, apiConfig: { url: `${assetApiConfig.baseUrl}/apis/products/PLC/integration/`, headers: { ...assetApiConfig.headers } } }; } getClientInitCode(nodeName) { const htmlCode = this.getHtmlInjectionCode(nodeName); const dataCode = this.getDataInjectionCode(nodeName); const eventsCode = this.getEventInjectionCode(nodeName); const syncCode = this.getSyncInjectionCode(nodeName); const saveCode = this.getSaveInjectionCode(nodeName); return ` // --- AssetMenu for ${nodeName} --- window.EVOLV.nodes.${nodeName}.assetMenu = window.EVOLV.nodes.${nodeName}.assetMenu || {}; ${htmlCode} ${dataCode} ${eventsCode} ${syncCode} ${saveCode} window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) { console.log('Initializing asset properties for ${nodeName}'); this.injectHtml(); this.wireEvents(node); this.loadData(node).catch((error) => console.error('Asset menu load failed:', error) ); }; `; } getDataInjectionCode(nodeName) { return ` // Asset data loader for ${nodeName} window.EVOLV.nodes.${nodeName}.assetMenu.loadData = async function(node) { const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {}; const categories = menuAsset.categories || {}; const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null; const apiConfig = menuAsset.apiConfig || {}; const elems = { supplier: document.getElementById('node-input-supplier'), type: document.getElementById('node-input-assetType'), model: document.getElementById('node-input-model'), unit: document.getElementById('node-input-unit') }; function resolveCategoryKey() { if (node.softwareType && categories[node.softwareType]) { return node.softwareType; } if (node.category && categories[node.category]) { return node.category; } return defaultCategory; } function normalizeModel(model = {}) { return { id: model.id ?? model.name, name: model.name, units: model.units || [] }; } function normalizeType(type = {}) { return { id: type.id || type.name, name: type.name, models: Array.isArray(type.models) ? type.models.map(normalizeModel) : [] }; } function normalizeSupplier(supplier = {}) { const types = (supplier.categories || []).reduce((acc, category) => { const categoryTypes = Array.isArray(category.types) ? category.types.map(normalizeType) : []; return acc.concat(categoryTypes); }, []); return { id: supplier.id || supplier.name, name: supplier.name, types }; } function normalizeApiCategory(key, label, suppliers = []) { const normalizedSuppliers = suppliers .map(normalizeSupplier) .filter((supplier) => supplier.types && supplier.types.length); if (!normalizedSuppliers.length) { return null; } return { softwareType: key, label: label || key, suppliers: normalizedSuppliers }; } async function fetchCategoryFromApi(key) { if (!apiConfig.url || !key) { return null; } const response = await fetch(apiConfig.url, { headers: apiConfig.headers || {} }); if (!response.ok) { throw new Error('Asset API request failed: ' + response.status); } const payload = await response.json(); if (!payload || payload.success === false || !Array.isArray(payload.data)) { throw new Error(payload?.message || 'Unexpected asset API response'); } return normalizeApiCategory(key, node.softwareType || key, payload.data); } function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') { const previous = selectEl.value; const mapper = typeof mapFn === 'function' ? mapFn : (value) => ({ value, label: value }); selectEl.innerHTML = ''; const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = placeholderText; placeholder.disabled = true; placeholder.selected = true; selectEl.appendChild(placeholder); items.forEach((item) => { const option = mapper(item); if (!option || typeof option.value === 'undefined') { return; } const opt = document.createElement('option'); opt.value = option.value; opt.textContent = option.label; selectEl.appendChild(opt); }); if (selectedValue) { selectEl.value = selectedValue; if (!selectEl.value) { selectEl.value = ''; } } else { selectEl.value = ''; } if (selectEl.value !== previous) { selectEl.dispatchEvent(new Event('change')); } } const categoryKey = resolveCategoryKey(); const resolvedCategoryKey = categoryKey || defaultCategory; let activeCategory = resolvedCategoryKey ? categories[resolvedCategoryKey] : null; if (resolvedCategoryKey) { node.category = resolvedCategoryKey; } try { const apiCategory = await fetchCategoryFromApi(resolvedCategoryKey); if (apiCategory) { categories[resolvedCategoryKey] = apiCategory; activeCategory = apiCategory; } } catch (error) { console.warn('[AssetMenu] API lookup failed for ${nodeName}, using local asset data', error); } const suppliers = activeCategory ? activeCategory.suppliers : []; populate( elems.supplier, suppliers, node.supplier, (supplier) => ({ value: supplier.id || supplier.name, label: supplier.name }), suppliers.length ? 'Select...' : 'No suppliers available' ); const activeSupplier = suppliers.find( (supplier) => String(supplier.id || supplier.name) === String(node.supplier) ); const types = activeSupplier ? activeSupplier.types : []; populate( elems.type, types, node.assetType, (type) => ({ value: type.id || type.name, label: type.name }), activeSupplier ? 'Select...' : 'Awaiting Supplier Selection' ); const activeType = types.find( (type) => String(type.id || type.name) === String(node.assetType) ); const models = activeType ? activeType.models : []; populate( elems.model, models, node.model, (model) => ({ value: model.id || model.name, label: model.name }), activeType ? 'Select...' : 'Awaiting Type Selection' ); const activeModel = models.find( (model) => String(model.id || model.name) === String(node.model) ); if (activeModel) { node.modelMetadata = activeModel; node.modelName = activeModel.name; } populate( elems.unit, activeModel ? activeModel.units || [] : [], node.unit, (unit) => ({ value: unit, label: unit }), activeModel ? 'Select...' : activeType ? 'Awaiting Model Selection' : 'Awaiting Type Selection' ); this.setAssetTagNumber(node, node.assetTagNumber || ''); }; `; } getEventInjectionCode(nodeName) { return ` // Asset event wiring for ${nodeName} window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) { const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {}; const categories = menuAsset.categories || {}; const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null; const elems = { supplier: document.getElementById('node-input-supplier'), type: document.getElementById('node-input-assetType'), model: document.getElementById('node-input-model'), unit: document.getElementById('node-input-unit') }; function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') { const previous = selectEl.value; const mapper = typeof mapFn === 'function' ? mapFn : (value) => ({ value, label: value }); selectEl.innerHTML = ''; const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = placeholderText; placeholder.disabled = true; placeholder.selected = true; selectEl.appendChild(placeholder); items.forEach((item) => { const option = mapper(item); if (!option || typeof option.value === 'undefined') { return; } const opt = document.createElement('option'); opt.value = option.value; opt.textContent = option.label; selectEl.appendChild(opt); }); if (selectedValue) { selectEl.value = selectedValue; if (!selectEl.value) { selectEl.value = ''; } } else { selectEl.value = ''; } if (selectEl.value !== previous) { selectEl.dispatchEvent(new Event('change')); } } const resolveCategoryKey = () => { if (node.softwareType && categories[node.softwareType]) { return node.softwareType; } if (node.category && categories[node.category]) { return node.category; } return defaultCategory; }; const getActiveCategory = () => { const key = resolveCategoryKey(); return key ? categories[key] : null; }; node.category = resolveCategoryKey(); elems.supplier.addEventListener('change', () => { const category = getActiveCategory(); const supplier = category ? category.suppliers.find( (item) => String(item.id || item.name) === String(elems.supplier.value) ) : null; const types = supplier ? supplier.types : []; populate( elems.type, types, node.assetType, (type) => ({ value: type.id || type.name, label: type.name }), supplier ? 'Select...' : 'Awaiting Supplier Selection' ); node.modelMetadata = null; populate(elems.model, [], '', undefined, 'Awaiting Type Selection'); populate(elems.unit, [], '', undefined, 'Awaiting Type Selection'); }); elems.type.addEventListener('change', () => { const category = getActiveCategory(); const supplier = category ? category.suppliers.find( (item) => String(item.id || item.name) === String(elems.supplier.value) ) : null; const type = supplier ? supplier.types.find( (item) => String(item.id || item.name) === String(elems.type.value) ) : null; const models = type ? type.models : []; populate( elems.model, models, node.model, (model) => ({ value: model.id || model.name, label: model.name }), type ? 'Select...' : 'Awaiting Type Selection' ); node.modelMetadata = null; populate( elems.unit, [], '', undefined, type ? 'Awaiting Model Selection' : 'Awaiting Type Selection' ); }); elems.model.addEventListener('change', () => { const category = getActiveCategory(); const supplier = category ? category.suppliers.find( (item) => String(item.id || item.name) === String(elems.supplier.value) ) : null; const type = supplier ? supplier.types.find( (item) => String(item.id || item.name) === String(elems.type.value) ) : null; const model = type ? type.models.find( (item) => String(item.id || item.name) === String(elems.model.value) ) : null; node.modelMetadata = model; node.modelName = model ? model.name : ''; populate( elems.unit, model ? model.units || [] : [], node.unit, (unit) => ({ value: unit, label: unit }), model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection' ); }); }; `; } getSyncInjectionCode(nodeName) { return ` // Asset synchronization helpers for ${nodeName} window.EVOLV.nodes.${nodeName}.assetMenu.setAssetTagNumber = function(node, tag) { const normalized = tag ? tag.toString() : ''; const input = document.getElementById('node-input-assetTagNumber'); const hint = document.getElementById('node-input-assetTagNumber-hint'); console.info('[AssetMenu] tag number update', { nodeId: node && node.id ? node.id : null, tag: normalized }); if (input) { input.value = normalized; } if (hint) { hint.textContent = normalized ? 'Assigned tag ' + normalized : 'Not registered yet'; } if (node) { node.assetTagNumber = normalized; } }; window.EVOLV.nodes.${nodeName}.assetMenu.buildSyncRequest = function(node) { const tagInput = document.getElementById('node-input-assetTagNumber'); const candidateTag = tagInput && tagInput.value ? tagInput.value.trim() : ''; const fallbackTag = node && node.assetTagNumber ? node.assetTagNumber : ''; const registrationDefaults = (window.EVOLV.nodes.${nodeName}.config && window.EVOLV.nodes.${nodeName}.config.assetRegistration && window.EVOLV.nodes.${nodeName}.config.assetRegistration.default) || {}; const displayName = node && node.name ? node.name : node && node.id ? node.id : '${nodeName}'; console.info('[AssetMenu] build sync payload', { nodeId: node && node.id ? node.id : null, candidateTag, fallbackTag, status: registrationDefaults.status || 'actief' }); return { asset: { tagNumber: candidateTag || fallbackTag, supplier: node && node.supplier ? node.supplier : '', assetType: node && node.assetType ? node.assetType : '', model: node && node.model ? node.model : '', unit: node && node.unit ? node.unit : '', assetName: displayName, assetDescription: displayName, assetStatus: registrationDefaults.status || 'actief', modelMetadata: node && node.modelMetadata ? node.modelMetadata : null }, nodeId: node && node.id ? node.id : null, nodeName: node && node.type ? node.type : '${nodeName}' }; }; window.EVOLV.nodes.${nodeName}.assetMenu.syncAsset = function(node) { const payload = this.buildSyncRequest(node); const redSettings = window.RED && window.RED.settings; const adminRoot = redSettings ? redSettings.httpAdminRoot : ''; const trimmedRoot = adminRoot && adminRoot.endsWith('/') ? adminRoot.slice(0, -1) : adminRoot || ''; const prefix = trimmedRoot || ''; const endpoint = (prefix || '') + '/${nodeName}/asset-reg'; console.info('[AssetMenu] sync request', { endpoint, nodeId: node && node.id ? node.id : null, tagNumber: payload && payload.asset ? payload.asset.tagNumber : null }); fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then((res) => res.json().catch((err) => { console.warn('[AssetMenu] asset sync response is not JSON', err); return { success: false, message: err.message || 'Invalid API response' }; }) ) .then((result) => { console.info('[AssetMenu] sync response', result); if (result && result.success) { const newTag = (result.data && result.data.asset_tag_number) || payload.asset.tagNumber || ''; this.setAssetTagNumber(node, newTag); if (window.RED && typeof window.RED.notify === 'function') { window.RED.notify('Asset synced: ' + (newTag || 'no tag'), 'info'); } } else { console.warn('[AssetMenu] asset sync failed', result && result.message); } }) .catch((error) => { console.error('[AssetMenu] asset sync error', error); }); }; `; } getHtmlTemplate() { return `

Asset selection

Not registered yet

`; } getHtmlInjectionCode(nodeName) { const htmlTemplate = this.getHtmlTemplate() .replace(/`/g, '\\`') .replace(/\$/g, '\\$'); return ` // Asset HTML injection for ${nodeName} window.EVOLV.nodes.${nodeName}.assetMenu.injectHtml = function() { const placeholder = document.getElementById('asset-fields-placeholder'); if (placeholder && !placeholder.hasChildNodes()) { placeholder.innerHTML = \`${htmlTemplate}\`; console.log('Asset HTML injected successfully'); } }; `; } getSaveInjectionCode(nodeName) { return ` // Asset save handler for ${nodeName} window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) { console.log('Saving asset properties for ${nodeName}'); const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {}; const categories = menuAsset.categories || {}; const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null; const resolveCategoryKey = () => { if (node.softwareType && categories[node.softwareType]) { return node.softwareType; } if (node.category && categories[node.category]) { return node.category; } return defaultCategory || ''; }; node.category = resolveCategoryKey(); const fields = ['supplier', 'assetType', 'model', 'unit', 'assetTagNumber']; const errors = []; fields.forEach((field) => { const el = document.getElementById(\`node-input-\${field}\`); node[field] = el ? el.value : ''; }); if (node.assetType && !node.unit) { errors.push('Unit must be set when a type is specified.'); } if (!node.unit) { errors.push('Unit is required.'); } errors.forEach((msg) => RED.notify(msg, 'error')); const saved = fields.reduce((acc, field) => { acc[field] = node[field]; return acc; }, {}); if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') { saved.modelId = node.modelMetadata.id; } console.log('[AssetMenu] save result:', saved); if (errors.length === 0 && this.syncAsset) { this.syncAsset(node); } return errors.length === 0; }; `; } } module.exports = AssetMenu;