Table of contents

Flow 1 — Machine

if (msg.payload.length > 1000) { return msg; } else { return {payload: ""} }
const unavailable_stream = { "MTConnectStreams": { "$": { "xmlns:m": "urn:mtconnect.org:MTConnectStreams:1.3", "xmlns": "urn:mtconnect.org:MTConnectStreams:1.3", "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "xmlns:x": "urn:CustomData.com:CustomDataStreams:1.3", "xsi:schemaLocation": "urn:CustomData.com:CustomDataStreams:1.3 DevicesCustomDataStreams.xsd" }, "Header": [{ "$": { "creationTime": "2025-11-21T05:11:26Z", "sender": "IKTBNPG-Milling", "instanceId": "1763696481", "version": "1.3.0.17", "bufferSize": "131072", "nextSequence": "5089", "firstSequence": "1", "lastSequence": "5088" } }], "Streams": [{ "DeviceStream": [{ "$": { "name": "FANUC_CNC", "uuid": "000" }, "ComponentStream": [{ "$": { "component": "Rotary", "name": "C1", "componentId": "C1" }, "Samples": [{ "Load": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nloadSPP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.load-SP_P1", "sequence": "5071" } }], "RotaryVelocity": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nspeedSPP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.speed-SP_P1", "sequence": "5070", "subType": "ACTUAL" } }] }], "Events": [{ "RotaryMode": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nspdlmodeSPP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.spdlmode-SP_P1", "sequence": "5072" } }] }], "Condition": [{ "Unavailable": [{ "$": { "dataItemId": "nservoSPP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.servo-SP_P1", "sequence": "5088", "type": "ACTUATOR" } }] }] }, { "$": { "component": "Path", "name": "Path", "componentId": "Path" }, "Samples": [{ "PathFeedrate": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "npathfdrt1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.pathfdrt-1", "sequence": "5055", "subType": "ACTUAL" } }] }], "Events": [{ "ActiveAxes": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nactaxes1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.actaxes-1", "sequence": "5056" } }], "Block": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nblock1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.block-1", "sequence": "5054" } }], "Execution": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nexecution1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.execution-1", "sequence": "5058" } }], "PathFeedrateOverride": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nfeedoverride1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.feedoverride-1", "sequence": "5061", "subType": "PROGRAMMED" } }, { "_": "UNAVAILABLE", "$": { "dataItemId": "nrapidoverride1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.rapidoverride-1", "sequence": "5062", "subType": "RAPID" } }], "Line": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nline1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.line-1", "sequence": "5053" } }], "ControllerMode": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nmode1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.mode-1", "sequence": "5057" } }], "PartCount": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "npartcnt1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.partcnt-1", "sequence": "5059" } }], "ProgramComment": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nprogcom1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.progcom-1", "sequence": "5052" } }], "Program": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nprogram1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.program-1", "sequence": "5051" } }], "RotaryVelocityOverride": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nspdloverride1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.spdloverride-1", "sequence": "5060" } }], "ToolNumber": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "ntoolnumber1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.toolnumber-1", "sequence": "5050" } }] }], "Condition": [{ "Unavailable": [{ "$": { "dataItemId": "ncomms1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.comms-1", "sequence": "5074", "type": "COMMUNICATIONS" } }, { "$": { "dataItemId": "nlogic1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.logic-1", "sequence": "5075", "type": "LOGIC_PROGRAM" } }, { "$": { "dataItemId": "nmotion1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.motion-1", "sequence": "5076", "type": "MOTION_PROGRAM" } }, { "$": { "dataItemId": "nopmessage1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.opmessage-1", "sequence": "5078", "type": "SYSTEM" } }, { "$": { "dataItemId": "nservonoaxis1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.servonoaxis-1", "sequence": "5073", "type": "ACTUATOR" } }, { "$": { "dataItemId": "nsystem1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.system-1", "sequence": "5077", "type": "SYSTEM" } }] }] }, { "$": { "component": "Linear", "name": "X1", "componentId": "X1" }, "Samples": [{ "Position": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nactXP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.act-X_P1", "sequence": "5064", "subType": "ACTUAL" } }], "Load": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nloadXP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.load-X_P1", "sequence": "5065" } }] }], "Condition": [{ "Unavailable": [{ "$": { "dataItemId": "noverheatXP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.overheat-X_P1", "sequence": "5080", "type": "TEMPERATURE" } }, { "$": { "dataItemId": "nservoXP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.servo-X_P1", "sequence": "5081", "type": "ACTUATOR" } }, { "$": { "dataItemId": "ntravelXP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.travel-X_P1", "sequence": "5079", "type": "POSITION" } }] }] }, { "$": { "component": "Linear", "name": "Y1", "componentId": "Y1" }, "Samples": [{ "Position": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nactYP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.act-Y_P1", "sequence": "5066", "subType": "ACTUAL" } }], "Load": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nloadYP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.load-Y_P1", "sequence": "5067" } }] }], "Condition": [{ "Unavailable": [{ "$": { "dataItemId": "noverheatYP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.overheat-Y_P1", "sequence": "5083", "type": "TEMPERATURE" } }, { "$": { "dataItemId": "nservoYP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.servo-Y_P1", "sequence": "5084", "type": "ACTUATOR" } }, { "$": { "dataItemId": "ntravelYP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.travel-Y_P1", "sequence": "5082", "type": "POSITION" } }] }] }, { "$": { "component": "Linear", "name": "Z1", "componentId": "Z1" }, "Samples": [{ "Position": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nactZP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.act-Z_P1", "sequence": "5068", "subType": "ACTUAL" } }], "Load": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nloadZP1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.load-Z_P1", "sequence": "5069" } }] }], "Condition": [{ "Unavailable": [{ "$": { "dataItemId": "noverheatZP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.overheat-Z_P1", "sequence": "5086", "type": "TEMPERATURE" } }, { "$": { "dataItemId": "nservoZP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.servo-Z_P1", "sequence": "5087", "type": "ACTUATOR" } }, { "$": { "dataItemId": "ntravelZP1", "timestamp": "2025-11-21T04:25:31.519Z", "name": "n.travel-Z_P1", "sequence": "5085", "type": "POSITION" } }] }] }, { "$": { "component": "Controller", "name": "controller", "componentId": "cn1" }, "Events": [{ "EmergencyStop": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "nestop1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.estop-1", "sequence": "5063" } }] }] }, { "$": { "component": "Device", "name": "FANUC_CNC", "componentId": "dev" }, "Events": [{ "x:Cuttingtime": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "CuttingTime", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.CuttingTime", "sequence": "5046" } }], "x:Cuttingtime2": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "CuttingTime2", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.CuttingTime2", "sequence": "5047" } }], "x:Cycletime1": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "CycleTime1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.CycleTime1", "sequence": "5048" } }], "x:Cycletime2": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "CycleTime2", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.CycleTime2", "sequence": "5049" } }], "x:Operatingtime1": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "OperatingTime1", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.OperatingTime1", "sequence": "5044" } }], "x:Operatingtime2": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "OperatingTime2", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.OperatingTime2", "sequence": "5045" } }], "x:Partsrequired": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "PartsRequired", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.PartsRequired", "sequence": "5043" } }], "x:Partstotal": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "PartsTotal", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.PartsTotal", "sequence": "5042" } }], "x:Powerontime": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "PowerOnTime", "timestamp": "2025-11-21T04:25:20.264Z", "name": "n.PowerOnTime", "sequence": "5040" } }], "AssetChanged": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "dev_asset_chg", "timestamp": "2025-11-21T03:41:21.063989Z", "sequence": "10", "assetType": "" } }], "AssetRemoved": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "dev_asset_rem", "timestamp": "2025-11-21T03:41:21.063989Z", "sequence": "11", "assetType": "" } }], "Availability": [{ "_": "UNAVAILABLE", "$": { "dataItemId": "navail", "timestamp": "2025-11-21T04:25:31.518Z", "name": "n.avail-", "sequence": "5041" } }] }] }] }] }] } } let milling_stream = flow.get('milling_stream'); let milling_stream1 = flow.get('milling_stream1'); let store = flow.get("deviceStreamStore") || [null, null]; if (msg.topic === "from_milling") { if (milling_stream == null) { milling_stream = unavailable_stream.MTConnectStreams?.Streams?.[0]?.DeviceStream || []; } else { milling_stream = milling_stream.MTConnectStreams?.Streams?.[0]?.DeviceStream || []; } if (milling_stream.length > 0) { if (!milling_stream[0].$) { milling_stream[0].$ = {}; } milling_stream[0].$.name = `${milling_stream[0].$.name}_0`; milling_stream[0].$.uuid = `${milling_stream[0].$.uuid}_0`; store[0] = milling_stream[0]; } } else if (msg.topic === "from_milling1") { if (milling_stream1 == null) { milling_stream1 = unavailable_stream.MTConnectStreams?.Streams?.[0]?.DeviceStream || []; } else { milling_stream1 = milling_stream1.MTConnectStreams?.Streams?.[0]?.DeviceStream || []; } if (milling_stream1.length > 0) { if (!milling_stream1[0].$) { milling_stream1[0].$ = {}; } milling_stream1[0].$.name = `${milling_stream1[0].$.name}_1`; milling_stream1[0].$.uuid = `${milling_stream1[0].$.uuid}_1`; store[1] = milling_stream1[0]; } } flow.set("deviceStreamStore", store); /* node.warn({ milling: !!store[0], milling1: !!store[1] }); */ if (!store[0] || !store[1]) { return null; } msg.payload = { MTConnectStreams: { Streams: [{ DeviceStream: [ store[0], store[1] ] }] } }; return msg;
let allDeviceStreams = msg.payload.MTConnectStreams.Streams[0].DeviceStream; let finalResult = []; allDeviceStreams.forEach(deviceStream => { let result = {}; let eventMap = {}; result.name1 = deviceStream.$.name || "Unknown"; result.uuid = deviceStream.$.uuid || "Unknown"; result.Faults = []; deviceStream.ComponentStream.forEach(component => { let componentName = component.$.component + (component.$.name ? `_${component.$.name}` : ""); ['Events', 'Samples', 'Condition'].forEach(section => { if (component[section]) { component[section].forEach(group => { for (let tag in group) { if (Array.isArray(group[tag]) && group[tag][0]) { group[tag].forEach(item => { let value = item['_'] || item; result[`${componentName}_${tag}`] = value; eventMap[tag] = value; if (tag === "Fault") { result.Faults.push({ component: componentName, tag: tag, dataItemId: item.$?.dataItemId || "", timestamp: item.$?.timestamp || "", name: item.$?.name || "", sequence: item.$?.sequence || "", nativeCode: item.$?.nativeCode || "", type: item.$?.type || "", message: value }); } }); } } }); } }); }); function getEventValue(tagName) { return eventMap[tagName] ?? null; } result.ActiveAxes = getEventValue('ActiveAxes'); result.ToolId = getEventValue('ToolId'); let programRaw = getEventValue('Program') || ""; let match = programRaw.match(/PATH\d+\/O(\d+)/); if (match) { result.Program = `PATH1/${match[1].padStart(3, '0')}`; } else { result.Program = ""; } result.ProgramComment = getEventValue('ProgramComment'); result.Line = getEventValue('Line'); result.Block = getEventValue('Block'); result.ControllerMode = getEventValue('ControllerMode'); result.Execution = getEventValue('Execution'); result.PartCount = getEventValue('PartCount'); result.RotaryVelocityOverride = getEventValue('RotaryVelocityOverride'); result.PathFeedrateOverride = getEventValue('PathFeedrateOverride'); result.RotaryMode = getEventValue('RotaryMode'); result.EmergencyStop = getEventValue('EmergencyStop'); result.Availability = getEventValue('Availability') === 'AVAILABLE' ? 1 : 0; result.Availability1 = getEventValue('Availability'); result.AssetChanged = getEventValue('AssetChanged'); result.AssetRemoved = getEventValue('AssetRemoved'); result.SPCC = String( result['Device_FANUC_CNC_x:Spccsignal'] || "FALSE" ).trim().toUpperCase() === 'TRUE' ? 1 : 0; result.SPCW = String( result['Device_FANUC_CNC_x:Spcwsignal'] || "FALSE" ).trim().toUpperCase() === 'TRUE' ? 1 : 0; finalResult.push(result); }); msg.payload = { machines: finalResult }; return msg;
let now = Date.now(); function getValueByKeyword(machine, keyword) { let key = Object.keys(machine).find(k => k.includes(keyword)); return key ? machine[key] : "0"; } function formatDuration(ms) { let totalSeconds = Math.floor(ms / 1000); let hrs = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); let mins = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); let secs = String(totalSeconds % 60).padStart(2, '0'); return `${hrs}:${mins}:${secs}`; } msg.payload.machines = msg.payload.machines.map(machine => { //Operating Time let operatingTimeRaw = parseInt(getValueByKeyword(machine, "Operatingtime1"), 10); let operatingTime1Raw = parseFloat(getValueByKeyword(machine, "Operatingtime2")); let totalOperatingMs = operatingTimeRaw + (operatingTime1Raw * 60 * 1000); machine.TotalOperatingMins = Math.round(totalOperatingMs / (60 * 1000)); machine.OperatingTimeFormatted = formatDuration(totalOperatingMs); //Cycle Time let cycleTimeRaw = parseInt(getValueByKeyword(machine, "Cycletime1"), 10); let cycleTime1Raw = parseFloat(getValueByKeyword(machine, "Cycletime2")); let totalCycleMs = cycleTimeRaw + (cycleTime1Raw * 60 * 1000); machine.TotalCycleTimeMins = Math.round(totalCycleMs / (60 * 1000)); machine.CycleTimeFormatted = formatDuration(totalCycleMs); return machine; }); return msg;
let machines = msg.payload.machines || []; let updatedMachines = []; let executionStats = {}; let total = machines.length; machines.forEach(machine => { let status = machine.Execution || "UNKNOWN"; if (!executionStats[status]) { executionStats[status] = { count: 0 }; } executionStats[status].count += 1; updatedMachines.push(machine); }); for (let status in executionStats) { executionStats[status].percentage = ((executionStats[status].count / total) * 100).toFixed(2); } msg.payload = { machines: updatedMachines, executionStats: executionStats, total: total }; return msg;
const odoo_data = global.get('odoo_data') || [] const machines_data = global.get('machine_data') const maintenance_data = global.get('maintenance_data') || [] msg.payload = { machines: machines_data.machines, executionStats: machines_data.executionStats, total: machines_data.total, productionData: odoo_data.productionData, maintenanceData: maintenance_data.maintenanceData } return msg;
let points = []; msg.payload.forEach(tool => { points.push({ measurement: "toollife", tags: { machine: tool.machine, toolNumber: String(tool.toolNumber) }, fields: { name: String(tool.name), type: String(tool.type), material: String(tool.material), expectedLife: Number(tool.expectedLife), usedMins: Number(tool.usedMins), remainingMins: Number(tool.remainingMins), percentage: Number(tool.percentage), status: String(tool.status), startTime: tool.startTime ? String(tool.startTime) : '', lastUpdatedTime: tool.lastUpdatedTime ? String(tool.lastUpdatedTime): '' }, timestamp: new Date() }); }); msg.payload = points; return msg;
const topic = msg.topic; let workorder = context.get("workorder") || null; let machines = context.get("machines") || []; // now store ALL machines if (topic === "workorder_raw") { workorder = msg.payload; context.set("workorder", workorder); } if (topic === "machine_data") { if (Array.isArray(msg.payload.machines)) { machines = msg.payload.machines; } else if (Array.isArray(msg.payload)) { machines = msg.payload; } context.set("machines", machines); } if (!workorder || machines.length === 0) { return null; } const matchedMachine = machines.find(m => m.name1 === workorder.workcenter_name && m.uuid === workorder.workcenter_code ); if (!matchedMachine) { //node.warn("⚠ No matching machine found for workorder: " + workorder.workcenter_name); return null; } let planned_time = Number(workorder.planned_time) || 0; let product_qty = Number(workorder.product_qty) || 0; let actual_qty = Number(matchedMachine.PartCount) || 0; let runtime = Number(matchedMachine.TotalOperatingMins) || 0; let totalCycle = Number(matchedMachine.TotalCycleTimeMins) || 0; let availability = 0; if (planned_time > 0) { availability = (runtime / planned_time) * 100; availability = Math.min(availability, 100); } let idealCycleTime = 0; if (product_qty > 0) idealCycleTime = planned_time / product_qty; let performance = 0; if (runtime > 0 && idealCycleTime > 0 && actual_qty > 0) { performance = ((idealCycleTime * actual_qty) / runtime) * 100; performance = Math.min(performance, 100); } let oee = 0; if (availability > 0 && performance > 0) { oee = (availability * performance) / 100; } msg.payload = { workorder_id: workorder.workorder_id, workorder_name: workorder.workorder_name, order_id: workorder.order_id, order_name: workorder.order_name, workorder_state: workorder.workorder_state, product_name: workorder.product_name, workcenter_name: workorder.workcenter_name, workcenter_code: workorder.workcenter_code, date_start: workorder.date_start, timestamp: workorder.timestamp, planned_time, runtime, product_qty, actual_qty, availability: Number(availability.toFixed(2)), performance: Number(performance.toFixed(2)), oee: Number(oee.toFixed(2)) }; return msg;
msg.payload = [ { measurement: "oee_metrics", fields: { availability: msg.payload.availability, performance: msg.payload.performance, oee: msg.payload.oee, runtime: msg.payload.runtime, planned_time: msg.payload.planned_time, product_qty: msg.payload.product_qty, actual_qty: msg.payload.actual_qty, workorder_id: Number(msg.payload.workorder_id), workorder_name: msg.payload.workorder_name, order_name: msg.payload.order_name, product_name: msg.payload.product_name, workorder_state: msg.payload.workorder_state, workcenter_name: msg.payload.workcenter_name, workcenter_code: msg.payload.workcenter_code, date_start: msg.payload.date_start }, tags: { workorder_id: String(Number(msg.payload.workorder_id)), workcenter: msg.payload.workcenter_name }, time: new Date().toISOString() } ]; return msg;
// When someone starts/pauses/continues/done if (msg.topic === "sync_active_order") { flow.set("activeOrder", msg.payload || null); node.send({ topic: "broadcast_active_order", payload: msg.payload || null }); return null; } //When a new dashboard connects if (msg.topic === "request_active_order") { const activeOrder = flow.get("activeOrder") || null; node.send({ topic: "broadcast_active_order", payload: activeOrder }); return null; } return null;
<style> md-toolbar { display: none !important; } md-content.md-default-theme, md-content { margin-top: 0 !important; padding-top: 0 !important; } :root { --primary-color: black; --secondary-color: #3498db; --accent-color: #e74c3c; --light-color: #ecf0f1; --dark-color: #34495e; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f5f7fa; } .fullscreen-dashboard { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; padding: 20px; background-color: var(--light-color); overflow: auto; } .header-container { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; background-color: var(--primary-color); padding: 15px 20px; border-radius: 8px; color: white; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } .search-container { display: flex; gap: 15px; align-items: center; } .time-info { font-size: 18px; font-weight: bold; } .fault-info { font-size: 30px; font-weight: bold; } .metrics-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; margin-bottom: 25px; } .metric-card { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); transition: transform 0.3s, box-shadow 0.3s; } .metric-card:hover { transform: translateY(-5px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); } .metric-title { font-size: 14px; color: #7f8c8d; margin-bottom: 5px; } .metric-value { font-size: 24px; font-weight: bold; color: var(--dark-color); } .connection-value { font-size: 50px; font-weight: bold; color: red; animation: blink 2s infinite; text-align: center; } .connection-value1 { font-size: 28px; font-weight: bold; color: black; text-align: center; } .main-content { display: flex; gap: 5px; margin-bottom: 25px; height: 35vh; } .fault-content { display: flex; gap: 5px; margin-bottom: 25px; height: 35vh; overflow-y: auto; overflow-x: hidden; } .fault-section { flex: 1; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); } .production-container { display: flex; flex-wrap: wrap; gap: 5px; width: 100%; } .production-section, .stats-section { flex: 1 1 48%; min-width: 300px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); box-sizing: border-box; margin: 10px; } .machine-container { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; } .machine-section { flex: 0 0 300px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; align-items: center; justify-content: center; } .stat-section { flex: 0 0 300px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; align-items: center; justify-content: center; } .start-section { flex: 0 0 300px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; align-items: center; } .section-title { font-size: 18px; color: var(--dark-color); margin-bottom: 15px; border-bottom: 2px solid var(--light-color); padding-bottom: 8px; } .big-number { font-size: 48px; font-weight: bold; text-align: center; margin: 20px 0; color: var(--primary-color); } .chart-container { height: 200px; position: relative; margin-top: 20px; } ?.production&view_type=form .bar-chart { position: absolute; bottom: 0; width: 100%; display: flex; align-items: flex-end; height: 100%; } .bar-column { flex: 1; display: flex; flex-direction: column; align-items: center; } .bar { width: 30px; background: var(--secondary-color); border-radius: 4px 4px 0 0; transition: height 0.5s ease; } .time-labels { display: flex; justify-content: space-around; margin-top: 10px; font-size: 12px; color: #7f8c8d; } .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin-bottom: 20px; } .stat-card { text-align: center; padding: 10px; background: #f8f9fa; border-radius: 6px; } .stat-title { font-size: 14px; color: #7f8c8d; } .stat-value { font-size: 32px; font-weight: bold; margin-top: 5px; } .behind-value { color: var(--accent-color); } .oee-pie { width: 200px; height: 200px; border-radius: 50%; background: conic-gradient(#2ecc71 0% 70%, #3498db 70% 85%, #f39c12 85% 95%, #ecf0f1 95% 100%); position: relative; margin: 20px 0; } .oee-value { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 36px; font-weight: bold; color: var(--primary-color); } .oee-legend { display: flex; flex-direction: column; gap: 8px; margin-top: 15px; } .oee-legend-item { display: flex; align-items: center; font-size: 14px; } .oee-legend-color { width: 15px; height: 15px; margin-right: 8px; border-radius: 3px; } .action-buttons { display: flex; justify-content: space-between; margin-top: 25px; } .action-button { padding: 10px 20px; border: none; border-radius: 6px; font-size: 16px; font-weight: bold; cursor: pointer; transition: all 0.3s; } .alarm-button { background: var(--accent-color); color: white; } .alarm-button:hover { background: #c0392b; transform: translateY(-2px); } .reject-button { background: #f39c12; color: white; } .reject-button:hover { background: #d35400; transform: translateY(-2px); } .categorize-button { background: black; color: white; } .categorize-button:hover { background: #2980b9; transform: translateY(-2px); } .home-button { background: white; color: black; } .home-button:hover { background: #2980b9; transform: translateY(-2px); } .table { width: 100%; border-collapse: collapse; } .table th, .table td { border: 1px solid #ddd; padding: 8px; text-align: center; } .btn { padding: 5px 10px; margin: 2px; border: none; border-radius: 5px; cursor: pointer; background-color: #007bff; color: white; } .btn:hover { background-color: #0056b3; } .active-row { background-color: #d9edf7; } .dt-summary { font-size: 20px; font-weight: bold; } .filters { margin: 10px 0; display: flexflex-wrap: wrap; gap: 10px; align-items: center; } .filters input[type="text"], .filters input[type="date"], .filters select { padding: 5px 8px; font-size: 14px; border-radius: 5px; border: 1px solid #ccc; } .dt-summary table { width: 100%; border-collapse: collapse; text-align: center; background-color: white; } .dt-summary th, .dt-summary td { border: 1px solid #ccc; padding: 8px 12px; } .dt-summary th { background-color: #f0f0f0; color: #222; } .dt-summary tr:nth-child(even) { background-color: #f5f5f5; } .export-btn { margin-top: 10px; padding: 8px 14px; background-color: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; } .export-btn:hover { background-color: #0056b3; } .blink-red { background-color: red; color: white; border: none; padding: 10px 20px; border-radius: 5px; } @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } } .emergency-stop { background-color: red; color: white; font-weight: bold; font-size: 18px; padding: 8px 16px; border-radius: 8px; display: inline-block; animation: blink 1s infinite; text-align: center; box-shadow: 0 0 10px rgba(255, 0, 0, 0.6); } @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } } .disabled-btn { background-color: #6c757d !important; border-color: #6c757d !important; color: white !important; cursor: not-allowed; } .progress-bar { height: 24px; color: white; text-align: center; border-radius: 4px; } .header { margin-top: 10px; padding: 5px; border-radius: 4px; font-family: sans-serif; font-size: 12px; } .running { color: #2e7d32; border: 1px solid #a5d6a7; background: #e8f5e9; } .stopped { color: #c62828; border: 1px solid #ef9a9a; background: #ffebee; } </style> <template> <div class="fullscreen-dashboard"> <div class="header-container"> <div class="time-info"> <div id="date" class="date-display">{{ dateDisplay }}</div> <div id="time" class="time-display">{{ timeDisplay }}</div> </div> <div class="fault-info" v-if="selected"> <div v-if="selected.EmergencyStop === 'TRIGGERED'" class="emergency-stop">🚨 EMERGENCY STOP</div> <div class="emergency-stop" v-if="selected.Faults && selected.Faults.length > 0">⚠️ ALARM</div> <div class="emergency-stop" v-if="selected && selected.maintenance && selected.maintenance.length > 0">🔧 MAINTENANCE</div> </div> <div class="search-container" v-if="selected"> <div class="action-buttons"><button class="action-button home-button" @click="goBack()">Home</button> </div> </div> </div> <div class="metrics-grid" v-if="selected" style="display: flex; flex-wrap: wrap; gap: 1rem;"> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 4em; line-height: 1; min-width: 50px; text-align: center;">🏭</div> <div> <div class="metric-title" style="font-weight: 600;">Machine Status</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.Availability1 }}</div> </div> </div> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 4em; line-height: 1; min-width: 50px; text-align: center;">▶️</div> <div> <div class="metric-title" style="font-weight: 600;">Execution Status</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.Execution }}</div> </div> </div> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 4em; line-height: 1; min-width: 50px; text-align: center;">🖥️</div> <div> <div class="metric-title" style="font-weight: 600;">Current Program</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.Program }}</div> <br> <div class="metric-title" style="font-weight: 600;">Line Number</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.Line }}</div> </div> </div> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 4em; line-height: 1; min-width: 50px; text-align: center;">🎛️</div> <div> <div class="metric-title" style="font-weight: 600;">Current Control Mode</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.ControllerMode }}</div> </div> </div> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 2em; line-height: 1; min-width: 50px; text-align: center;">↕️ ↔️ ↗️</div> <div> <div class="metric-title" style="font-weight: 600;">Absolute Positions</div> <div class="metric-value" style="font-size: 1.2em;">X: {{ selected.Linear_X1_Position }} <br> Y: {{ selected.Linear_Y1_Position }} <br> Z: {{ selected.Linear_Z1_Position }} </div> </div> </div> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 4em; line-height: 1; min-width: 50px; text-align: center;">🕒</div> <div> <div class="metric-title" style="font-weight: 600;">Current Runtime</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.OperatingTimeFormatted }}</div> </div> </div> <div class="metric-card" style="flex: 1 1 200px; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; box-shadow: 1px 1px 4px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 0.75rem;"> <div style="font-size: 4em; line-height: 1; min-width: 50px; text-align: center;">↻</div> <div> <div class="metric-title" style="font-weight: 600;">Current Cycle Time</div> <div class="metric-value" style="font-size: 1.2em;">{{ selected.CycleTimeFormatted }}</div> </div> </div> </div> <div class="production-container"> <div class="production-section" v-if="selected && selected.Faults && selected.Faults.length > 0"> <h3 style="color: #d32f2f; display: flex; align-items: center;"> <span style="font-size: 2.2rem; margin-right: 12px; animation: pulse 1.5s infinite;">🚨</span> Alarm Message: </h3> <div style="max-height: 300px; overflow-y: auto;"> <table class="table"> <thead> <tr> <th>No.</th> <th>Component</th> <th>Type</th> <th>Code</th> <th>Message</th> </tr> </thead> <tbody> <tr v-for="(fault, index) in pagedFaults" :key="index"> <td>{{ index + 1 + (faultsCurrentPage - 1) * faultsPerPage }}</td> <td>{{ fault.component }}</td> <td>{{ fault.type }}</td> <td>{{ fault.nativeCode }}</td> <td>{{ fault.message }}</td> </tr> </tbody> </table> <div class="pagination-controls" v-if="selected.Faults.length > faultsPerPage" style="margin-top:10px;"> <button class="btn btn-sm btn-primary" :disabled="faultsCurrentPage === 1" @click="setFaultsPage(faultsCurrentPage - 1)">Prev</button> <span> Page {{ faultsCurrentPage }} / {{ ceil(selected.Faults.length / faultsPerPage) }} </span> <button class="btn btn-sm btn-primary" :disabled="faultsCurrentPage === ceil(selected.Faults.length / faultsPerPage)" @click="setFaultsPage(faultsCurrentPage + 1)">Next</button> </div> </div> </div> </div> <div class="main-content"> <div class="production-container"> <div class="production-section" v-if="!selected && machines.length === 0"> <br> <div class="connection-value">Connection Error!</div><br> <div class="connection-value1">Please ensure that the PC is on</div> </div> <template v-if="machines.length > 0 && !selected"> <div class="machine-section" v-for="machine in machines" :key="machine.uuid"> <svg class="cnc-3d" viewBox="0 0 250 180" xmlns="http://www.w3.org/2000/svg" @click="selectMachine(machine)" style="cursor:pointer; width: 100%; height: auto;"> <ellipse cx="120" cy="165" rx="85" ry="10" fill="rgba(0,0,0,0.1)" /> <line x1="185" y1="15" x2="185" y2="30" stroke="black" stroke-width="2" /> <rect x="182" y="10" width="6" height="5" fill="#dc3545" stroke="black" :opacity="machine.Execution === 'STOPPED' ? 1 : 0.3" /> <rect x="182" y="5" width="6" height="5" fill="#ffc107" stroke="black" :opacity="machine.Execution === 'IDLE' ? 1 : 0.3" /> <rect x="182" y="0" width="6" height="5" fill="#28a745" stroke="black" :opacity="machine.Execution === 'ACTIVE' ? 1 : 0.3" /> <path d="M30 40 L170 40 L205 15 L65 15 Z" fill="#e0e0e0" stroke="black" stroke-width="0.5" /> <path d="M170 40 L205 15 L205 105 L170 130 Z" fill="#b0b0b0" stroke="black" stroke-width="0.5" /> <line x1="178" y1="45" x2="195" y2="33" stroke="black" stroke-width="1" /> <line x1="178" y1="52" x2="195" y2="40" stroke="black" stroke-width="1" /> <line x1="178" y1="59" x2="195" y2="47" stroke="black" stroke-width="1" /> <rect x="30" y="40" width="140" height="90" :fill="getExecutionColor(machine.Execution)" stroke="black" stroke-width="0.5" /> <rect x="40" y="55" width="80" height="65" fill="#333" rx="2" /> <rect x="43" y="58" width="74" height="59" fill="#1a1a1a" /> <rect x="43" y="58" width="36" height="59" fill="#88b0d0" opacity="0.2" /> <rect x="81" y="58" width="36" height="59" fill="#88b0d0" opacity="0.2" /> <circle cx="85" cy="90" r="3" fill="white" stroke="black" stroke-width="0.5" /> <circle cx="75" cy="90" r="3" fill="white" stroke="black" stroke-width="0.5" /> <rect x="125" y="45" width="40" height="80" fill="#f5f5f5" stroke="black" rx="1" /> <rect x="128" y="48" width="34" height="25" fill="#111" /> <text x="145" y="65" font-size="4" fill="#00ff00" text-anchor="middle" font-family="monospace">{{ machine.Execution }}</text> <circle cx="135" cy="80" r="3" fill="#4fd615" stroke="black" stroke-width="0.5" /> <circle cx="145" cy="80" r="3" fill="#f0ec0a" stroke="black" stroke-width="0.5" /> <circle cx="155" cy="80" r="3" fill="#b71c1c" stroke="black" stroke-width="0.5" /> <rect x="128" y="88" width="34" height="32" fill="#333" stroke="black" rx="1" /> <rect x="30" y="130" width="140" height="25" fill="#333" stroke="black" /> <path d="M170 130 L205 105 L205 130 L170 155 Z" fill="#222" stroke="black" /> <rect x="45" y="140" width="30" height="8" fill="#111" stroke="black" rx="1" /> </svg> <h3>{{ machine.name1 }}</h3> <div class="header" :class="{ running: machine.Availability1 === 'AVAILABLE', stopped: machine.Availability1 !== 'AVAILABLE' }"> <i class="fa fa-cogs"></i> Machine Status: {{ machine.Availability1 === 'AVAILABLE' ? 'AVAILABLE' : 'UNAVAILABLE' }} </div> </div> </template> <div class="stat-section" v-if="machines.length > 0 && !selected"> <div style="display: flex; align-items: center;"> <div style="flex: 1; text-align: center;"> <svg viewBox="0 0 200 200" width="200" height="200"> <template v-for="slice in pieChartSlices" :key="slice.status"> <circle v-if="slice.isFull" cx="100" cy="100" r="90" :fill="slice.color" /> <path v-else :d="slice.path" :fill="slice.color" stroke="#fff" stroke-width="1" /> </template> </svg> </div> <div style="flex: 2; margin-left: 20px;"> <table class="table"> <thead> <tr> <th>Execution Status</th> <th>Count</th> <th>%</th> </tr> </thead> <tbody> <tr v-for="(stat, status) in executionStats" :key="status" :style="{ backgroundColor: getStatusColor(status), color: '#fff' }"> <td>{{ status }}</td> <td>{{ stat.count }}</td> <td>{{ stat.percentage }}%</td> </tr> </tbody> </table> </div> </div> </div> <div class="production-container"> <div class="production-section" v-if="!selected"> <button class="action-button categorize-button" @click="loadToolLife"> Tool Life Tracker </button> </div> </div> <!-- PRODUCTION SECTION --> <div class="production-container"> <div class="production-section" v-if="selected"> <div v-if="selectedOrders.length"> <h3> <span style="font-size: 2rem; vertical-align: middle; margin-right: 10px;">📋</span> Available Manufacturing Orders: </h3> <div style="max-height: 300px; overflow-y: auto;"> <table class="table table-bordered table-striped"> <thead> <tr> <th>No</th> <th>Order Name</th> <th>MO Status</th> <th>Product</th> <th>Qty</th> <th>Work Order</th> <th>Work Order Status</th> <th>Expected Duration</th> <th>Action</th> </tr> </thead> <tbody> <template v-for="(item, index) in pagedOrders" :key="(item.order && item.order.id || index) + '-' + (item.workorder && item.workorder.id || index)"> <tr v-if="!isStarted(item)"> <td>{{ index + 1 + (currentPage - 1) * itemsPerPage }}</td> <td>{{ item.order.name }}</td> <td>{{ item.order.state }}</td> <td>{{ item.order.product_id[1] }}</td> <td>{{ item.order.product_qty }}</td> <td>{{ item.workorder.name }}</td> <td>{{ item.workorder.state }}</td> <td>{{ item.workorder.duration_expected }} mins</td> <td> <button class="btn btn-sm" @click="startProduction(item)" :disabled="selectedProductionOrder" :class="selectedProductionOrder ? 'btn-secondary disabled-btn' : 'btn-success'"> {{ selectedProductionOrder ? 'Running...' : 'Start' }} </button> </td> </tr> </template> </tbody> </table> </div> <div class="pagination-controls" v-if="selectedOrders.length > itemsPerPage" style="margin-top:10px;"> <button class="btn btn-sm btn-primary" :disabled="currentPage === 1" @click="setPage(currentPage - 1)">Prev</button> <span> Page {{ currentPage }} / {{ ceil(selectedOrders.length / itemsPerPage) }} </span> <button class="btn btn-sm btn-primary" :disabled="currentPage === ceil(selectedOrders.length / itemsPerPage)" @click="setPage(currentPage + 1)">Next</button> </div> </div> <div v-if="!selectedOrders.length"> <p>No Manufacturing Orders found for this machine.</p> </div> </div> <div class="stats-section" v-if="selectedProductionOrder && selected"> <h3> Current Work Order:</h3> <table class="table"> <tr> <th>MO Reference</th> <td>{{ selectedProductionOrder.order.name }}</td> </tr> <tr> <th>Product</th> <td>{{ selectedProductionOrder.order.product_id[1] }}</td> </tr> <tr> <th>Planned Qty</th> <td>{{ selectedProductionOrder.order.product_qty }}</td> </tr> <tr> <th>Actual Qty</th> <td>{{ selected.PartCount }}</td> </tr> </table> <br> <div class="mt-2" style="text-align:center;"> <div class="btn-group"> <button class="btn btn-warning" v-if="!selectedProductionOrder.paused" @click="pauseProduction()">Pause</button> <button class="btn btn-primary" v-if="selectedProductionOrder.paused" @click="continueProduction()">Continue</button> <button class="btn btn-danger" @click="doneProduction()">Done</button> </div> </div> </div> </div> <!-- TOOL LIFE SECTION --> <div class="production-container" v-if="selected"> <div class="production-section"> <div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;"> <h3 style="margin:0; display:flex; align-items:center;"> <span style="font-size: 1.8rem; margin-right: 8px;">🛠️</span> Tool Life <small style="font-weight:400; color:#777; margin-left:8px; font-size:0.6em;">— {{ selected.name1 }}</small> </h3> <button class="btn btn-sm btn-success" @click="openToolForm()" v-if="!showToolForm">+ Add Tool</button> </div> <!-- CREATE TOOL FORM --> <div v-if="showToolForm" class="tool-form" style="margin-top:12px; padding:14px; border:1px solid #ddd; border-radius:8px; background:#fafafa;"> <div style="display:flex; flex-wrap:wrap; gap:12px;"> <div style="flex:1 1 130px;"> <label style="display:block; font-weight:600; margin-bottom:4px; font-size:0.9em;">Tool Number</label> <select class="form-control" v-model.number="newTool.toolNumber" style="width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;"> <option value="" disabled>Select</option> <option v-for="n in 12" :key="n" :value="n">{{ n }}</option> </select> </div> <div style="flex:1 1 180px;"> <label style="display:block; font-weight:600; margin-bottom:4px; font-size:0.9em;">Tool Name</label> <input type="text" class="form-control" v-model="newTool.name" placeholder="e.g. Finish Boring Bar" style="width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;"> </div> <div style="flex:1 1 160px;"> <label style="display:block; font-weight:600; margin-bottom:4px; font-size:0.9em;">Type</label> <select class="form-control" v-model="newTool.type" style="width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;"> <option value="" disabled>Select</option> <option v-for="t in toolTypes" :key="t" :value="t">{{ t }}</option> </select> </div> <div style="flex:1 1 160px;"> <label style="display:block; font-weight:600; margin-bottom:4px; font-size:0.9em;">Material</label> <select class="form-control" v-model="newTool.material" style="width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;"> <option value="" disabled>Select</option> <option v-for="mat in toolMaterials" :key="mat" :value="mat">{{ mat }}</option> </select> </div> <div style="flex:1 1 160px;"> <label style="display:block; font-weight:600; margin-bottom:4px; font-size:0.9em;">Machine</label> <input type="text" class="form-control" :value="selected.name1" disabled style="width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px; background:#eee; color:#555;"> </div> <div style="flex:1 1 160px;"> <label style="display:block; font-weight:600; margin-bottom:4px; font-size:0.9em;">Expected Rated Life (mins)</label> <input type="number" min="0" class="form-control" v-model.number="newTool.expectedLife" placeholder="e.g. 500" style="width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;"> </div> </div> <div style="margin-top:14px; text-align:right;"> <button class="btn btn-secondary" @click="cancelToolForm()">Cancel</button> <button class="btn btn-primary" @click="saveTool()">Save</button> </div> </div> <div style="max-height: 300px; overflow-y: auto; margin-top:12px;" v-if="selectedToolList.length > 0"> <table class="table table-bordered table-striped"> <thead> <tr> <th>No.</th> <th>Tool No.</th> <th>Tool Name</th> <th>Type</th> <th>Material</th> <th>Expected Life (mins)</th> <!-- <th>Used (mins)</th> <th>Remaining (mins)</th> --> <th>Wear %</th> <th>Status</th> <th>Start Time</th> <th>Last Updated</th> <th>Action</th> </tr> </thead> <tbody> <tr v-for="(tool, index) in pagedTools" :key="tool.id"> <td>{{ index + 1 + (toolsCurrentPage - 1) * toolsPerPage }}</td> <td>{{ tool.toolNumber }}</td> <td>{{ tool.name }}</td> <td>{{ tool.type }}</td> <td>{{ tool.material }}</td> <td>{{ tool.expectedLife }}</td> <!-- <td>{{ tool.usedMins }}</td> <td>{{ tool.remainingMins }}</td> --> <td> <div style="display:flex; flex-direction:column; align-items:center; gap:4px;"> <span :style="{ fontWeight: 600, fontSize: '0.85em', color: tool.statusColor }"> {{ tool.percentage }}% </span> <div style="width:100px; height:8px; background:#e5e7eb; border-radius:6px; overflow:hidden;"> <div :style="{ height: '100%', width: tool.percentage + '%', background: tool.statusColor, borderRadius: '6px', transition: 'width 0.3s ease' }"></div> </div> </div> </td> <td> <span :style="{ padding: '2px 10px', borderRadius: '12px', color: '#fff', fontWeight: 600, fontSize: '0.85em', backgroundColor: tool.statusColor }"> {{ tool.status }} </span> </td> <td style="font-size:0.82em; white-space:nowrap; color:#555;"> {{ tool.startTime ? new Date(tool.startTime).toLocaleString() : '—' }} </td> <td style="font-size:0.82em; white-space:nowrap; color:#555;"> {{ tool.lastUpdatedTime ? new Date(tool.lastUpdatedTime).toLocaleString() : '—' }} </td> <td><button class="btn btn-sm btn-danger" @click="deleteTool(tool)">Delete</button></td> </tr> </tbody> </table> <div class="pagination-controls" v-if="selectedToolList.length > toolsPerPage" style="margin-top:10px;"> <button class="btn btn-sm btn-primary" :disabled="toolsCurrentPage === 1" @click="setToolsPage(toolsCurrentPage - 1)">Prev</button> <span> Page {{ toolsCurrentPage }} / {{ ceil(selectedToolList.length / toolsPerPage) }} </span> <button class="btn btn-sm btn-primary" :disabled="toolsCurrentPage === ceil(selectedToolList.length / toolsPerPage)" @click="setToolsPage(toolsCurrentPage + 1)">Next</button> </div> </div> <p v-if="selectedToolList.length === 0 && !showToolForm">No tools added yet for {{ selected.name1 }}.</p> </div> </div> <!-- MAINTENANCE SECTION --> <div class="production-container" v-if="selected && selected.maintenance && selected.maintenance.length > 0"> <div class="production-section"> <div style="max-height: 300px; overflow-y: auto;"> <h3> <span style="font-size: 1.8rem; vertical-align: middle; margin-right: 8px;">⚙️</span> Maintenance Requests: </h3> <table class="table table-bordered table-striped"> <thead> <tr> <th>No</th> <th>Maintenance</th> <th>Type</th> <th>Stage</th> <th>Schedule Date</th> <th>Remaining Days</th> </tr> </thead> <tbody> <tr v-for="(m, index) in pagedMaintenance" :key="index"> <td>{{ index + 1 + (maintenanceCurrentPage - 1) * maintenanceItemsPerPage }}</td> <td>{{ m.name }}</td> <td>{{ m.maintenance_type }}</td> <td> <div class="progress-bar" :style="{ backgroundColor: m.stage === 'New Request' ? 'grey' : m.stage === 'In Progress' ? 'orange' : 'gray' }"> {{ m.stage }} </div> </td> <td>{{ m.schedule_date.split(' ')[0] }}</td> <td> <div class="progress-bar" :style="{ width: m.remaining_days <= 0 ? '100%' : m.remaining_days <= 3 ? '80%' : m.remaining_days <= 10 ? '50%' : '20%', backgroundColor: m.remaining_days <= 3 ? 'red' : m.remaining_days <= 7 ? 'orange' : 'green' }"> {{ m.remaining_days }} days </div> </td> </tr> </tbody> </table> </div> <div class="pagination-controls" v-if="selected.maintenance.length > maintenanceItemsPerPage" style="margin-top:10px;"> <button class="btn btn-sm btn-primary" :disabled="maintenanceCurrentPage === 1" @click="setMaintenancePage(maintenanceCurrentPage - 1)">Prev</button> <span> Page {{ maintenanceCurrentPage }} / {{ ceil(selected.maintenance.length / maintenanceItemsPerPage) }} </span> <button class="btn btn-sm btn-primary" :disabled="maintenanceCurrentPage === ceil(selected.maintenance.length / maintenanceItemsPerPage)" @click="setMaintenancePage(maintenanceCurrentPage + 1)">Next</button> </div> </div> </div> </div> </div> </div> </template> <script> export default { data() { return { dateDisplay: '-- -- ----', timeDisplay: '--:--:--', clockInterval: null, machines: [], selected: null, executionStats: null, productionData: [], maintenanceData: [], selectedOrders: [], selectedProductionOrder: null, startedOrders: {}, calcInterval: null, currentPage: 1, itemsPerPage: 2, pagedOrders: [], faultsCurrentPage: 1, faultsPerPage: 2, pagedFaults: [], maintenanceCurrentPage: 1, maintenanceItemsPerPage: 2, pagedMaintenance: [], toolList: [], showToolForm: false, newTool: { toolNumber: '', name: '', type: '', material: '', expectedLife: '' }, toolTypes: ['Boring Bar', 'Countersink', 'Drill', 'End Mill', 'Face Mill', 'Reamer', 'Tap', 'Insert', 'Other'], toolMaterials: ['Carbide', 'HSS', 'Ceramic', 'Diamond (PCD)', 'Cermet', 'Other'], toolsCurrentPage: 1, toolsPerPage: 5, pagedTools: [], toolWearInterval: null, exampleMode: false }; }, computed: { readyToRestore() { return this.productionData.length > 0 && this.maintenanceData.length > 0 && this.selected && !this.selectedOrders.length; }, pieChartSlices() { if (!this.executionStats) return []; const entries = Object.entries(this.executionStats); const total = entries.reduce((sum, [, stat]) => sum + (stat.percentage || 0), 0) || 100; let startAngle = -90; return entries.map(([status, stat]) => { const sweep = (stat.percentage / total) * 360; const endAngle = startAngle + sweep; const slice = { status, stat, color: this.getStatusColor(status), isFull: sweep >= 359.9, path: this.describeArc(100, 100, 90, startAngle, endAngle) }; startAngle = endAngle; return slice; }); }, selectedToolList() { if (!this.selected) return []; return this.toolList.filter(t => t.machine === this.selected.name1); } }, watch: { msg(newVal) { if (!newVal) return; if (newVal.topic === 'broadcast_active_order') { console.log('Received broadcast_active_order:', newVal.payload); if (this.calcInterval) { clearInterval(this.calcInterval); this.calcInterval = null; } if (newVal.payload) { this.selectedProductionOrder = newVal.payload; localStorage.setItem('activeProductionOrder', JSON.stringify({ machine_uuid: this.selected?.uuid || null, item: newVal.payload })); if (!newVal.payload.paused) { this.startInterval(newVal.payload); } } else { console.log('Clearing active production order (broadcast)'); this.selectedProductionOrder = null; localStorage.removeItem('activeProductionOrder'); } return; } if (!newVal.payload) return; if (newVal.payload.machines) { this.machines = newVal.payload.machines; if (this.selected && this.selected.uuid) { const updated = this.machines.find(m => m.uuid === this.selected.uuid); if (updated) { this.selected = updated; this.refreshSelectedPaging(); } } } if (newVal.payload.executionStats) { this.executionStats = newVal.payload.executionStats; } if (newVal.payload.maintenanceData && this.machines) { this.maintenanceData = newVal.payload.maintenanceData; this.machines.forEach(machine => { machine.maintenance = this.maintenanceData.filter(req => req.workcenter && req.workcenter.name === machine.name1 && req.workcenter.code === machine.uuid ); }); if (this.selected) { const updated = this.machines.find(m => m.uuid === this.selected.uuid); if (updated) { this.selected = updated; this.refreshSelectedPaging(); } } } if (newVal.payload.orders && this.selected && this.selected.uuid) { this.selectedOrders = newVal.payload.orders.filter( o => o.machine_id === this.selected.uuid ); localStorage.setItem('selectedOrders', JSON.stringify(this.selectedOrders)); this.updatePagedOrders(); } if (newVal.payload.productionData && typeof newVal.payload.productionData === 'object') { this.productionData = newVal.payload.productionData; // this used to only get filtered into selectedOrders inside // selectMachine() — i.e. once, at click time — so the // Manufacturing Orders table went stale until a page refresh // re-ran selectMachine(). Re-deriving it here on every tick // keeps it live, same as machines/maintenance above. this.deriveOrdersForSelected(); } if (newVal.payload.maintenanceData && typeof newVal.payload.maintenanceData === 'object') { this.maintenanceData = newVal.payload.maintenanceData; } }, readyToRestore(ready) { if (ready) { console.log('Restoring machine and orders after refresh...'); this.selectMachine(this.selected); } }, selected() { this.refreshSelectedPaging(); }, selectedOrders: { deep: true, handler() { this.currentPage = 1; this.updatePagedOrders(); } } }, methods: { ceil(value) { return Math.ceil(value); }, refreshSelectedPaging() { this.faultsCurrentPage = 1; this.updatePagedFaults(); this.maintenanceCurrentPage = 1; this.updatePagedMaintenance(); this.toolsCurrentPage = 1; this.updatePagedTools(); }, updateTime() { const now = new Date(); const malaysiaTime = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); const hours = String(malaysiaTime.getHours()).padStart(2, '0'); const minutes = String(malaysiaTime.getMinutes()).padStart(2, '0'); const seconds = String(malaysiaTime.getSeconds()).padStart(2, '0'); const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const day = days[malaysiaTime.getDay()]; const date = malaysiaTime.getDate(); const month = malaysiaTime.toLocaleString('default', { month: 'long' }); const year = malaysiaTime.getFullYear(); this.timeDisplay = `${hours}:${minutes}:${seconds}`; this.dateDisplay = `${day}, ${date} ${month} ${year}`; }, getExecutionColor(status) { const colors = { ACTIVE: '#28a745', UNAVAILABLE: '#6c757d', READY: '#007bff', STOPPED: '#dc3545', IDLE: '#ffc107' }; return colors[status] || '#999'; }, getStatusColor(status) { return this.getExecutionColor(status); }, polarToCartesian(cx, cy, r, angleDeg) { const angleRad = (angleDeg * Math.PI) / 180; return { x: cx + r * Math.cos(angleRad), y: cy + r * Math.sin(angleRad) }; }, describeArc(cx, cy, r, startAngle, endAngle) { const startPt = this.polarToCartesian(cx, cy, r, startAngle); const endPt = this.polarToCartesian(cx, cy, r, endAngle); const sweep = endAngle - startAngle; const largeArcFlag = sweep > 180 ? 1 : 0; return ['M', cx, cy, 'L', startPt.x, startPt.y, 'A', r, r, 0, largeArcFlag, 1, endPt.x, endPt.y, 'Z'].join(' '); }, requestActiveOrder() { console.log('Requesting current active order from flow...'); this.send({ topic: 'request_active_order' }); }, sendStartPayload(item) { const now = Date.now(); this.send({ topic: 'start_workorder', payload: { id: item.workorder.id, name: item.workorder.name, order_id: item.order.id, order_name: item.order.name, product_name: item.order.product_id[1], product_qty: item.order.product_qty, planned_time: item.workorder.duration_expected || 0, workorder_state: item.workorder.state, workcenter_name: item.workorder.workcenter_id[1], workcenter_code: item.workorder.code, date_start: item.order.date_start, timestamp: now } }); }, startInterval(item) { if (this.calcInterval) clearInterval(this.calcInterval); this.calcInterval = setInterval(() => { if (!item || item.paused) return; const now = Date.now(); this.send({ topic: 'workorder_raw', payload: { workorder_id: item.workorder.id, workorder_name: item.workorder.name, order_id: item.order.id, order_name: item.order.name, product_name: item.order.product_id[1], product_qty: item.order.product_qty, planned_time: item.workorder.duration_expected || 0, workorder_state: item.workorder.state, workcenter_name: item.workorder.workcenter_id[1], workcenter_code: item.workorder.code, date_start: item.order.date_start, timestamp: now } }); }, 1000); }, selectMachine(machine) { this.selected = machine; this.refreshSelectedPaging(); localStorage.setItem('selectedMachine', JSON.stringify(machine)); if (this.selectedOrders && this.selectedOrders.length > 0) { localStorage.setItem('selectedOrders', JSON.stringify(this.selectedOrders)); } // restore whatever's already running on this machine (if anything), // then let deriveOrdersForSelected() build the pending list around it this.selectedProductionOrder = this.startedOrders[machine.uuid] || null; this.deriveOrdersForSelected(); }, startProduction(item) { this.selectedProductionOrder = item; item.paused = false; localStorage.setItem('activeProductionOrder', JSON.stringify({ machine_uuid: this.selected?.uuid || null, item })); if (this.selected && this.selected.uuid) { this.startedOrders[this.selected.uuid] = item; } this.sendStartPayload(item); this.startInterval(item); this.send({ topic: 'sync_active_order', payload: item }); }, pauseProduction() { if (this.selectedProductionOrder) { this.selectedProductionOrder.paused = true; if (this.calcInterval) { clearInterval(this.calcInterval); this.calcInterval = null; } const saved = localStorage.getItem('activeProductionOrder'); if (saved) { const parsed = JSON.parse(saved); parsed.item.paused = true; localStorage.setItem('activeProductionOrder', JSON.stringify(parsed)); } this.send({ topic: 'pause_workorder', payload: { id: this.selectedProductionOrder.workorder.id, name: this.selectedProductionOrder.workorder.name } }); } this.send({ topic: 'sync_active_order', payload: this.selectedProductionOrder }); }, continueProduction() { if (this.selectedProductionOrder) { this.selectedProductionOrder.paused = false; const saved = localStorage.getItem('activeProductionOrder'); if (saved) { const parsed = JSON.parse(saved); parsed.item.paused = false; localStorage.setItem('activeProductionOrder', JSON.stringify(parsed)); } this.send({ topic: 'continue_workorder', payload: { id: this.selectedProductionOrder.workorder.id, name: this.selectedProductionOrder.workorder.name } }); this.startInterval(this.selectedProductionOrder); } this.send({ topic: 'sync_active_order', payload: this.selectedProductionOrder }); }, doneProduction() { if (this.selectedProductionOrder) { if (this.calcInterval) { clearInterval(this.calcInterval); this.calcInterval = null; } this.send({ topic: 'finish_workorder', payload: { id: this.selectedProductionOrder.workorder.id, name: this.selectedProductionOrder.workorder.name } }); this.send({ topic: 'sync_active_order', payload: null }); if (this.selected && this.selected.uuid) { delete this.startedOrders[this.selected.uuid]; } localStorage.removeItem('activeProductionOrder'); this.selectedProductionOrder = null; } }, isStarted(item) { return this.selectedProductionOrder === item; }, goBack() { this.selected = null; this.selectedOrders = []; this.selectedProductionOrder = null; this.showToolForm = false; this.resetToolForm(); localStorage.removeItem('selectedMachine'); localStorage.removeItem('selectedOrders'); }, updatePagedOrders() { const start = (this.currentPage - 1) * this.itemsPerPage; const end = start + this.itemsPerPage; this.pagedOrders = this.selectedOrders.slice(start, end); }, // Re-filters productionData down to the pending work orders for // whichever machine is selected right now. Pulled out of // selectMachine() so it can ALSO be called every time a fresh // productionData payload arrives (see the msg watcher) — previously // this only ran once, at the moment you clicked a machine card, so // the Manufacturing Orders table went stale until you refreshed the // page (which re-ran selectMachine() via readyToRestore). // Deliberately does NOT touch selectedProductionOrder — the // currently-running order is only ever changed by explicit actions // (startProduction/doneProduction) or a broadcast_active_order sync, // never by this background refresh. deriveOrdersForSelected() { if (!this.selected || !Array.isArray(this.productionData)) { this.selectedOrders = []; this.currentPage = 1; this.updatePagedOrders(); return; } const machine = this.selected; const runningId = this.selectedProductionOrder ? this.selectedProductionOrder.workorder.id : null; const orders = []; this.productionData.forEach(order => { (order.workorders || []).forEach(wo => { if (wo.workcenter_id && wo.workcenter_id[1] === machine.name1 && wo.code === machine.uuid) { if (runningId !== null && wo.id === runningId) return; // already shown in the "Current Work Order" panel orders.push({ order, workorder: wo }); } }); }); this.selectedOrders = orders; localStorage.setItem('selectedOrders', JSON.stringify(this.selectedOrders)); if (this.currentPage > Math.ceil(orders.length / this.itemsPerPage)) { this.currentPage = 1; } this.updatePagedOrders(); }, setPage(page) { if (page >= 1 && page <= Math.ceil(this.selectedOrders.length / this.itemsPerPage)) { this.currentPage = page; this.updatePagedOrders(); } }, updatePagedFaults() { if (!this.selected || !this.selected.Faults) return; const start = (this.faultsCurrentPage - 1) * this.faultsPerPage; const end = start + this.faultsPerPage; this.pagedFaults = this.selected.Faults.slice(start, end); }, setFaultsPage(page) { if (page >= 1 && page <= Math.ceil(this.selected.Faults.length / this.faultsPerPage)) { this.faultsCurrentPage = page; this.updatePagedFaults(); } }, updatePagedMaintenance() { if (!this.selected || !this.selected.maintenance) { this.pagedMaintenance = []; return; } const start = (this.maintenanceCurrentPage - 1) * this.maintenanceItemsPerPage; const end = start + this.maintenanceItemsPerPage; this.pagedMaintenance = this.selected.maintenance.slice(start, end); }, setMaintenancePage(page) { if (!this.selected || !this.selected.maintenance) return; const maxPage = Math.ceil(this.selected.maintenance.length / this.maintenanceItemsPerPage); if (page >= 1 && page <= maxPage) { this.maintenanceCurrentPage = page; this.updatePagedMaintenance(); } }, publishToolLife() { const payload = this.toolList.map(tool => ({ machine: tool.machine, toolNumber: tool.toolNumber, name: tool.name, type: tool.type, material: tool.material, expectedLife: tool.expectedLife, usedMins: tool.usedMins ?? 0, remainingMins: tool.remainingMins ?? 0, percentage: tool.percentage ?? 0, status: tool.status ?? 'OK', startTime: tool.startTime ?? null, lastUpdatedTime: tool.lastUpdatedTime ?? null })); this.send({ topic: 'toollife', payload }); }, openToolForm() { this.showToolForm = true; }, cancelToolForm() { this.showToolForm = false; this.resetToolForm(); }, resetToolForm() { this.newTool = { toolNumber: '', name: '', type: '', material: '', expectedLife: '' }; }, saveTool() { if (!this.selected) return; if (!this.newTool.toolNumber || !this.newTool.name || !this.newTool.type || !this.newTool.material || !this.newTool.expectedLife) { alert('Please fill in all fields before saving.'); return; } const tool = { id: Date.now(), toolNumber: this.newTool.toolNumber, name: this.newTool.name, type: this.newTool.type, material: this.newTool.material, machine: this.selected.name1, expectedLife: this.newTool.expectedLife, usedSeconds: 0, startTime: null, lastUpdatedTime: null }; this.recalcToolMetrics(tool); this.toolList.push(tool); localStorage.setItem('toolList', JSON.stringify(this.toolList)); this.publishToolLife(); this.resetToolForm(); this.showToolForm = false; this.toolsCurrentPage = Math.ceil(this.selectedToolList.length / this.toolsPerPage) || 1; this.updatePagedTools(); }, deleteTool(tool) { this.toolList = this.toolList.filter(t => t.id !== tool.id); localStorage.setItem('toolList', JSON.stringify(this.toolList)); this.publishToolLife(); const maxPage = Math.ceil(this.selectedToolList.length / this.toolsPerPage) || 1; if (this.toolsCurrentPage > maxPage) this.toolsCurrentPage = maxPage; this.updatePagedTools(); }, updatePagedTools() { const start = (this.toolsCurrentPage - 1) * this.toolsPerPage; const end = start + this.toolsPerPage; this.pagedTools = this.selectedToolList.slice(start, end); }, setToolsPage(page) { const maxPage = Math.ceil(this.selectedToolList.length / this.toolsPerPage) || 1; if (page >= 1 && page <= maxPage) { this.toolsCurrentPage = page; this.updatePagedTools(); } }, recalcToolMetrics(tool) { const usedMins = Math.round(((tool.usedSeconds || 0) / 60) * 10) / 10; const expected = Number(tool.expectedLife) || 0; let percentage = expected > 0 ? (usedMins / expected) * 100 : 0; percentage = Math.min(100, Math.round(percentage)); const remainingMins = Math.max(0, Math.round((expected - usedMins) * 10) / 10); let status; let statusColor; if (percentage > 90) { status = 'Replace'; statusColor = '#d32f2f'; // red } else if (percentage > 80) { status = 'Critical'; statusColor = '#fd7e14'; // orange } else if (percentage > 70) { status = 'Warning'; statusColor = '#ffc107'; // amber } else { status = 'OK'; statusColor = '#28a745'; // green } tool.usedMins = usedMins; tool.remainingMins = remainingMins; tool.percentage = percentage; tool.status = status; tool.statusColor = statusColor; }, evaluateToolWear() { if (!this.toolList.length) return; const machineList = this.machines.length > 0 ? this.machines : (this.selected ? [this.selected] : []); if (!machineList.length) return; let anyChanged = false; machineList.forEach(machine => { const isActive = machine.Execution === 'ACTIVE'; const isAuto = machine.ControllerMode === 'AUTOMATIC'; const spcc = Number(machine.SPCC) === 1; const spcw = Number(machine.SPCW) === 1; if (!isActive || !isAuto || !(spcc || spcw)) return; const activeTcode = Number(machine['Device_FANUC_CNC_x:Tcode2']); if (!activeTcode) return; const candidates = this.toolList.filter(t => t.machine === machine.name1 && Number(t.toolNumber) === activeTcode ); if (!candidates.length) return; const activeCandidates = candidates.filter(t => (Number(t.percentage) || 0) < 100 && t.status !== 'Replace' ); if (!activeCandidates.length) return; const activeTool = activeCandidates.reduce((latest, t) => t.id > latest.id ? t : latest ); activeTool.usedSeconds = (activeTool.usedSeconds || 0) + 1; const now = new Date().toISOString(); if (!activeTool.startTime) { activeTool.startTime = now; } activeTool.lastUpdatedTime = now; this.recalcToolMetrics(activeTool); anyChanged = true; }); if (anyChanged) { localStorage.setItem('toolList', JSON.stringify(this.toolList)); this.updatePagedTools(); this.publishToolLife(); } }, loadToolLife() { console.log("ToolLife button clicked"); this.toolMode = true this.send({ payload: { tab: 'ToolLife' } }) } }, mounted() { this.updateTime(); this.clockInterval = setInterval(this.updateTime, 1000); const savedOrder = localStorage.getItem('activeProductionOrder'); if (savedOrder) { try { const parsed = JSON.parse(savedOrder); this.selectedProductionOrder = parsed.item; if (parsed.item.paused) { this.selectedProductionOrder.paused = true; } if (parsed.machine_uuid) { this.startedOrders[parsed.machine_uuid] = parsed.item; } if (!this.selectedProductionOrder.paused) { this.startInterval(this.selectedProductionOrder); } } catch (e) { console.error('Failed to restore saved work order:', e); localStorage.removeItem('activeProductionOrder'); } } const savedMachine = localStorage.getItem('selectedMachine'); if (savedMachine) { try { this.selected = JSON.parse(savedMachine); this.refreshSelectedPaging(); } catch (e) { console.error('Failed to restore selected machine:', e); localStorage.removeItem('selectedMachine'); } } const savedOrders = localStorage.getItem('selectedOrders'); if (savedOrders) { try { this.selectedOrders = JSON.parse(savedOrders); this.updatePagedOrders(); } catch (e) { console.error('Failed to restore selected orders:', e); localStorage.removeItem('selectedOrders'); } } const savedTools = localStorage.getItem('toolList'); if (savedTools) { try { this.toolList = JSON.parse(savedTools); this.toolList.forEach(t => this.recalcToolMetrics(t)); this.updatePagedTools(); this.publishToolLife(); } catch (e) { console.error('Failed to restore tool list:', e); localStorage.removeItem('toolList'); } } this.toolWearInterval = setInterval(this.evaluateToolWear, 1000); setTimeout(() => this.requestActiveOrder(), 800); setTimeout(() => this.requestActiveOrder(), 3000); }, unmounted() { if (this.clockInterval) clearInterval(this.clockInterval); if (this.calcInterval) clearInterval(this.calcInterval); if (this.toolWearInterval) clearInterval(this.toolWearInterval); } }; </script>