Files
Virtual-Box/.node-red/.flows.json.backup
2025-12-02 16:27:21 +00:00

2429 lines
291 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[
{
"id": "cac3a4383120cb57",
"type": "tab",
"label": "Flow 1",
"disabled": false,
"info": "",
"env": []
},
{
"id": "6309170c5f36ea14",
"type": "tab",
"label": "Flow 2",
"disabled": false,
"info": "",
"env": []
},
{
"id": "16bb591480852f51",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Start ",
"style": {
"stroke": "#92d04f",
"fill": "#addb7b",
"label": true
},
"nodes": [
"6ad64dedab2042b9",
"0f5ee343ed17976c",
"4e025693949ec4bd",
"b55c91c096a366db",
"33d1f41119e0e262",
"f98ae23b2430c206",
"0d023d87a13bf56f",
"dbc7a5ee041845ed",
"e15d6c1f78b644a2",
"317c9c75095b9499"
],
"x": 1194,
"y": 159,
"w": 762,
"h": 162
},
{
"id": "bdaf9298cd8e306b",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Cavity Settings ",
"style": {
"stroke": "#ff7f7f",
"fill": "#ffbfbf",
"label": true
},
"nodes": [
"e1f2a3b4c5d6e7f8",
"75dbe316f19fd44c"
],
"x": null,
"y": null,
"w": null,
"h": null
},
{
"id": "ec32d0a62eacfb22",
"type": "group",
"z": "cac3a4383120cb57",
"name": "UI/UX",
"style": {
"fill": "#d1d1d1",
"label": true
},
"nodes": [
"1821c4842945ecd8",
"f2a3b4c5d6e7f8a9",
"f3a4b5c6d7e8f9a0",
"f4a5b6c7d8e9f0a1",
"f5a6b7c8d9e0f1a2",
"f1a2b3c4d5e6f7a8",
"a7d58e15929b3d8c",
"cc81a9dbfd443d62",
"06f9769e8b0d5355",
"0a5caf3e23c68e6e",
"010de5af3ced0ae3",
"6f9de736a538d0d1",
"16af50d6fce977a8",
"2f04a72fdeb67f3f",
"8f890f97aa9257c7",
"952cd0a9a4504f2b",
"fcee023b62d44e58"
],
"x": 234,
"y": 179,
"w": 632,
"h": 342
},
{
"id": "b7ab5e0cc02b9508",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Work Orders",
"style": {
"stroke": "#9363b7",
"fill": "#dbcbe7",
"label": true
},
"nodes": [
"9bbd4fade968036d",
"65ddb4cca6787bde",
"596b390d7aaf69fb",
"f6ad294bc02618c9",
"f2bab26e27e2023d",
"0779932734d8201c",
"3772c25d07b07407",
"c2b272494952cd98",
"87d85c86e4773aa5",
"15a6b7b6d8f39fe4",
"64661fe6aa2cb83d",
"578c92e75bf0f266",
"76ce53cf1ae40e9c",
"0d6ec01f421acdef",
"fd32602c52d896e9",
"d39000415ba85495",
"dc9b9a26af05dfa8",
"event_logger_node_id",
"anomaly_detector_node_id",
"anomaly_split_node_id",
"5834d8be57b34837",
"4ed29a43614c50e8",
"361bb7e976470121",
"cycle_kpi_data_merger",
"progress_check_handler_node",
"ab9ff66e69294c7c",
"00b6132848964bd9",
"db_guard_db_guard_cycles",
"e2eda117b5af3874"
],
"x": 1194,
"y": 339,
"w": 1152,
"h": 422
},
{
"id": "72d151da50327bc8",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Anomaly System",
"style": {
"stroke": "#ffC000",
"fill": "#ffdf7f",
"label": true
},
"nodes": [
"anomaly_alert_ui_global",
"anomaly_acknowledge_handler",
"anomaly_mysql_node_id",
"2c97cebc1585f1ac",
"6068cf22f01b287a",
"a976e4f98bc8a253",
"7f5c75b3e90c5d27"
],
"x": 224,
"y": 19,
"w": 922,
"h": 142
},
{
"id": "2d0188d56fd5c86a",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Alerts",
"style": {
"fill": "#bfdbef",
"label": true
},
"nodes": [
"alert_process_function",
"alert_insert_mysql",
"13d0fa52d4b27889"
],
"x": 234,
"y": 819,
"w": 512,
"h": 82
},
{
"id": "7620ddfb5d167d4b",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Graphs",
"style": {
"fill": "#bfbfbf",
"label": true
},
"nodes": [
"graph_mariadb_node",
"8e589c36104b3f0d",
"27056a025a7bce27",
"8fa69225b6ab996f",
"6352d57b1d9746aa"
],
"x": 1194,
"y": 39,
"w": 802,
"h": 82
},
{
"id": "75dbe316f19fd44c",
"type": "group",
"z": "cac3a4383120cb57",
"g": "bdaf9298cd8e306b",
"name": "Cavities Settings",
"style": {
"stroke": "#ffff00",
"fill": "#ffffbf",
"label": true
},
"nodes": [
"e1f2a3b4c5d6e7f8",
"28c173789034639c"
],
"x": null,
"y": null,
"w": null,
"h": null
},
{
"id": "28c173789034639c",
"type": "group",
"z": "cac3a4383120cb57",
"g": "75dbe316f19fd44c",
"name": "Settings",
"style": {
"stroke": "#92d04f",
"fill": "#ffffbf",
"label": true
},
"nodes": [
"eaebd8c719c3d135",
"a1b2c3d4e5f6a7b8",
"c9d8e7f6a5b4c3d2",
"b2c3d4e5f6a7b8c9",
"7311641fd09b4d3a",
"0b5740c4a2b298b7",
"7ae8e8e77eb05158",
"95f2b9ce958d6785",
"010f9764b4dd50a1"
],
"x": 234,
"y": 559,
"w": 762,
"h": 202
},
{
"id": "c567195d86466cd5",
"type": "ui_tab",
"name": "Home",
"icon": "dashboard",
"order": 1,
"disabled": false,
"hidden": false
},
{
"id": "f4c299235c1b719d",
"type": "ui_base",
"theme": {
"name": "theme-custom",
"lightTheme": {
"default": "#0094CE",
"baseColor": "#0094CE",
"baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
"edited": true,
"reset": false
},
"darkTheme": {
"default": "#097479",
"baseColor": "#000000",
"baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
"edited": true,
"reset": false
},
"customTheme": {
"name": "Transparent",
"default": "#4B7930",
"baseColor": "#000000",
"baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
"reset": false
},
"themeState": {
"base-color": {
"default": "#4B7930",
"value": "#000000",
"edited": true
},
"page-titlebar-backgroundColor": {
"value": "#000000",
"edited": false
},
"page-backgroundColor": {
"value": "#111111",
"edited": false
},
"page-sidebar-backgroundColor": {
"value": "#333333",
"edited": false
},
"group-textColor": {
"value": "#262626",
"edited": false
},
"group-borderColor": {
"value": "#000000",
"edited": true
},
"group-backgroundColor": {
"value": "#333333",
"edited": false
},
"widget-textColor": {
"value": "#eeeeee",
"edited": false
},
"widget-backgroundColor": {
"value": "#000000",
"edited": false
},
"widget-borderColor": {
"value": "#333333",
"edited": false
},
"base-font": {
"value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif"
}
},
"angularTheme": {
"primary": "indigo",
"accents": "blue",
"warn": "red",
"background": "grey",
"palette": "light"
}
},
"site": {
"name": "Node-RED Dashboard",
"hideToolbar": "true",
"allowSwipe": "false",
"lockMenu": "false",
"allowTempTheme": "true",
"dateFormat": "DD/MM/YYYY",
"sizes": {
"sx": 48,
"sy": 48,
"gx": 6,
"gy": 6,
"cx": 6,
"cy": 6,
"px": 0,
"py": 0
}
}
},
{
"id": "919b5b8d778e2b6c",
"type": "ui_group",
"name": "Default",
"tab": "c567195d86466cd5",
"order": 1,
"disp": false,
"width": "25",
"collapse": false,
"className": ""
},
{
"id": "d1a1e2f3a4b5c6d7",
"type": "ui_tab",
"name": "Work Orders",
"icon": "list",
"order": 2,
"disabled": false,
"hidden": false
},
{
"id": "a1b2c3d4e5f60718",
"type": "ui_tab",
"name": "Alerts",
"icon": "warning",
"order": 3,
"disabled": false,
"hidden": false
},
{
"id": "b2c3d4e5f6a70182",
"type": "ui_tab",
"name": "Graphs",
"icon": "show_chart",
"order": 4,
"disabled": false,
"hidden": false
},
{
"id": "c3d4e5f6a7b80192",
"type": "ui_tab",
"name": "Help",
"icon": "help",
"order": 5,
"disabled": false,
"hidden": false
},
{
"id": "d4e5f6a7b8c90123",
"type": "ui_tab",
"name": "Settings",
"icon": "settings",
"order": 6,
"disabled": false,
"hidden": false
},
{
"id": "e1f2a3b4c5d6e7f8",
"type": "ui_group",
"g": "75dbe316f19fd44c",
"name": "Work Orders Group",
"tab": "d1a1e2f3a4b5c6d7",
"order": 1,
"disp": false,
"width": 25,
"collapse": false,
"className": ""
},
{
"id": "e2f3a4b5c6d7e8f9",
"type": "ui_group",
"name": "Alerts Group",
"tab": "a1b2c3d4e5f60718",
"order": 1,
"disp": false,
"width": "25",
"collapse": false,
"className": ""
},
{
"id": "e3f4a5b6c7d8e9f0",
"type": "ui_group",
"name": "Graphs Group",
"tab": "b2c3d4e5f6a70182",
"order": 1,
"disp": false,
"width": "25",
"collapse": false,
"className": ""
},
{
"id": "e4f5a6b7c8d9e0f1",
"type": "ui_group",
"name": "Help Group",
"tab": "c3d4e5f6a7b80192",
"order": 1,
"disp": false,
"width": "25",
"collapse": false,
"className": ""
},
{
"id": "e5f6a7b8c9d0e1f2",
"type": "ui_group",
"name": "Settings Group",
"tab": "d4e5f6a7b8c90123",
"order": 1,
"disp": false,
"width": "25",
"collapse": false,
"className": ""
},
{
"id": "00d8ad2b0277f906",
"type": "MySQLdatabase",
"name": "machine_data",
"host": "10.147.20.244",
"port": "3306",
"db": "machine_data",
"tz": "",
"charset": "UTF8"
},
{
"id": "a844720c.608d6",
"type": "MySQLdatabase",
"host": "127.0.0.1",
"port": "3306",
"db": "nodered",
"tz": ""
},
{
"id": "db58ad1a.e37a8",
"type": "ui_tab",
"name": "Test stuff",
"icon": "dashboard"
},
{
"id": "8880d363.148ac",
"type": "ui_group",
"name": "Thermostat demo",
"tab": "db58ad1a.e37a8",
"order": 2,
"disp": true,
"width": "6"
},
{
"id": "1821c4842945ecd8",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "919b5b8d778e2b6c",
"name": "Home Template",
"order": 0,
"width": "25",
"height": "25",
"format": "<style>\n@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');\n\n/* SCALE: align 100% zoom with prior 125% appearance */\n:root {\n #oee md-content,\n #oee .nr-dashboard-template,\n #oee .nr-dashboard-template md-content,\n #oee .nr-dashboard-cardpanel,\n #oee .nr-dashboard-cardpanel md-card,\n #oee .nr-dashboard-cardpanel md-card-content {\n background: transparent !important;\n box-shadow: none !important;\n }\n font-size: 125%;\n --bg-0: #0c1117;\n --bg-1: #131a23;\n --panel: #151e2b;\n --panel-hi: #1a2433;\n --text: #ecf3ff;\n --muted: #96a5b7;\n --accent: #2ea3ff;\n --accent-2: #1f8cf3;\n --good: #24d06f;\n --warn: #ffd100;\n --bad: #ff4d4f;\n --radius: 0.75rem;\n --shadow: 0 0.5rem 1rem rgba(0, 0, 0, .35), inset 0 0.0625rem 0 rgba(255, 255, 255, .05);\n --sidebar-width: 3.75rem;\n --sidebar-gap: clamp(0.75rem, 1.2vh, 1rem);\n --content-max: 100rem;\n --content-pad: clamp(1rem, 1.2vw, 1.4rem);\n --section-gap: clamp(0.75rem, 1vw, 1rem);\n --card-pad: clamp(1rem, 1.1vw, 1.25rem);\n --card-pad-lg: clamp(1.1rem, 1.2vw, 1.35rem);\n --fs-page-title: clamp(1.2rem, 1vw + 0.35rem, 1.7rem);\n --fs-section-title: clamp(0.95rem, 0.9vw + 0.25rem, 1.25rem);\n --fs-label: clamp(0.85rem, 0.7vw + 0.3rem, 1.05rem);\n --fs-label-lg: clamp(0.95rem, 0.8vw + 0.32rem, 1.15rem);\n --fs-kpi: clamp(2.25rem, 2.5vw, 3.25rem);\n --fs-kpi-unit: clamp(1.25rem, 1.4vw, 1.5rem);\n --fs-body: clamp(0.8rem, 0.7vw + 0.28rem, 1rem);\n --progress-height: 0.875rem;\n --start-min-height: 9.375rem;\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml,\nbody,\n#oee {\n width: 100vw;\n height: 100vh;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n overflow-x: hidden;\n background: var(--bg-0);\n color: var(--text);\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n}\n\nbody {\n overflow: hidden;\n}\n\nbody > md-content,\nbody > md-content > md-content,\n.nr-dashboard-template,\n.nr-dashboard-template md-content,\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-content {\n background: transparent !important;\n height: 100%;\n overflow: hidden;\n}\n\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-card,\n.nr-dashboard-cardpanel md-card-content {\n background: transparent !important;\n box-shadow: none !important;\n padding: 0 !important;\n}\n\n/* LAYOUT: lock sidebar + content grid across tabs */\n#oee {\n position: fixed;\n inset: 0;\n display: flex;\n overflow: hidden;\n}\n\n.sidebar {\n width: var(--sidebar-width);\n background: #0b1119;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: space-between;\n padding: clamp(0.625rem, 1.4vh, 0.9rem) clamp(0.5rem, 0.8vw, 0.75rem);\n}\n\n.side-top {\n display: flex;\n flex-direction: column;\n gap: var(--sidebar-gap);\n align-items: center;\n}\n\n.sb-btn {\n width: 2.75rem;\n height: 2.75rem;\n border-radius: 0.75rem;\n background: #0f1721;\n border: 1px solid #19222e;\n display: grid;\n place-items: center;\n color: #8fb3d9;\n cursor: pointer;\n transition: 0.16s box-shadow, 0.16s transform, 0.16s border-color;\n}\n\n.sb-btn.active {\n border-color: #2fd289;\n box-shadow: 0 0 0 0.125rem rgba(36, 208, 111, .25), 0 0.625rem 1.125rem rgba(36, 208, 111, .28);\n color: #2fd289;\n}\n\n.sb-ico {\n font-size: clamp(1.1rem, 1.2vw, 1.25rem);\n line-height: 1;\n}\n\n.sb-foot {\n font-size: clamp(0.6rem, 0.6vw, 0.7rem);\n color: #6c7b8d;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n}\n\n.main {\n flex: 1;\n overflow: auto;\n padding: var(--content-pad);\n}\n\n.container {\n max-width: var(--content-max);\n margin: 0 auto;\n display: flex;\n flex-direction: column;\n gap: var(--section-gap);\n}\n\n.card {\n background: linear-gradient(160deg, var(--panel) 0%, var(--panel-hi) 100%);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n}\n\n.label {\n color: var(--text);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-weight: 700;\n font-size: var(--fs-label);\n text-align: center;\n}\n\n.kpis {\n display: grid;\n grid-template-columns: repeat(4, minmax(11.25rem, 1fr));\n gap: var(--section-gap);\n}\n\n.kpi {\n height: 6.25rem;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n gap: 0.25rem;\n padding: var(--card-pad);\n}\n\n.kpi .label {\n font-size: var(--fs-label-lg);\n}\n\n.kval {\n display: flex;\n align-items: flex-end;\n gap: 0.3rem;\n color: var(--good);\n font-weight: 700;\n}\n\n.knum {\n font-size: var(--fs-kpi);\n line-height: 1;\n}\n\n.kunit {\n font-size: var(--fs-kpi-unit);\n line-height: 1;\n}\n\n.panel {\n padding: var(--card-pad-lg);\n display: flex;\n flex-direction: column;\n gap: clamp(0.75rem, 0.9vw, 0.95rem);\n}\n\n.panel-title {\n font-size: var(--fs-section-title);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n font-weight: 700;\n color: var(--text);\n margin: 0;\n padding: 0;\n background: transparent !important;\n border: none;\n box-shadow: none;\n}\n\n.panel-strip {\n background: transparent;\n border: none;\n padding: 0;\n margin: 0;\n}\n\n.row-3 {\n display: flex;\n gap: var(--section-gap);\n background: transparent;\n}\n\n.mini {\n flex: 1;\n min-height: 6.25rem;\n padding: var(--card-pad);\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n.mini .val {\n font-size: var(--fs-body);\n color: var(--text);\n font-weight: 700;\n}\n\n.progress {\n display: flex;\n align-items: center;\n gap: 0.4rem;\n}\n\n.track {\n flex: 1;\n height: var(--progress-height);\n background: #111a24;\n border-radius: 999px;\n overflow: hidden;\n box-shadow: inset 0 0.0625rem 0 rgba(255, 255, 255, .05);\n}\n\n.fill {\n height: 100%;\n width: 0;\n background: var(--good);\n transition: width 0.35s ease;\n}\n\n.pct {\n min-width: 3rem;\n text-align: right;\n color: var(--text);\n font-weight: 700;\n font-size: var(--fs-body);\n}\n\n.bottom {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: var(--section-gap);\n}\n\n.gparts,\n.status,\n.start-wrap {\n min-height: clamp(9.375rem, 18vw, 11.25rem);\n padding: var(--card-pad-lg);\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n.gnum {\n font-size: clamp(2.5rem, 3.2vw, 3.5rem);\n color: var(--good);\n font-weight: 700;\n}\n\n.gmeta {\n color: var(--muted);\n font-size: clamp(0.75rem, 0.9vw, 0.85rem);\n}\n\n.status {\n justify-content: center;\n gap: clamp(0.75rem, 1vw, 1rem);\n}\n\n.st-row {\n background: #121c28;\n border-radius: 0.625rem;\n border: 1px solid #243244;\n padding: 0.9rem 1rem;\n display: flex;\n align-items: center;\n justify-content: space-between;\n}\n\n.st-name {\n color: var(--text);\n font-weight: 600;\n font-size: var(--fs-body);\n}\n\n.st-val {\n font-weight: 700;\n font-size: clamp(1.25rem, 1.4vw, 1.5rem);\n text-transform: uppercase;\n}\n\n.st-on {\n color: var(--good);\n}\n\n.st-off {\n color: var(--bad);\n}\n\n.start-wrap {\n align-items: center;\n justify-content: center;\n padding: 0;\n}\n\n.start {\n width: 100%;\n height: 100%;\n min-height: var(--start-min-height);\n border-radius: var(--radius);\n border: none;\n background: linear-gradient(180deg, #32ff7e 0%, #2fd289 100%);\n color: var(--text);\n font-weight: 700;\n font-size: clamp(1.4rem, 2vw, 2rem);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n box-shadow: 0 0 1.25rem rgba(36, 208, 111, .5), inset 0 0.0625rem 0 rgba(255, 255, 255, .1);\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter;\n}\n\n.start:hover {\n transform: translateY(-0.05rem);\n filter: brightness(1.08);\n}\n.modal {\nposition: fixed;\ninset: 0;\ndisplay: flex;\nalign-items: center;\njustify-content: center;\nbackground: rgba(12, 17, 23, 0.82);\nbackdrop-filter: blur(4px);\nz-index: 999;\npadding: 2rem;\n}\n\n.modal.ng-hide {\ndisplay: none !important;\n}\n\n.modal-card {\nwidth: clamp(18rem, 32vw, 26rem);\nbackground: rgba(20, 30, 44, 0.94);\nborder-radius: 1rem;\nborder: 1px solid rgba(46, 163, 255, 0.35);\nbox-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, 0.45);\npadding: 1.75rem;\ndisplay: flex;\nflex-direction: column;\ngap: 0.75rem;\ncolor: var(--text);\n}\n\n.prompt-actions,\n.prompt-entry {\ndisplay: flex;\ngap: 0.75rem;\njustify-content: center;\n}\n\n.prompt-no,\n.prompt-yes,\n.prompt-entry button {\nflex: 1;\npadding: 0.9rem 1rem;\nborder-radius: 0.75rem;\nborder: none;\nfont-weight: 700;\ntext-transform: uppercase;\ncursor: pointer;\ntransition: transform 0.16s ease, filter 0.16s ease;\n}\n\n.prompt-no {\nbackground: rgba(36, 208, 111, 0.85);\ncolor: var(--text);\n}\n\n.prompt-yes,\n.prompt-entry button {\nbackground: rgba(46, 163, 255, 0.85);\ncolor: var(--text);\n}\n\n.prompt-entry input {\nflex: 1;\npadding: 0.85rem 1rem;\nborder-radius: 0.75rem;\nborder: 1px solid rgba(46, 163, 255, 0.35);\nbackground: rgba(15, 22, 33, 0.9);\ncolor: var(--text);\nfont-size: 1rem;\n}\n\n.start:active {\n transform: none;\n filter: brightness(0.96);\n}\n\n@media (max-width: 68.75rem) {\n .kpis {\n grid-template-columns: repeat(2, minmax(11.25rem, 1fr));\n }\n .row-3 {\n flex-direction: column;\n }\n .bottom {\n grid-template-columns: 1fr;\n }\n}\n\n\n/* Override Node-RED Dashboard's gray backgrounds on text elements */\n.nr-dashboard-template p,\n.nr-dashboard-template h1,\n.nr-dashboard-template h2,\n.nr-dashboard-template h3,\n.nr-dashboard-template h4,\n.nr-dashboard-template label {\n background-color: transparent !important;\n}\n\n/* Scrap modal sizing - responsive */\n.modal-card-scrap {\n width: min(90vw, 28rem);\n max-height: 85vh;\n overflow-y: auto;\n}\n\n.wo-info {\n color: var(--accent);\n font-size: var(--fs-section-title);\n margin: 0.5rem 0;\n}\n\n.wo-summary {\n color: var(--muted);\n margin: 0.25rem 0 0.75rem;\n}\n\n.scrap-question {\n font-size: var(--fs-label-lg);\n text-align: center;\n margin-bottom: 0.75rem;\n color: var(--text);\n}\n\n/* Numpad styling */\n.numpad-container {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n.numpad-display {\n background: #0f1721;\n border: 2px solid var(--accent);\n border-radius: var(--radius);\n padding: 0.5rem;\n font-size: clamp(1rem, 4vw, 1.5rem);\n font-weight: 700;\n text-align: right;\n color: var(--text);\n min-height: 2rem;\n display: flex;\n align-items: center;\n justify-content: flex-end;\n word-break: break-all;\n overflow-wrap: anywhere;\n}\n\n.numpad-error {\n background: rgba(255, 77, 79, 0.2);\n border: 1px solid var(--bad);\n border-radius: var(--radius);\n padding: 0.5rem;\n color: var(--bad);\n text-align: center;\n font-size: var(--fs-body);\n margin-top: -0.3rem;\n}\n\n.numpad-grid {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 0.4rem;\n}\n\n.numpad-btn {\n background: linear-gradient(160deg, #1a2433 0%, #151e2b 100%);\n border: 1px solid #243244;\n border-radius: var(--radius);\n color: var(--text);\n font-size: clamp(1rem, 3vw, 1.4rem);\n font-weight: 700;\n padding: 0.5rem;\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter;\n min-height: 2.5rem;\n}\n\n.numpad-btn:active {\n transform: scale(0.95);\n filter: brightness(1.2);\n}\n\n.numpad-clear,\n.numpad-back {\n background: linear-gradient(160deg, #2a1a1a 0%, #1f1515 100%);\n color: var(--warn);\n}\n\n.numpad-actions {\n display: flex;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n.scrap-submit-btn {\n flex: 1;\n background: linear-gradient(180deg, #32ff7e 0%, #2fd289 100%);\n box-shadow: 0 0 1.25rem rgba(36, 208, 111, .5);\n border: none;\n border-radius: var(--radius);\n color: var(--text);\n padding: 0.5rem;\n font-weight: 700;\n font-size: clamp(0.85rem, 2vw, 1rem);\n text-transform: uppercase;\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter;\n}\n\n.scrap-submit-btn:active {\n transform: scale(0.98);\n filter: brightness(1.1);\n}\n\n/* Updated button styles */\n.prompt-continue {\n flex: 1;\n padding: 1.2rem;\n border-radius: var(--radius);\n border: none;\n font-weight: 700;\n font-size: var(--fs-label);\n text-transform: uppercase;\n cursor: pointer;\n background: linear-gradient(180deg, #2ea3ff 0%, #1f8cf3 100%);\n color: var(--text);\n transition: 0.16s transform, 0.16s filter;\n}\n\n.prompt-continue:active {\n transform: scale(0.98);\n filter: brightness(1.1);\n}\n\n.prompt-yes {\n flex: 1;\n padding: 1.2rem;\n border-radius: var(--radius);\n border: none;\n font-weight: 700;\n font-size: var(--fs-label);\n text-transform: uppercase;\n cursor: pointer;\n background: linear-gradient(180deg, #ffd100 0%, #e6bd00 100%);\n color: var(--bg-0);\n transition: 0.16s transform, 0.16s filter;\n}\n\n.prompt-yes:active {\n transform: scale(0.98);\n filter: brightness(1.1);\n}\n\n/* STOP button styling */\n.start.stop-btn {\n background: linear-gradient(180deg, #ff4d4f 0%, #e63946 100%);\n box-shadow: 0 0 1.25rem rgba(255, 77, 79, .5);\n}\n\n.start.stop-btn:hover {\n filter: brightness(1.15);\n}\n\n/* Disabled START button */\n.start:disabled {\n background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%);\n box-shadow: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.start:disabled:hover {\n transform: none;\n filter: none;\n}\n\n</style>\n<div id=\"oee\">\n <aside class=\"sidebar\">\n <div class=\"side-top\">\n <button class=\"sb-btn active\" data-label=\"Home\" ng-click=\"gotoTab('Home')\"><span class=\"sb-ico\">🏠</span></button>\n <button class=\"sb-btn\" data-label=\"Work Orders\" ng-click=\"gotoTab('Work Orders')\"><span class=\"sb-ico\">📋</span></button>\n <button class=\"sb-btn\" data-label=\"Alerts\" ng-click=\"gotoTab('Alerts')\"><span class=\"sb-ico\">⚠️</span></button>\n <button class=\"sb-btn\" data-label=\"Graphs\" ng-click=\"gotoTab('Graphs')\"><span class=\"sb-ico\">📊</span></button>\n <button class=\"sb-btn\" data-label=\"Help\" ng-click=\"gotoTab('Help')\"><span class=\"sb-ico\">❓</span></button>\n <button class=\"sb-btn\" data-label=\"Settings\" ng-click=\"gotoTab('Settings')\"><span class=\"sb-ico\">⚙️</span></button>\n </div>\n <div class=\"sb-foot\">OEE V1.0</div>\n </aside>\n\n <main class=\"main\">\n <div class=\"container\">\n <section class=\"kpis\">\n <article class=\"card kpi\">\n <div class=\"label\">OEE</div>\n <div class=\"kval\"><span id=\"kpi-oee-value\" class=\"knum\">0</span><span class=\"kunit\">%</span></div>\n </article>\n <article class=\"card kpi\">\n <div class=\"label\">Availability</div>\n <div class=\"kval\"><span id=\"kpi-availability-value\" class=\"knum\">0</span><span class=\"kunit\">%</span></div>\n </article>\n <article class=\"card kpi\">\n <div class=\"label\">Performance</div>\n <div class=\"kval\"><span id=\"kpi-performance-value\" class=\"knum\">0</span><span class=\"kunit\">%</span></div>\n </article>\n <article class=\"card kpi\">\n <div class=\"label\">Quality</div>\n <div class=\"kval\"><span id=\"kpi-quality-value\" class=\"knum\">0</span><span class=\"kunit\">%</span></div>\n </article>\n </section>\n\n <section class=\"card panel\">\n <div class=\"panel-strip\"><h2 class=\"panel-title\">Current Work Order</h2></div>\n <div class=\"row-3\">\n <div class=\"card mini\">\n <div class=\"label\">Work Order ID</div>\n <div id=\"workorder-id\" class=\"val\">&nbsp;</div>\n </div>\n <div class=\"card mini\">\n <div class=\"label\">SKU</div>\n <div id=\"workorder-sku\" class=\"val\">&nbsp;</div>\n </div>\n <div class=\"card mini\">\n <div class=\"label\">Cycle Time</div>\n <div id=\"workorder-cycle\" class=\"val\">0</div>\n </div>\n </div>\n <div class=\"progress\">\n <div class=\"track\"><div id=\"workorder-progress-fill\" class=\"fill\"></div></div>\n <div id=\"workorder-progress-percent\" class=\"pct\">0%</div>\n </div>\n </section>\n\n <section class=\"bottom\">\n <article class=\"card gparts\">\n <div class=\"label\">Good Parts</div>\n <div id=\"good-parts-value\" class=\"gnum\">0</div>\n <div id=\"good-parts-meta\" class=\"gmeta\">out of 0</div>\n </article>\n\n <article class=\"card status\">\n <div class=\"st-row\"><span class=\"st-name\">Machine</span><span id=\"machine-state\" class=\"st-val st-off\">OFFLINE</span></div>\n <div class=\"st-row\"><span class=\"st-name\">Production</span><span id=\"production-state\" class=\"st-val st-off\">STOPPED</span></div>\n </article>\n\n <article class=\"card start-wrap\">\n <button id=\"start-button\" type=\"button\" class=\"start\" ng-click=\"toggleStartStop()\" ng-class=\"{'stop-btn': isProductionRunning}\" ng-disabled=\"!hasActiveOrder && !isProductionRunning\">{{ isProductionRunning ? 'STOP' : 'START' }}</button>\n </article>\n </section>\n </div>\n </main>\n</div>\n\n<!-- Resume/Restart Prompt Modal -->\n<div id=\"resume-modal\" class=\"modal\" ng-show=\"resumePrompt.show\">\n <div class=\"modal-card\">\n <h2>Work Order Already In Progress</h2>\n <p class=\"wo-info\">{{ resumePrompt.id }}</p>\n <p class=\"wo-summary\">\n <strong>{{ resumePrompt.goodParts }}</strong> of <strong>{{ resumePrompt.targetQty }}</strong> parts completed\n ({{ resumePrompt.progressPercent }}%)\n </p>\n <p class=\"wo-summary\">Cycle Count: <strong>{{ resumePrompt.cycleCount }}</strong></p>\n\n <div style=\"margin-top: 1.5rem; display: flex; flex-direction: column; gap: 0.75rem;\">\n <button class=\"prompt-continue\" ng-click=\"resumeWorkOrder()\">\n Resume from {{ resumePrompt.goodParts }} parts\n </button>\n <button class=\"prompt-yes\" ng-click=\"confirmRestart()\">\n Restart from 0 (Warning: Progress will be lost!)\n </button>\n </div>\n </div>\n</div>\n<div id=\"scrap-modal\" class=\"modal\" ng-show=\"scrapPrompt.show\">\n <div class=\"modal-card modal-card-scrap\">\n <h2>Work Order Complete</h2>\n <p class=\"wo-info\">{{ scrapPrompt.orderId }}</p>\n <p class=\"wo-summary\">Produced <strong>{{ scrapPrompt.produced }}</strong> of <strong>{{ scrapPrompt.target }}</strong> pieces</p>\n\n <div class=\"scrap-question\">Were there any scrap parts?</div>\n\n <!-- Numpad interface -->\n <div class=\"numpad-container\" ng-if=\"scrapPrompt.enterMode\">\n <div class=\"numpad-display\">{{ scrapPrompt.scrapCount || 0 }}</div>\n <div class=\"numpad-error\" ng-if=\"scrapPrompt.error\">{{ scrapPrompt.error }}</div>\n\n <div class=\"numpad-grid\">\n <button class=\"numpad-btn\" ng-click=\"addDigit(7)\">7</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(8)\">8</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(9)\">9</button>\n\n <button class=\"numpad-btn\" ng-click=\"addDigit(4)\">4</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(5)\">5</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(6)\">6</button>\n\n <button class=\"numpad-btn\" ng-click=\"addDigit(1)\">1</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(2)\">2</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(3)\">3</button>\n\n <button class=\"numpad-btn numpad-clear\" ng-click=\"clearScrap()\">C</button>\n <button class=\"numpad-btn\" ng-click=\"addDigit(0)\">0</button>\n <button class=\"numpad-btn numpad-back\" ng-click=\"backspace()\">⌫</button>\n </div>\n\n <div class=\"numpad-actions\">\n <button class=\"scrap-submit-btn\" ng-click=\"submitScrapValidated(scrapPrompt.scrapCount)\">\n Submit Scrap\n </button>\n </div>\n </div>\n\n <!-- Initial choice buttons -->\n <div class=\"prompt-actions\" ng-if=\"!scrapPrompt.enterMode\">\n <!-- Don't ask again checkbox -->\n <div style=\"display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-bottom: 0.75rem;\">\n <input type=\"checkbox\" id=\"remind-checkbox\" ng-model=\"scrapPrompt.remindAgain\" style=\"width: 1.2rem; height: 1.2rem; cursor: pointer;\">\n <label for=\"remind-checkbox\" style=\"cursor: pointer; margin: 0; font-size: var(--fs-body); color: var(--muted);\">\n Remind me again if we keep overproducing\n </label>\n </div>\n\n <button class=\"prompt-continue\" ng-click=\"skipScrap()\">\n No, Continue Production\n </button>\n <button class=\"prompt-yes\" ng-click=\"openScrapEntry()\">\n Yes, Enter Scrap\n </button>\n </div>\n </div>\n</div>\n\n<script>\n(function(scope) {\n scope.gotoTab = function(tabName) {\n scope.send({ ui_control: { tab: tabName } });\n };\n\n // Phase 7: Tab activation listener - refresh data when returning to Home\n scope.$on('$destroy', function() {\n if (scope.tabRefreshInterval) {\n clearInterval(scope.tabRefreshInterval);\n }\n });\n\n // Request current state when tab becomes visible\n scope.refreshHomeData = function() {\n scope.send({ action: \"get-current-state\" });\n };\n\n // Poll for updates when on Home tab (every 2 seconds)\n // This ensures UI stays fresh when returning from other tabs\n scope.tabRefreshInterval = setInterval(function() {\n // Only refresh if we're on the Home tab (check if element is visible)\n var homeElement = document.getElementById('oee');\n if (homeElement && homeElement.offsetParent !== null) {\n scope.refreshHomeData();\n }\n }, 2000);\n\n\n window.kpiOeePercent = window.kpiOeePercent || 0;\n window.kpiAvailabilityPercent = window.kpiAvailabilityPercent || 0;\n window.kpiPerformancePercent = window.kpiPerformancePercent || 0;\n window.kpiQualityPercent = window.kpiQualityPercent || 0;\n window.currentWorkOrderId = window.currentWorkOrderId || \"\";\n window.currentSku = window.currentSku || \"\";\n window.currentCycleTime = window.currentCycleTime || 0;\n window.currentProgressPercent = window.currentProgressPercent || 0;\n window.goodPartsCount = window.goodPartsCount || 0;\n window.goodPartsTarget = window.goodPartsTarget || 0;\n window.machineOnline = typeof window.machineOnline === \"boolean\" ? window.machineOnline : false;\n window.productionStarted = typeof window.productionStarted === \"boolean\" ? window.productionStarted : false;\n\n var elements = {\n kpiOee: document.getElementById(\"kpi-oee-value\"),\n kpiAvailability: document.getElementById(\"kpi-availability-value\"),\n kpiPerformance: document.getElementById(\"kpi-performance-value\"),\n kpiQuality: document.getElementById(\"kpi-quality-value\"),\n workOrderId: document.getElementById(\"workorder-id\"),\n workOrderSku: document.getElementById(\"workorder-sku\"),\n workOrderCycle: document.getElementById(\"workorder-cycle\"),\n progressFill: document.getElementById(\"workorder-progress-fill\"),\n progressPercent: document.getElementById(\"workorder-progress-percent\"),\n goodPartsValue: document.getElementById(\"good-parts-value\"),\n goodPartsMeta: document.getElementById(\"good-parts-meta\"),\n machineState: document.getElementById(\"machine-state\"),\n productionState: document.getElementById(\"production-state\"),\n startButton: document.getElementById(\"start-button\")\n };\n\n function setText(el, value) {\n if (el) {\n el.textContent = value;\n }\n }\n\n function setNumber(el, value) {\n setText(el, String(Math.max(0, Math.round(Number(value) || 0))));\n }\n\n function clampPercent(value) {\n var num = Number(value) || 0;\n return Math.max(0, Math.min(100, Math.round(num)));\n }\n\n function updateState(el, isActive, onLabel, offLabel) {\n if (!el) {\n return;\n }\n el.textContent = isActive ? onLabel : offLabel;\n el.classList.remove(\"st-on\", \"st-off\");\n el.classList.add(isActive ? \"st-on\" : \"st-off\");\n }\n\n function renderDashboard() {\n setNumber(elements.kpiOee, window.kpiOeePercent);\n setNumber(elements.kpiAvailability, window.kpiAvailabilityPercent);\n setNumber(elements.kpiPerformance, window.kpiPerformancePercent);\n setNumber(elements.kpiQuality, window.kpiQualityPercent);\n\n setText(elements.workOrderId, window.currentWorkOrderId || \"\");\n setText(elements.workOrderSku, window.currentSku || \"\");\n setText(elements.workOrderCycle, window.currentCycleTime ? String(window.currentCycleTime) : \"0\");\n\n var progress = clampPercent(window.currentProgressPercent);\n if (elements.progressFill) {\n elements.progressFill.style.width = progress + \"%\";\n }\n setText(elements.progressPercent, progress + \"%\");\n\n setText(elements.goodPartsValue, String(window.goodPartsCount || 0));\n setText(elements.goodPartsMeta, \"out of \" + String(window.goodPartsTarget || 0));\n\n updateState(elements.machineState, !!window.machineOnline, \"ONLINE\", \"OFFLINE\");\n updateState(elements.productionState, !!window.productionStarted, \"STARTED\", \"STOPPED\");\n }\n\n scope.renderDashboard = renderDashboard;\n\n scope.scrapPrompt = {\n show: false,\n enterMode: false,\n orderId: '',\n sku: '',\n target: 0,\n produced: 0,\n scrapCount: 0,\n remindAgain: false\n };\n \n scope.submitScrap = function(scrapCount) {\n scrapCount = Math.max(0, Number(scrapCount) || 0);\n scope.send({\n action: 'scrap-entry',\n payload: {\n id: scope.scrapPrompt.orderId,\n scrap: scrapCount\n }\n });\n scope.scrapPrompt.show = false;\n scope.scrapPrompt.enterMode = false;\n scope.scrapPrompt.remindAgain = false;\n };\n\n // Numpad digit entry\n scope.addDigit = function(digit) {\n scope.scrapPrompt.error = '';\n const current = String(scope.scrapPrompt.scrapCount || 0);\n const newValue = current === '0' ? String(digit) : current + String(digit);\n scope.scrapPrompt.scrapCount = Number(newValue);\n };\n\n // Clear scrap count\n scope.clearScrap = function() {\n scope.scrapPrompt.scrapCount = 0;\n scope.scrapPrompt.error = '';\n };\n\n // Backspace\n scope.backspace = function() {\n scope.scrapPrompt.error = '';\n const current = String(scope.scrapPrompt.scrapCount || 0);\n const newValue = current.length > 1 ? current.slice(0, -1) : '0';\n scope.scrapPrompt.scrapCount = Number(newValue);\n };\n\n // Open scrap entry (validate and show numpad)\n scope.openScrapEntry = function() {\n scope.scrapPrompt.enterMode = true;\n scope.scrapPrompt.scrapCount = 0;\n scope.scrapPrompt.error = '';\n };\n\n // Skip scrap (No, Continue)\n scope.skipScrap = function() {\n scope.send({\n action: 'scrap-skip',\n payload: {\n id: scope.scrapPrompt.orderId,\n remindAgain: !!scope.scrapPrompt.remindAgain\n }\n });\n scope.scrapPrompt.show = false;\n scope.scrapPrompt.enterMode = false;\n scope.scrapPrompt.remindAgain = false;\n scope.scrapPrompt.error = '';\n };\n\n // START/STOP toggle\n scope.isProductionRunning = false;\n scope.hasActiveOrder = false;\n\n scope.toggleStartStop = function() {\n if (scope.isProductionRunning) {\n // STOP production - pause tracking (work order stays active)\n scope.send({ action: \"stop\" });\n scope.isProductionRunning = false;\n // Keep hasActiveOrder=true so button stays enabled (resume pattern)\n } else {\n // START/RESUME production - send start action to backend\n scope.send({ action: \"start\" });\n scope.isProductionRunning = true;\n }\n };\n\n // Submit scrap with validation\n scope.submitScrapValidated = function(scrapCount) {\n scrapCount = Math.max(0, Number(scrapCount) || 0);\n\n // Validate: scrap can't exceed produced quantity\n if (scrapCount > scope.scrapPrompt.produced) {\n scope.scrapPrompt.error = 'Scrap cannot exceed produced quantity (' + scope.scrapPrompt.produced + ')';\n return;\n }\n\n scope.send({\n action: 'scrap-entry',\n payload: {\n id: scope.scrapPrompt.orderId,\n scrap: scrapCount\n }\n });\n scope.scrapPrompt.show = false;\n scope.scrapPrompt.enterMode = false;\n scope.scrapPrompt.remindAgain = false;\n scope.scrapPrompt.scrapCount = 0;\n scope.scrapPrompt.error = '';\n };\n\n\n renderDashboard();\n\n\n})(scope);\n\n(function ensureNoMaterialTint(){\n const root = document.getElementById('oee');\n if (!root) return;\n\n const targets = root.querySelectorAll([\n '.md-toolbar',\n 'md-toolbar',\n '.md-subheader',\n 'md-subheader',\n '.md-toolbar-tools',\n '.md-card-title'\n ].join(','));\n\n targets.forEach(el => {\n if (el && el.style) {\n if (el.style.background) {\n el.style.background = 'transparent';\n }\n if (el.style.backgroundColor) {\n el.style.backgroundColor = 'transparent';\n }\n }\n if (el && el.classList) {\n el.classList.remove(\n 'md-whiteframe-1dp','md-whiteframe-2dp','md-whiteframe-3dp',\n 'md-whiteframe-z1','md-whiteframe-z2','md-whiteframe-z3'\n );\n }\n });\n\n if (!document.getElementById('oee-theme-sentinel')) {\n const style = document.createElement('style');\n style.id = 'oee-theme-sentinel';\n style.textContent = `\n #oee .md-toolbar::before, #oee .md-toolbar::after,\n #oee .md-subheader::before, #oee .md-subheader::after,\n #oee md-toolbar::before, #oee md-toolbar::after,\n #oee md-subheader::before, #oee md-subheader::after {\n display: none !important;\n content: none !important;\n }\n `;\n document.head.appendChild(style);\n }\n\n scope.$evalAsync(scope.renderDashboard);\n\n})(scope);\n\n\n// optional render trigger\n\n</script>\n<script>\n(function killMaterialTintsLive(){\nconst root = document.getElementById('oee');\nif (!root) return;\n\nconst zap = (el) => {\nif (!el) return;\nel.style.background = 'transparent';\nel.style.backgroundColor = 'transparent';\nel.style.boxShadow = 'none';\n};\n\nconst sel = [\n'.md-subheader','md-subheader','.md-toolbar','md-toolbar',\n'.md-card-title','.md-sticky-clone','._md-sticky-clone',\n'.nr-dashboard-template md-content','md-content'\n].join(',');\n\n// Clean anything present now\nroot.querySelectorAll(sel).forEach(zap);\n\n// Clean future clones dynamically\nconst mo = new MutationObserver((muts) => {\nfor (const m of muts) {\nm.addedNodes && m.addedNodes.forEach(n => {\nif (!(n instanceof HTMLElement)) return;\nif (n.matches && n.matches(sel)) zap(n);\nn.querySelectorAll && n.querySelectorAll(sel).forEach(zap);\n});\n}\n});\nmo.observe(document.body, { childList: true, subtree: true });\n})();\n\n\n\n (function(scope) {\n scope.$watch('msg', function(msg) {\n if (!msg) {\n return;\n }\n\n // Handle current state updates (from tab refresh)\n if (msg.topic === 'currentState' && msg.payload) {\n var state = msg.payload;\n \n if (state.activeWorkOrder) {\n window.currentWorkOrderId = state.activeWorkOrder.id || \"\";\n window.currentSku = state.activeWorkOrder.sku || \"\";\n window.currentCycleTime = state.activeWorkOrder.cycleTime || 0;\n window.goodPartsCount = state.activeWorkOrder.good || 0;\n window.goodPartsTarget = state.activeWorkOrder.target || 0;\n window.currentProgressPercent = state.activeWorkOrder.progressPercent || 0;\n }\n \n if (state.kpis) {\n window.kpiOeePercent = state.kpis.oee || 0;\n window.kpiAvailabilityPercent = state.kpis.availability || 0;\n window.kpiPerformancePercent = state.kpis.performance || 0;\n window.kpiQualityPercent = state.kpis.quality || 0;\n }\n \n scope.isProductionRunning = state.productionStarted || false;\n scope.hasActiveOrder = !!state.activeWorkOrder;\n \n scope.renderDashboard();\n return;\n }\n\n \n // Handle resume prompt\n if (msg.topic === 'resumePrompt' && msg.payload) {\n scope.resumePrompt.show = true;\n scope.resumePrompt.id = msg.payload.id || '';\n scope.resumePrompt.sku = msg.payload.sku || '';\n scope.resumePrompt.cycleCount = msg.payload.cycleCount || 0;\n scope.resumePrompt.goodParts = msg.payload.goodParts || 0;\n scope.resumePrompt.targetQty = msg.payload.targetQty || 0;\n scope.resumePrompt.progressPercent = msg.payload.progressPercent || 0;\n scope.resumePrompt.order = msg.payload.order || null;\n return;\n }\n\n if (msg.topic === 'machineStatus') {\n \n // ===== UPDATE PRODUCTION STATE =====\n if (msg.payload.trackingEnabled !== undefined) {\n scope.isProductionRunning = msg.payload.trackingEnabled;\n }\n if (msg.payload.productionStarted !== undefined) {\n window.productionStarted = msg.payload.productionStarted;\n }\n if (msg.payload.machineOnline !== undefined) {\n window.machineOnline = msg.payload.machineOnline;\n }\n\n // ===== UPDATE KPI DISPLAYS =====\n if (msg.payload && msg.payload.kpis) {\n var kpis = msg.payload.kpis;\n\n // Update OEE\n if (window.kpiOeePercent !== kpis.oee) {\n window.kpiOeePercent = kpis.oee;\n var oeeEl = document.getElementById('kpi-oee-value');\n if (oeeEl) oeeEl.textContent = kpis.oee.toFixed(1) + '%';\n }\n\n // Update Availability\n if (window.kpiAvailabilityPercent !== kpis.availability) {\n window.kpiAvailabilityPercent = kpis.availability;\n var availEl = document.getElementById('kpi-availability-value');\n if (availEl) availEl.textContent = kpis.availability.toFixed(1) + '%';\n }\n\n // Update Performance\n if (window.kpiPerformancePercent !== kpis.performance) {\n window.kpiPerformancePercent = kpis.performance;\n var perfEl = document.getElementById('kpi-performance-value');\n if (perfEl) perfEl.textContent = kpis.performance.toFixed(1) + '%';\n }\n\n // Update Quality\n if (window.kpiQualityPercent !== kpis.quality) {\n window.kpiQualityPercent = kpis.quality;\n var qualEl = document.getElementById('kpi-quality-value');\n if (qualEl) qualEl.textContent = kpis.quality.toFixed(1) + '%';\n }\n }\n\n window.machineOnline = msg.payload.machineOnline;\n window.productionStarted = msg.payload.productionStarted;\n scope.renderDashboard();\n return;\n }\n if (msg.topic === 'activeWorkOrder') {\n const order = msg.payload || {};\n if (!order || !order.id) {\n window.currentWorkOrderId = \"\";\n window.currentSku = \"\";\n window.currentCycleTime = 0;\n window.currentProgressPercent = 0;\n window.goodPartsCount = 0;\n window.goodPartsTarget = 0;\n } else {\n window.currentWorkOrderId = order.id || \"\";\n window.currentSku = order.sku || \"\";\n window.currentCycleTime = Number(order.cycleTime) || 0;\n window.currentProgressPercent = Number(order.progressPercent) || 0;\n window.goodPartsCount = Number(order.good) || 0;\n window.goodPartsTarget = Number(order.target) || 0;\n }\n \n // ===== UPDATE KPI DISPLAYS =====\n if (order.kpis) {\n var kpis = order.kpis;\n \n // Update OEE\n window.kpiOeePercent = kpis.oee || 0;\n var oeeEl = document.getElementById('kpi-oee-value');\n if (oeeEl) oeeEl.textContent = (kpis.oee || 0).toFixed(1) + '%';\n \n // Update Availability\n window.kpiAvailabilityPercent = kpis.availability || 0;\n var availEl = document.getElementById('kpi-availability-value');\n if (availEl) availEl.textContent = (kpis.availability || 0).toFixed(1) + '%';\n \n // Update Performance\n window.kpiPerformancePercent = kpis.performance || 0;\n var perfEl = document.getElementById('kpi-performance-value');\n if (perfEl) perfEl.textContent = (kpis.performance || 0).toFixed(1) + '%';\n \n // Update Quality\n window.kpiQualityPercent = kpis.quality || 0;\n var qualEl = document.getElementById('kpi-quality-value');\n if (qualEl) qualEl.textContent = (kpis.quality || 0).toFixed(1) + '%';\n }\n \n // Update START button availability\n scope.hasActiveOrder = !!(order && order.id);\n if (!order.id) {\n scope.isProductionRunning = false;\n }\n scope.renderDashboard();\n }\n if (!scope.scrapPrompt) {\n scope.scrapPrompt = {\n show: false,\n enterMode: false,\n orderId: '',\n sku: '',\n target: 0,\n produced: 0,\n scrapCount: 0,\n remindAgain: false\n };\n }\n \n if (msg.topic === 'scrapPrompt') {\n var p = msg.payload || {};\n \n scope.scrapPrompt.show = true;\n scope.scrapPrompt.enterMode = false;\n scope.scrapPrompt.remindAgain = false;\n scope.scrapPrompt.orderId = p.id || '';\n scope.scrapPrompt.sku = p.sku || '';\n scope.scrapPrompt.target = Number(p.target) || 0;\n scope.scrapPrompt.produced = Number(p.produced) || 0;\n scope.scrapPrompt.scrapCount = 0;\n \n scope.gotoTab('Home'); // make sure we're on Home\n scope.$evalAsync(); // trigger Angular digest so ng-show updates\n return;\n }\n scope.submitScrap = function(scrapCount) {\n scrapCount = Math.max(0, Number(scrapCount) || 0);\n scope.send({\n action: 'scrap-entry',\n payload: {\n id: scope.scrapPrompt.orderId,\n scrap: scrapCount\n }\n });\n scope.scrapPrompt.show = false;\n scope.scrapPrompt.enterMode = false;\n scope.scrapPrompt.remindAgain = false;\n };\n });\n\n // Resume/Restart Prompt State\n scope.resumePrompt = {\n show: false,\n id: '',\n sku: '',\n cycleCount: 0,\n goodParts: 0,\n targetQty: 0,\n progressPercent: 0,\n order: null\n };\n\n scope.resumeWorkOrder = function() {\n if (!scope.resumePrompt.order) {\n console.error('No order data for resume');\n return;\n }\n\n scope.send({\n action: 'resume-work-order',\n payload: scope.resumePrompt.order\n });\n\n scope.resumePrompt.show = false;\n scope.hasActiveOrder = true;\n };\n\n scope.confirmRestart = function() {\n if (!confirm('Are you sure you want to restart? All progress (' + scope.resumePrompt.goodParts + ' parts) will be lost!')) {\n return;\n }\n\n if (!scope.resumePrompt.order) {\n console.error('No order data for restart');\n return;\n }\n\n scope.send({\n action: 'restart-work-order',\n payload: scope.resumePrompt.order\n });\n\n scope.resumePrompt.show = false;\n scope.hasActiveOrder = true;\n };\n\n })(scope);\n\n\n</script>\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 380,
"y": 280,
"wires": [
[
"a7d58e15929b3d8c",
"010de5af3ced0ae3"
]
]
},
{
"id": "f2a3b4c5d6e7f8a9",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "e2f3a4b5c6d7e8f9",
"name": "Alerts Template",
"order": 0,
"width": "25",
"height": "25",
"format": "<style>\n@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');\n\n/* SCALE: align 100% zoom with prior 125% appearance */\n:root {\n font-size: 125%;\n --bg-0: #0c1117;\n --bg-1: #131a23;\n --panel: #151e2b;\n --panel-hi: #1a2433;\n --text: #ecf3ff;\n --muted: #96a5b7;\n --accent: #2ea3ff;\n --accent-2: #1f8cf3;\n --good: #24d06f;\n --warn: #ffd100;\n --bad: #ff4d4f;\n --radius: 0.75rem;\n --shadow: 0 0.5rem 1rem rgba(0, 0, 0, .35), inset 0 0.0625rem 0 rgba(255, 255, 255, .05);\n --sidebar-width: 3.75rem;\n --sidebar-gap: clamp(0.75rem, 1.2vh, 1rem);\n --content-max: 80rem;\n --content-pad: clamp(1rem, 1.2vw, 1.4rem);\n --section-gap: clamp(0.75rem, 1vw, 1rem);\n --card-pad: clamp(1rem, 1.1vw, 1.25rem);\n --fs-page-title: clamp(1.2rem, 1vw + 0.35rem, 1.7rem);\n --fs-section-title: clamp(0.95rem, 0.9vw + 0.25rem, 1.25rem);\n --fs-body: clamp(0.8rem, 0.7vw + 0.28rem, 1rem);\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml,\nbody,\n#oee {\n width: 100vw;\n height: 100vh;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n overflow-x: hidden;\n background: var(--bg-0);\n color: var(--text);\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n}\n\nbody {\n overflow: hidden;\n}\n\nbody > md-content,\nbody > md-content > md-content,\n.nr-dashboard-template,\n.nr-dashboard-template md-content,\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-content {\n background: transparent !important;\n height: 100%;\n overflow: hidden;\n}\n\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-card,\n.nr-dashboard-cardpanel md-card-content {\n background: transparent !important;\n box-shadow: none !important;\n padding: 0 !important;\n}\n\n/* LAYOUT: lock sidebar + content grid across tabs */\n#oee {\n position: fixed;\n inset: 0;\n display: flex;\n overflow: hidden;\n}\n\n.sidebar {\n width: var(--sidebar-width);\n background: #0b1119;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: space-between;\n padding: clamp(0.625rem, 1.4vh, 0.9rem) clamp(0.5rem, 0.8vw, 0.75rem);\n}\n\n.side-top {\n display: flex;\n flex-direction: column;\n gap: var(--sidebar-gap);\n align-items: center;\n}\n\n.sb-btn {\n width: 2.75rem;\n height: 2.75rem;\n border-radius: 0.75rem;\n background: #0f1721;\n border: 1px solid #19222e;\n display: grid;\n place-items: center;\n color: #8fb3d9;\n cursor: pointer;\n transition: 0.16s box-shadow, 0.16s transform, 0.16s border-color;\n}\n\n.sb-btn.active {\n border-color: #2fd289;\n box-shadow: 0 0 0 0.125rem rgba(36, 208, 111, .25), 0 0.625rem 1.125rem rgba(36, 208, 111, .28);\n color: #2fd289;\n}\n\n.sb-ico {\n font-size: clamp(1.1rem, 1.2vw, 1.25rem);\n line-height: 1;\n}\n\n.sb-foot {\n font-size: clamp(0.6rem, 0.6vw, 0.7rem);\n color: #6c7b8d;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n}\n\n.main {\n flex: 1;\n overflow: auto;\n padding: var(--content-pad);\n}\n\n.container {\n max-width: var(--content-max);\n margin: 0 auto;\n display: flex;\n flex-direction: column;\n gap: var(--section-gap);\n}\n\n.card {\n background: linear-gradient(160deg, var(--panel) 0%, var(--panel-hi) 100%);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n}\n\n/* HEADER: maintain title weight + spacing */\n.page-heading {\n display: flex;\n align-items: center;\n padding: 0 0.25rem;\n}\n\n.page-title {\n margin: 0;\n font-size: var(--fs-page-title);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n/* QUICK ALERTS: button row stays balanced on any width */\n.quick-alerts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(13.75rem, 1fr));\n gap: var(--section-gap);\n padding: var(--card-pad);\n}\n\n.quick-alert-btn {\n background: linear-gradient(180deg, #ff5252 0%, #ff4d4f 100%);\n box-shadow: 0 0 1.25rem rgba(255, 77, 79, .5), inset 0 0.0625rem 0 rgba(255, 255, 255, .1);\n border: none;\n border-radius: var(--radius);\n color: var(--text);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-weight: 700;\n padding: clamp(1.1rem, 1.8vw, 1.4rem);\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter;\n}\n\n.quick-alert-btn:hover {\n transform: translateY(-0.05rem);\n filter: brightness(1.08);\n}\n\n.quick-alert-btn:active {\n transform: none;\n filter: brightness(0.96);\n}\n\n/* FORM CARD: consistent padding + typography */\n.alert-card {\n padding: var(--card-pad);\n display: flex;\n flex-direction: column;\n gap: clamp(0.9rem, 1vw, 1.1rem);\n}\n\n.form-group {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.form-group label {\n font-size: var(--fs-section-title);\n letter-spacing: 0.04em;\n text-transform: uppercase;\n}\n\n.form-group select,\n.form-group textarea {\n background: #121c28;\n border: 1px solid #243244;\n border-radius: var(--radius);\n color: var(--text);\n padding: 0.8rem 0.9rem;\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n font-size: var(--fs-body);\n}\n\ntextarea {\n resize: vertical;\n min-height: 7.5rem;\n}\n\n.alert-send {\n align-self: flex-end;\n background: linear-gradient(180deg, #ff5252 0%, #ff4d4f 100%);\n box-shadow: 0 0 1.25rem rgba(255, 77, 79, .5), inset 0 0.0625rem 0 rgba(255, 255, 255, .1);\n border: none;\n border-radius: var(--radius);\n color: var(--text);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-weight: 700;\n padding: 0.8rem 1.4rem;\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter;\n}\n\n.alert-send:hover {\n transform: translateY(-0.05rem);\n filter: brightness(1.08);\n}\n\n.alert-send:active {\n transform: none;\n filter: brightness(0.96);\n}\n\n\n/* Override Node-RED Dashboard's gray backgrounds on text elements */\n.nr-dashboard-template p,\n.nr-dashboard-template h1,\n.nr-dashboard-template h2,\n.nr-dashboard-template h3,\n.nr-dashboard-template h4,\n.nr-dashboard-template label {\n background-color: transparent !important;\n}\n\n/* Alert sent feedback animation */\n@keyframes alert-sent {\n 0% {\n transform: scale(1);\n filter: brightness(1);\n }\n 50% {\n transform: scale(0.98);\n background: linear-gradient(180deg, #24d06f 0%, #2fd289 100%);\n box-shadow: 0 0 1.5rem rgba(36, 208, 111, .7);\n filter: brightness(1.2);\n }\n 100% {\n transform: scale(1);\n filter: brightness(1);\n }\n}\n\n.alert-sent-animation {\n animation: alert-sent 0.4s ease-out;\n}\n\n</style>\n<div id=\"oee\">\n <aside class=\"sidebar\">\n <div class=\"side-top\">\n <button class=\"sb-btn\" data-label=\"Home\" ng-click=\"gotoTab('Home')\"><span class=\"sb-ico\">🏠</span></button>\n <button class=\"sb-btn\" data-label=\"Work Orders\" ng-click=\"gotoTab('Work Orders')\"><span class=\"sb-ico\">📋</span></button>\n <button class=\"sb-btn active\" data-label=\"Alerts\" ng-click=\"gotoTab('Alerts')\"><span class=\"sb-ico\">⚠️</span></button>\n <button class=\"sb-btn\" data-label=\"Graphs\" ng-click=\"gotoTab('Graphs')\"><span class=\"sb-ico\">📊</span></button>\n <button class=\"sb-btn\" data-label=\"Help\" ng-click=\"gotoTab('Help')\"><span class=\"sb-ico\">❓</span></button>\n <button class=\"sb-btn\" data-label=\"Settings\" ng-click=\"gotoTab('Settings')\"><span class=\"sb-ico\">⚙️</span></button>\n </div>\n <div class=\"sb-foot\">OEE V1.0</div>\n </aside>\n\n <main class=\"main\">\n <div class=\"container\">\n <section class=\"page-heading\">\n <h1 class=\"page-title\">Incidents</h1>\n </section>\n\n <section class=\"card quick-alerts\">\n <button class=\"quick-alert-btn\" data-alert=\"Material Out\">Material Out</button>\n <button class=\"quick-alert-btn\" data-alert=\"Machine Stopped\">Machine Stopped</button>\n <button class=\"quick-alert-btn\" data-alert=\"Emergency Stop\">Emergency Stop</button>\n </section>\n\n <section class=\"card alert-card\">\n <div class=\"form-group\">\n <label for=\"alert-type\">Alert type</label>\n <select id=\"alert-type\">\n <option>Quality Defect Detected</option>\n <option>Tooling/Mold Issue</option>\n <option>Temperature Out of Range</option>\n <option>Pressure Issue</option>\n <option>Cycle Time Deviation</option>\n <option>Safety Interlock Triggered</option>\n <option>Hydraulic/Pneumatic Failure</option>\n <option>Electrical Fault</option>\n <option>Sensor Malfunction</option>\n <option>Other</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label for=\"alert-description\">Description</label>\n <textarea id=\"alert-description\" rows=\"5\" placeholder=\"Add context or instructions (optional)\"></textarea>\n </div>\n <button id=\"alert-send\" class=\"alert-send\" type=\"button\">Send Alert</button>\n </section>\n </div>\n </main>\n</div>\n\n<script>\n(function(scope) {\n scope.gotoTab = function(tabName) {\n scope.send({ ui_control: { tab: tabName } });\n };\n\n // Quick alert buttons - with new features (timestamp, visual feedback, DB logging)\n var quickButtons = document.querySelectorAll('[data-alert]');\n quickButtons.forEach(function(btn) {\n btn.addEventListener('click', function() {\n var type = btn.getAttribute('data-alert');\n var timestamp = new Date().toISOString();\n\n // Visual feedback\n btn.classList.add('alert-sent-animation');\n setTimeout(function() {\n btn.classList.remove('alert-sent-animation');\n }, 400);\n\n // Send message with timestamp and description\n scope.send({\n payload: {\n action: 'alert',\n type: type,\n timestamp: timestamp,\n description: ''\n }\n });\n });\n });\n\n // Send alert button - with new features\n var sendButton = document.getElementById('alert-send');\n if (sendButton) {\n sendButton.addEventListener('click', function() {\n var type = document.getElementById('alert-type').value;\n var description = (document.getElementById('alert-description').value || '').trim();\n var timestamp = new Date().toISOString();\n\n // Visual feedback\n sendButton.classList.add('alert-sent-animation');\n setTimeout(function() {\n sendButton.classList.remove('alert-sent-animation');\n }, 400);\n\n // Clear form\n document.getElementById('alert-description').value = '';\n\n // Send message with timestamp\n scope.send({\n payload: {\n action: 'alert',\n type: type,\n timestamp: timestamp,\n description: description\n }\n });\n });\n }\n})(scope);\n\n(function ensureNoMaterialTint(){\n const root = document.getElementById('oee');\n if (!root) return;\n\n const targets = root.querySelectorAll([\n '.md-toolbar',\n 'md-toolbar',\n '.md-subheader',\n 'md-subheader',\n '.md-toolbar-tools',\n '.md-card-title'\n ].join(','));\n\n targets.forEach(el => {\n if (el && el.style) {\n if (el.style.background) {\n el.style.background = 'transparent';\n }\n if (el.style.backgroundColor) {\n el.style.backgroundColor = 'transparent';\n }\n }\n if (el && el.classList) {\n el.classList.remove(\n 'md-whiteframe-1dp','md-whiteframe-2dp','md-whiteframe-3dp',\n 'md-whiteframe-z1','md-whiteframe-z2','md-whiteframe-z3'\n );\n }\n });\n\n if (!document.getElementById('oee-theme-sentinel')) {\n const style = document.createElement('style');\n style.id = 'oee-theme-sentinel';\n style.textContent = `\n #oee .md-toolbar::before, #oee .md-toolbar::after,\n #oee .md-subheader::before, #oee .md-subheader::after,\n #oee md-toolbar::before, #oee md-toolbar::after,\n #oee md-subheader::before, #oee md-subheader::after {\n display: none !important;\n content: none !important;\n }\n `;\n document.head.appendChild(style);\n }\n})();\n\n\n// optional render trigger\nscope.$evalAsync(function() {});\n</script>",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 380,
"y": 360,
"wires": [
[
"a7d58e15929b3d8c",
"9f2a5d3a891a5130"
]
]
},
{
"id": "f3a4b5c6d7e8f9a0",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "e3f4a5b6c7d8e9f0",
"name": "Graphs Template",
"order": 0,
"width": "25",
"height": "25",
"format": "<style>\n @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');\n\n :root {\n font-size: 125%;\n --bg-0: #0c1117;\n --bg-1: #131a23;\n --panel: #151e2b;\n --panel-hi: #1a2433;\n --text: #ecf3ff;\n --muted: #96a5b7;\n --accent: #2ea3ff;\n --accent-2: #1f8cf3;\n --good: #24d06f;\n --warn: #ffd100;\n --bad: #ff4d4f;\n --radius: 0.75rem;\n --shadow: 0 0.5rem 1rem rgba(0, 0, 0, .35),\n inset 0.0625rem 0 rgba(255, 255, 255, .05);\n\n --sidebar-width: 3.75rem;\n --content-max: 100rem;\n --content-pad: clamp(1rem, 1.2vw, 1.4rem);\n --section-gap: clamp(0.75rem, 1vw, 1rem);\n --card-pad: clamp(1rem, 1.1vw, 1.25rem);\n }\n\n .graphs-wrapper {\n display: flex;\n width: 100%;\n min-height: calc(100vh - 1px);\n background: var(--bg-0);\n }\n\n .sidebar {\n width: var(--sidebar-width);\n background: #0b1119;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 0.75rem;\n padding: 1rem 0.5rem;\n flex-shrink: 0;\n }\n\n .sb-btn {\n width: 2.75rem;\n height: 2.75rem;\n border-radius: .75rem;\n background: #0f1721;\n border: 1px solid #19222e;\n display: grid;\n place-items: center;\n color: #8fb3d9;\n cursor: pointer;\n }\n\n .sb-btn.active {\n border-color: #2fd289;\n color: #2fd289;\n }\n\n .main {\n flex: 1;\n overflow-y: auto;\n padding: var(--content-pad);\n }\n\n .container {\n max-width: var(--content-max);\n margin: auto;\n display: flex;\n flex-direction: column;\n gap: var(--section-gap);\n }\n\n .chart-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: var(--section-gap);\n }\n\n .chart-card {\n background: linear-gradient(160deg, var(--panel), var(--panel-hi));\n border-radius: var(--radius);\n padding: var(--card-pad);\n box-shadow: var(--shadow);\n height: 370px;\n }\n\n .chart-placeholder canvas {\n width: 100% !important;\n height: 100% !important;\n }\n\n .filter-buttons {\n display: flex;\n gap: .5rem;\n flex-wrap: wrap;\n }\n\n .filter-btn {\n padding: 0.3rem 0.75rem;\n border-radius: 0.4rem;\n border: none;\n background: #0f1721;\n color: var(--text);\n cursor: pointer;\n font-size: .8rem;\n border: 1px solid #19222e;\n }\n\n .filter-btn.active {\n background: var(--accent-2);\n border-color: var(--accent-2);\n }\n</style>\n\n<div class=\"graphs-wrapper\">\n <aside class=\"sidebar\">\n <button class=\"sb-btn\" ng-click=\"gotoTab('Home')\">🏠</button>\n <button class=\"sb-btn\" ng-click=\"gotoTab('Work Orders')\">📋</button>\n <button class=\"sb-btn\" ng-click=\"gotoTab('Alerts')\">⚠️</button>\n <button class=\"sb-btn active\" ng-click=\"gotoTab('Graphs')\">📊</button>\n <button class=\"sb-btn\" ng-click=\"gotoTab('Help')\">❓</button>\n <button class=\"sb-btn\" ng-click=\"gotoTab('Settings')\">⚙️</button>\n <div style=\"margin-top:auto; color:#809ab8; font-size:.7rem;\">OEE V1.0</div>\n </aside>\n\n <main class=\"main\">\n <div class=\"container\">\n <h1 style=\"margin-bottom:0;\">Graphs</h1>\n<!--\n <section class=\"filter-bar\">\n <div class=\"filter-buttons\">\n <button class=\"filter-btn active\" ng-click=\"selectRange('24h')\">24 Hours</button>\n <button class=\"filter-btn\" ng-click=\"selectRange('7d')\">7 Days</button>\n <button class=\"filter-btn\" ng-click=\"selectRange('30d')\">30 Days</button>\n <button class=\"filter-btn\" ng-click=\"selectRange('90d')\">90 Days</button>\n </div>\n </section>\n-->\n\n <section class=\"chart-grid\">\n <article class=\"chart-card\">\n <h2>OEE</h2>\n <div class=\"chart-placeholder\"><canvas id=\"chart-oee\"></canvas></div>\n </article>\n\n <article class=\"chart-card\">\n <h2>Availability</h2>\n <div class=\"chart-placeholder\"><canvas id=\"chart-availability\"></canvas></div>\n </article>\n\n <article class=\"chart-card\">\n <h2>Performance</h2>\n <div class=\"chart-placeholder\"><canvas id=\"chart-performance\"></canvas></div>\n </article>\n\n <article class=\"chart-card\">\n <h2>Quality</h2>\n <div class=\"chart-placeholder\"><canvas id=\"chart-quality\"></canvas></div>\n </article>\n </section>\n </div>\n </main>\n</div>\n\n<script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>\n\n<script>\n(function(scope) {\n scope.gotoTab = function(t) {\n scope.send({ui_control: {tab: t}});\n };\n\n scope.selectedRange = '24h';\n scope._charts = {};\n scope._debounceTimer = null;\n\n // Tab watcher with 300ms debounce\n scope.$watch('msg', function(msg) {\n if (!msg) return;\n\n if (msg.ui_control && msg.ui_control.tab === 'Graphs') {\n if (scope._debounceTimer) clearTimeout(scope._debounceTimer);\n scope._debounceTimer = setTimeout(function() {\n scope.send({\n topic: 'fetch-graph-data',\n payload: { range: scope.selectedRange }\n });\n }, 300);\n }\n\n // Handle graphData format (from Fetch/Format Graph Data)\n if (msg.graphData) {\n createCharts(scope.selectedRange, msg.graphData);\n }\n\n // Handle chartsData format (from Record KPI History)\n if (msg.topic === 'chartsData' && msg.payload) {\n var kpiData = msg.payload;\n\n // Build labels and data arrays from KPI history\n var labels = [];\n var oeeData = [];\n var availData = [];\n var perfData = [];\n var qualData = [];\n\n var oeeHist = kpiData.oee || [];\n oeeHist.forEach(function(point, index) {\n var timestamp = new Date(point.timestamp);\n labels.push(timestamp.toLocaleTimeString());\n\n oeeData.push(point.value || 0);\n availData.push((kpiData.availability[index] && kpiData.availability[index].value) || 0);\n perfData.push((kpiData.performance[index] && kpiData.performance[index].value) || 0);\n qualData.push((kpiData.quality[index] && kpiData.quality[index].value) || 0);\n });\n\n var graphData = {\n labels: labels,\n datasets: [\n { label: 'OEE %', data: oeeData },\n { label: 'Availability %', data: availData },\n { label: 'Performance %', data: perfData },\n { label: 'Quality %', data: qualData }\n ]\n };\n\n createCharts(scope.selectedRange, graphData);\n }\n });\n\n scope.selectRange = function(range) {\n scope.selectedRange = range;\n \n setTimeout(function() {\n document.querySelectorAll('.filter-btn').forEach(function(btn) {\n btn.classList.remove('active');\n var text = btn.textContent.toLowerCase();\n if ((range === '24h' && text.includes('24')) ||\n (range === '7d' && text.includes('7')) ||\n (range === '30d' && text.includes('30')) ||\n (range === '90d' && text.includes('90'))) {\n btn.classList.add('active');\n }\n });\n }, 10);\n\n scope.send({\n topic: 'fetch-graph-data',\n payload: { range: range }\n });\n };\n\n function createCharts(range, data) {\n // Destroy existing to prevent stacking\n if (scope._charts.oee) scope._charts.oee.destroy();\n if (scope._charts.availability) scope._charts.availability.destroy();\n if (scope._charts.performance) scope._charts.performance.destroy();\n if (scope._charts.quality) scope._charts.quality.destroy();\n\n var labels = data.labels || [];\n var datasets = data.datasets || [];\n\n var oeeData = datasets.find(function(d) { return d.label === 'OEE %'; }) || { data: [] };\n var availData = datasets.find(function(d) { return d.label === 'Availability %'; }) || { data: [] };\n var perfData = datasets.find(function(d) { return d.label === 'Performance %'; }) || { data: [] };\n var qualData = datasets.find(function(d) { return d.label === 'Quality %'; }) || { data: [] };\n\n var oeeCtx = document.getElementById('chart-oee');\n if (oeeCtx) {\n scope._charts.oee = new Chart(oeeCtx, {\n type: 'line',\n data: {\n labels: labels,\n datasets: [{\n label: 'OEE %',\n data: oeeData.data,\n borderColor: '#24d06f',\n backgroundColor: 'rgba(36, 208, 111, 0.1)',\n borderWidth: 2,\n fill: true,\n tension: 0.35\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n animation: false\n }\n });\n }\n\n var availCtx = document.getElementById('chart-availability');\n if (availCtx) {\n scope._charts.availability = new Chart(availCtx, {\n type: 'line',\n data: {\n labels: labels,\n datasets: [{\n label: 'Availability %',\n data: availData.data,\n borderColor: '#ff4d4f',\n backgroundColor: 'rgba(255, 77, 79, 0.1)',\n borderWidth: 2,\n fill: true,\n tension: 0.35\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n animation: false\n }\n });\n }\n\n var perfCtx = document.getElementById('chart-performance');\n if (perfCtx) {\n scope._charts.performance = new Chart(perfCtx, {\n type: 'line',\n data: {\n labels: labels,\n datasets: [{\n label: 'Performance %',\n data: perfData.data,\n borderColor: '#2196F3',\n backgroundColor: 'rgba(33, 150, 243, 0.1)',\n borderWidth: 2,\n fill: true,\n tension: 0.35\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n animation: false,\n scales: { y: { min: 0, max: 100 } }\n }\n });\n }\n\n var qualCtx = document.getElementById('chart-quality');\n if (qualCtx) {\n scope._charts.quality = new Chart(qualCtx, {\n type: 'line',\n data: {\n labels: labels,\n datasets: [{\n label: 'Quality %',\n data: qualData.data,\n borderColor: '#ffd100',\n backgroundColor: 'rgba(255, 209, 0, 0.1)',\n borderWidth: 2,\n fill: true,\n tension: 0.35\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n animation: false,\n scales: { y: { min: 0, max: 100 } }\n }\n });\n }\n }\n\n})(scope);\n\n\n // Initial load and tab refresh for Graphs\n scope.refreshGraphData = function() {\n // Get current filter selection or default to 24h\n var currentFilter = scope.selectedRange || '24h';\n scope.send({\n topic: 'fetch-graph-data',\n action: 'fetch-graph-data',\n payload: { range: currentFilter }\n });\n };\n\n // Load data immediately on initialization\n setTimeout(function() {\n scope.refreshGraphData();\n }, 500);\n\n // Set up tab refresh interval (every 5 seconds when Graphs tab is visible)\n scope.graphsRefreshInterval = setInterval(function() {\n // Check if Graphs tab is visible\n var graphsElement = document.querySelector('.graphs-wrapper');\n if (graphsElement && graphsElement.offsetParent !== null) {\n scope.refreshGraphData();\n }\n }, 5000);\n\n // Cleanup on destroy\n scope.$on('$destroy', function() {\n if (scope.graphsRefreshInterval) {\n clearInterval(scope.graphsRefreshInterval);\n }\n });\n\n</script>",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 390,
"y": 400,
"wires": [
[
"a7d58e15929b3d8c"
]
]
},
{
"id": "f4a5b6c7d8e9f0a1",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "e4f5a6b7c8d9e0f1",
"name": "Help Template",
"order": 0,
"width": "25",
"height": "25",
"format": "<style>\n@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');\n\n/* SCALE: align 100% zoom with prior 125% appearance */\n:root {\n font-size: 125%;\n --bg-0: #0c1117;\n --bg-1: #131a23;\n --panel: #151e2b;\n --panel-hi: #1a2433;\n --text: #ecf3ff;\n --muted: #96a5b7;\n --accent: #2ea3ff;\n --accent-2: #1f8cf3;\n --good: #24d06f;\n --warn: #ffd100;\n --bad: #ff4d4f;\n --radius: 0.75rem;\n --shadow: 0 0.5rem 1rem rgba(0, 0, 0, .35), inset 0 0.0625rem 0 rgba(255, 255, 255, .05);\n --sidebar-width: 3.75rem;\n --sidebar-gap: clamp(0.75rem, 1.2vh, 1rem);\n --content-max: 70rem;\n --content-pad: clamp(1rem, 1.2vw, 1.4rem);\n --section-gap: clamp(0.75rem, 1vw, 1rem);\n --card-pad: clamp(1rem, 1.1vw, 1.25rem);\n --fs-page-title: clamp(1.2rem, 1vw + 0.35rem, 1.7rem);\n --fs-section-title: clamp(1rem, 0.9vw + 0.3rem, 1.35rem);\n --fs-body: clamp(0.85rem, 0.7vw + 0.3rem, 1.05rem);\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml,\nbody,\n#oee {\n width: 100vw;\n height: 100vh;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n overflow-x: hidden;\n background: var(--bg-0);\n color: var(--text);\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n}\n\nbody {\n overflow: hidden;\n}\n\nbody > md-content,\nbody > md-content > md-content,\n.nr-dashboard-template,\n.nr-dashboard-template md-content,\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-content {\n background: transparent !important;\n height: 100%;\n overflow: hidden;\n}\n\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-card,\n.nr-dashboard-cardpanel md-card-content {\n background: transparent !important;\n box-shadow: none !important;\n padding: 0 !important;\n}\n\n/* LAYOUT: lock sidebar + content grid across tabs */\n#oee {\n position: fixed;\n inset: 0;\n display: flex;\n overflow: hidden;\n}\n\n.sidebar {\n width: var(--sidebar-width);\n background: #0b1119;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: space-between;\n padding: clamp(0.625rem, 1.4vh, 0.9rem) clamp(0.5rem, 0.8vw, 0.75rem);\n}\n\n.side-top {\n display: flex;\n flex-direction: column;\n gap: var(--sidebar-gap);\n align-items: center;\n}\n\n.sb-btn {\n width: 2.75rem;\n height: 2.75rem;\n border-radius: 0.75rem;\n background: #0f1721;\n border: 1px solid #19222e;\n display: grid;\n place-items: center;\n color: #8fb3d9;\n cursor: pointer;\n transition: 0.16s box-shadow, 0.16s transform, 0.16s border-color;\n}\n\n.sb-btn.active {\n border-color: #2fd289;\n box-shadow: 0 0 0 0.125rem rgba(36, 208, 111, .25), 0 0.625rem 1.125rem rgba(36, 208, 111, .28);\n color: #2fd289;\n}\n\n.sb-ico {\n font-size: clamp(1.1rem, 1.2vw, 1.25rem);\n line-height: 1;\n}\n\n.sb-foot {\n font-size: clamp(0.6rem, 0.6vw, 0.7rem);\n color: #6c7b8d;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n}\n\n.main {\n flex: 1;\n overflow: auto;\n padding: var(--content-pad);\n}\n\n.container {\n max-width: var(--content-max);\n margin: 0 auto;\n display: flex;\n flex-direction: column;\n gap: var(--section-gap);\n}\n\n.card {\n background: linear-gradient(160deg, var(--panel) 0%, var(--panel-hi) 100%);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n}\n\n/* HEADER: maintain title styling */\n.page-heading {\n display: flex;\n align-items: center;\n padding: 0 0.25rem;\n}\n\n.page-title {\n margin: 0;\n font-size: var(--fs-page-title);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n/* HELP CARDS: keep text hierarchy + spacing clean */\n.help-card {\n display: flex;\n flex-direction: column;\n gap: clamp(0.9rem, 1vw, 1.1rem);\n padding: var(--card-pad);\n}\n\n.help-title {\n margin: 0;\n font-size: var(--fs-section-title);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n.help-body {\n margin: 0;\n line-height: 1.65;\n color: var(--muted);\n font-size: var(--fs-body);\n font-weight: 500;\n}\n\n\n/* Override Node-RED Dashboard's gray backgrounds on text elements */\n.nr-dashboard-template p,\n.nr-dashboard-template h1,\n.nr-dashboard-template h2,\n.nr-dashboard-template h3,\n.nr-dashboard-template h4,\n.nr-dashboard-template label {\n background-color: transparent !important;\n}\n</style>\n<div id=\"oee\">\n <aside class=\"sidebar\">\n <div class=\"side-top\">\n <button class=\"sb-btn\" data-label=\"Home\" ng-click=\"gotoTab('Home')\"><span class=\"sb-ico\">🏠</span></button>\n <button class=\"sb-btn\" data-label=\"Work Orders\" ng-click=\"gotoTab('Work Orders')\"><span class=\"sb-ico\">📋</span></button>\n <button class=\"sb-btn\" data-label=\"Alerts\" ng-click=\"gotoTab('Alerts')\"><span class=\"sb-ico\">⚠️</span></button>\n <button class=\"sb-btn\" data-label=\"Graphs\" ng-click=\"gotoTab('Graphs')\"><span class=\"sb-ico\">📊</span></button>\n <button class=\"sb-btn active\" data-label=\"Help\" ng-click=\"gotoTab('Help')\"><span class=\"sb-ico\">❓</span></button>\n <button class=\"sb-btn\" data-label=\"Settings\" ng-click=\"gotoTab('Settings')\"><span class=\"sb-ico\">⚙️</span></button>\n </div>\n <div class=\"sb-foot\">OEE V1.0</div>\n </aside>\n\n <main class=\"main\">\n <div class=\"container\">\n <section class=\"page-heading\">\n <h1 class=\"page-title\">Help</h1>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">About this Dashboard</h2>\n <p class=\"help-body\">This interface tracks Overall Equipment Effectiveness metrics, production progress, and incident logging for injection molding operations. Navigate between tabs using the sidebar to access work orders, real-time monitoring, performance graphs, incident reporting, and machine configuration.</p>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">Getting Started with a Work Order</h2>\n <p class=\"help-body\">Go to the Work Orders tab and upload an Excel file with your work orders, or select from existing orders in the table. Click Load to activate a work order, then navigate to the Home tab where you'll see the current work order details. Before starting production, configure your mold settings in the Settings tab by selecting a mold preset or entering cavity counts manually.</p>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">Running Production</h2>\n <p class=\"help-body\">On the Home tab, press the START button to begin production. The system tracks cycle counts, good parts, scrap, and progress toward your target quantity. Press STOP to pause production. Monitor real-time KPIs including OEE, Availability, Performance, and Quality metrics displayed on the dashboard.</p>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">Logging Incidents</h2>\n <p class=\"help-body\">Use the Alerts tab to record production incidents. Quick-log common issues with preset buttons like Material Out, Machine Stopped, or Emergency Stop. For detailed logging, select an alert type from the dropdown, add notes, and submit. All incidents are timestamped and contribute to availability and OEE calculations.</p>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">Configuring Molds</h2>\n <p class=\"help-body\">In the Settings tab, use the Mold Presets section to search for your mold by manufacturer and name. Select a preset to automatically load cavity counts, or manually adjust the Mold Configuration fields below. If your mold isn't available, use the Add Mold button in the Integrations section to create a new preset with manufacturer, name, and cavity details.</p>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">Viewing Performance Data</h2>\n <p class=\"help-body\">The Graphs tab displays historical OEE trends broken down by Availability, Performance, and Quality. Use these charts to identify patterns, track improvements, and diagnose recurring issues affecting your production efficiency.</p>\n </section>\n </div>\n </main>\n</div>\n\n<script>\n(function(scope) {\n scope.gotoTab = function(tabName) {\n scope.send({ ui_control: { tab: tabName } });\n };\n})(scope);\n\n(function ensureNoMaterialTint(){\n const root = document.getElementById('oee');\n if (!root) return;\n\n const targets = root.querySelectorAll([\n '.md-toolbar',\n 'md-toolbar',\n '.md-subheader',\n 'md-subheader',\n '.md-toolbar-tools',\n '.md-card-title'\n ].join(','));\n\n targets.forEach(el => {\n if (el && el.style) {\n if (el.style.background) {\n el.style.background = 'transparent';\n }\n if (el.style.backgroundColor) {\n el.style.backgroundColor = 'transparent';\n }\n }\n if (el && el.classList) {\n el.classList.remove(\n 'md-whiteframe-1dp','md-whiteframe-2dp','md-whiteframe-3dp',\n 'md-whiteframe-z1','md-whiteframe-z2','md-whiteframe-z3'\n );\n }\n });\n\n if (!document.getElementById('oee-theme-sentinel')) {\n const style = document.createElement('style');\n style.id = 'oee-theme-sentinel';\n style.textContent = `\n #oee .md-toolbar::before, #oee .md-toolbar::after,\n #oee .md-subheader::before, #oee .md-subheader::after,\n #oee md-toolbar::before, #oee md-toolbar::after,\n #oee md-subheader::before, #oee md-subheader::after {\n display: none !important;\n content: none !important;\n }\n `;\n document.head.appendChild(style);\n }\n})();\n\n\n// optional render trigger\nscope.$evalAsync(function() {});\n</script>\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 380,
"y": 440,
"wires": [
[
"a7d58e15929b3d8c"
]
]
},
{
"id": "f5a6b7c8d9e0f1a2",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "e5f6a7b8c9d0e1f2",
"name": "Settings Template",
"order": 0,
"width": "25",
"height": "25",
"format": "<style>\n@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');\n\n/* SCALE: align 100% zoom with prior 125% appearance */\n:root {\n font-size: 125%;\n --bg-0: #0c1117;\n --bg-1: #131a23;\n --panel: #151e2b;\n --panel-hi: #1a2433;\n --text: #ecf3ff;\n --muted: #96a5b7;\n --accent: #2ea3ff;\n --accent-2: #1f8cf3;\n --good: #24d06f;\n --warn: #ffd100;\n --bad: #ff4d4f;\n --radius: 0.75rem;\n --shadow: 0 0.5rem 1rem rgba(0, 0, 0, .35), inset 0 0.0625rem 0 rgba(255, 255, 255, .05);\n --sidebar-width: 3.75rem;\n --sidebar-gap: clamp(0.75rem, 1.2vh, 1rem);\n --content-max: 70rem;\n --content-pad: clamp(1rem, 1.2vw, 1.4rem);\n --section-gap: clamp(0.75rem, 1vw, 1rem);\n --card-pad: clamp(1rem, 1.1vw, 1.25rem);\n --fs-page-title: clamp(1.2rem, 1vw + 0.35rem, 1.7rem);\n --fs-section-title: clamp(1rem, 0.9vw + 0.3rem, 1.35rem);\n --fs-body: clamp(0.8rem, 0.7vw + 0.28rem, 1rem);\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml,\nbody,\n#oee {\n width: 100vw;\n height: 100vh;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n overflow-x: hidden;\n background: var(--bg-0);\n color: var(--text);\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n}\n\nbody {\n overflow: hidden;\n}\n\nbody > md-content,\nbody > md-content > md-content,\n.nr-dashboard-template,\n.nr-dashboard-template md-content,\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-content {\n background: transparent !important;\n height: 100%;\n overflow: hidden;\n}\n\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-card,\n.nr-dashboard-cardpanel md-card-content {\n background: transparent !important;\n box-shadow: none !important;\n padding: 0 !important;\n}\n\n/* LAYOUT: lock sidebar + content grid across tabs */\n#oee {\n position: fixed;\n inset: 0;\n display: flex;\n overflow: hidden;\n}\n\n.sidebar {\n width: var(--sidebar-width);\n background: #0b1119;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: space-between;\n padding: clamp(0.625rem, 1.4vh, 0.9rem) clamp(0.5rem, 0.8vw, 0.75rem);\n}\n\n.side-top {\n display: flex;\n flex-direction: column;\n gap: var(--sidebar-gap);\n align-items: center;\n}\n\n.sb-btn {\n width: 2.75rem;\n height: 2.75rem;\n border-radius: 0.75rem;\n background: #0f1721;\n border: 1px solid #19222e;\n display: grid;\n place-items: center;\n color: #8fb3d9;\n cursor: pointer;\n transition: 0.16s box-shadow, 0.16s transform, 0.16s border-color;\n}\n\n.sb-btn.active {\n border-color: #2fd289;\n box-shadow: 0 0 0 0.125rem rgba(36, 208, 111, .25), 0 0.625rem 1.125rem rgba(36, 208, 111, .28);\n color: #2fd289;\n}\n\n.sb-ico {\n font-size: clamp(1.1rem, 1.2vw, 1.25rem);\n line-height: 1;\n}\n\n.sb-foot {\n font-size: clamp(0.6rem, 0.6vw, 0.7rem);\n color: #6c7b8d;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n}\n\n.main {\n flex: 1;\n overflow: auto;\n padding: var(--content-pad);\n}\n\n.container {\n max-width: var(--content-max);\n margin: 0 auto;\n display: flex;\n flex-direction: column;\n gap: var(--section-gap);\n}\n\n.card {\n background: linear-gradient(160deg, var(--panel) 0%, var(--panel-hi) 100%);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n}\n\n/* HEADER: maintain title styling */\n.page-heading {\n display: flex;\n align-items: center;\n padding: 0 0.25rem;\n}\n\n.page-title {\n margin: 0;\n font-size: var(--fs-page-title);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n.section-title {\n margin: 0;\n font-size: var(--fs-section-title);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n/* SETTINGS CARDS: clean grid + balanced inputs */\n.mold-card,\n.integrations-card {\n display: flex;\n flex-direction: column;\n gap: clamp(0.9rem, 1vw, 1.1rem);\n padding: var(--card-pad);\n}\n\n.form-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(13.75rem, 1fr));\n gap: clamp(0.75rem, 1vw, 1rem);\n}\n\n.form-field {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.form-field label {\n font-size: var(--fs-body);\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n.form-field input,\n.form-field select {\n background: #121c28;\n border: 1px solid #243244;\n border-radius: var(--radius);\n color: var(--text);\n padding: 0.75rem 0.9rem;\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n font-size: var(--fs-body);\n}\n\n.integrations-split {\n display: flex;\n gap: clamp(1rem, 1.5vw, 2rem);\n align-items: flex-start;\n}\n\n.integrations-left {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.integrations-right {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n align-items: flex-start;\n}\n\n.add-mold-info {\n margin: 0;\n font-size: var(--fs-body);\n color: var(--muted);\n line-height: 1.5;\n}\n\n.integrations-button {\n width: fit-content;\n background: #121c28;\n border: 1px dashed rgba(150, 165, 183, .45);\n border-radius: var(--radius);\n color: rgba(150, 165, 183, .8);\n padding: 0.75rem 1.5rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n font-weight: 700;\n cursor: not-allowed;\n}\n.preset-add-form {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.preset-add-actions {\n display: flex;\n gap: 0.75rem;\n justify-content: flex-end;\n}\n\n/* PRESET SEARCH STYLES */\n.preset-card {\n display: flex;\n flex-direction: column;\n gap: clamp(0.9rem, 1vw, 1.1rem);\n padding: var(--card-pad);\n}\n\n.preset-filters {\n display: grid;\n grid-template-columns: 2fr 1fr 1fr;\n gap: clamp(0.75rem, 1vw, 1rem);\n}\n\n.preset-results {\n max-height: 20rem;\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.preset-item {\n background: #121c28;\n border: 1px solid #243244;\n border-radius: var(--radius);\n padding: 1rem;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 1rem;\n}\n\n.preset-info {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.preset-name {\n font-size: var(--fs-body);\n font-weight: 700;\n color: var(--text);\n}\n\n.preset-details {\n display: flex;\n gap: 1rem;\n font-size: clamp(0.75rem, 0.7vw + 0.25rem, 0.9rem);\n color: var(--muted);\n}\n\n.preset-badge {\n display: inline-block;\n background: rgba(46, 163, 255, 0.2);\n color: var(--accent);\n padding: 0.25rem 0.5rem;\n border-radius: 0.375rem;\n font-size: clamp(0.7rem, 0.6vw + 0.2rem, 0.85rem);\n font-weight: 600;\n}\n\n.preset-select-btn {\n background: linear-gradient(180deg, #32ff7e 0%, #2fd289 100%);\n box-shadow: 0 0 1.25rem rgba(36, 208, 111, .5), inset 0 0.0625rem 0 rgba(255, 255, 255, .1);\n border: none;\n border-radius: var(--radius);\n color: var(--text);\n padding: 0.6rem 1.2rem;\n font-weight: 700;\n font-size: var(--fs-body);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter;\n}\n\n.preset-select-btn:hover {\n transform: translateY(-0.05rem);\n filter: brightness(1.08);\n}\n\n.preset-select-btn:active {\n transform: none;\n filter: brightness(0.96);\n}\n\n.preset-empty {\n text-align: center;\n color: var(--muted);\n padding: 2rem;\n font-size: var(--fs-body);\n}\n\n\n/* Override Node-RED Dashboard's gray backgrounds on text elements */\n.nr-dashboard-template p,\n.nr-dashboard-template h1,\n.nr-dashboard-template h2,\n.nr-dashboard-template h3,\n.nr-dashboard-template h4,\n.nr-dashboard-template label {\n background-color: transparent !important;\n}\n</style>\n<div id=\"oee\">\n <aside class=\"sidebar\">\n <div class=\"side-top\">\n <button class=\"sb-btn\" data-label=\"Home\" ng-click=\"gotoTab('Home')\"><span class=\"sb-ico\">🏠</span></button>\n <button class=\"sb-btn\" data-label=\"Work Orders\" ng-click=\"gotoTab('Work Orders')\"><span class=\"sb-ico\">📋</span></button>\n <button class=\"sb-btn\" data-label=\"Alerts\" ng-click=\"gotoTab('Alerts')\"><span class=\"sb-ico\">⚠️</span></button>\n <button class=\"sb-btn\" data-label=\"Graphs\" ng-click=\"gotoTab('Graphs')\"><span class=\"sb-ico\">📊</span></button>\n <button class=\"sb-btn\" data-label=\"Help\" ng-click=\"gotoTab('Help')\"><span class=\"sb-ico\">❓</span></button>\n <button class=\"sb-btn active\" data-label=\"Settings\" ng-click=\"gotoTab('Settings')\"><span class=\"sb-ico\">⚙️</span></button>\n </div>\n <div class=\"sb-foot\">OEE V1.0</div>\n </aside>\n\n <main class=\"main\">\n <div class=\"container\">\n <section class=\"page-heading\">\n <h1 class=\"page-title\">Settings</h1>\n </section>\n\n <section class=\"card preset-card\">\n <h2 class=\"section-title\">Mold Presets</h2>\n <div class=\"preset-filters\">\n <div class=\"form-field\">\n <label for=\"preset-manufacturer\">Manufacturer</label>\n <select id=\"preset-manufacturer\"\n ng-model=\"selectedManufacturer\"\n ng-change=\"onManufacturerChange()\">\n <option value=\"\">Select manufacturer...</option>\n <option ng-repeat=\"mfg in manufacturers\" value=\"{{mfg}}\">{{mfg}}</option>\n </select>\n </div>\n <div class=\"form-field\">\n <label for=\"preset-mold\">Mold</label>\n <select id=\"preset-mold\"\n ng-model=\"selectedMold\"\n ng-options=\"preset as preset.mold_name for preset in moldsForManufacturer | orderBy:'mold_name'\"\n ng-change=\"onMoldChange()\">\n <option value=\"\">Select mold...</option>\n </select>\n </div>\n </div>\n \n <div class=\"preset-results\" id=\"preset-results\">\n <!-- Default state: simplified -->\n <div class=\"preset-empty\" ng-if=\"!showAddMoldForm\">\n <p>Select a manufacturer and mold from the dropdowns above.</p>\n </div>\n\n <!-- Add Mold form -->\n <div class=\"preset-add-form\" ng-if=\"showAddMoldForm\">\n <div class=\"form-grid\">\n <div class=\"form-field\">\n <label for=\"new-mold-manufacturer\">Manufacturer</label>\n <input id=\"new-mold-manufacturer\"\n type=\"text\"\n ng-model=\"newMold.manufacturer\"\n placeholder=\"Type manufacturer...\" />\n </div>\n <div class=\"form-field\">\n <label for=\"new-mold-name\">Mold Name</label>\n <input id=\"new-mold-name\"\n type=\"text\"\n ng-model=\"newMold.mold_name\"\n placeholder=\"Type mold name...\" />\n </div>\n <div class=\"form-field\">\n <label for=\"new-mold-cavities\">Mold Cavities</label>\n <input id=\"new-mold-cavities\"\n type=\"text\"\n ng-model=\"newMold.cavities\"\n placeholder=\"Type Theoretical Cavities...\" />\n </div>\n <div class=\"form-field\">\n <label for=\"new-mold-active\">Active Cavities</label>\n <input id=\"new-mold-active\"\n type=\"text\"\n ng-model=\"newMold.active\"\n placeholder=\"Type Active Cavities...\" />\n </div>\n </div>\n <div class=\"preset-add-actions\">\n <button class=\"preset-select-btn\"\n type=\"button\"\n ng-click=\"saveNewMold()\"\n ng-disabled=\"isSavingMold\">\n {{ isSavingMold ? 'Saving...' : 'Save' }}\n </button>\n <button class=\"preset-select-btn\"\n type=\"button\"\n ng-click=\"cancelAddMold()\"\n ng-disabled=\"isSavingMold\">\n Cancel\n </button>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"card mold-card\">\n <h2 class=\"form-field\">Mold Configuration</h2>\n <div class=\"form-grid\">\n <div class=\"form-field\">\n <label for=\"mold-active\">Mold Cavities</label>\n <input id=\"mold-total\" type=\"number\" ng-model=\"moldTotal\" ng-change=\"updateMoldSettings()\" />\n </div>\n <div class=\"form-field\">\n <label for=\"mold-active\">Active Cavities</label>\n <input id=\"mold-active\" type=\"number\" ng-model=\"moldActive\" ng-change=\"updateMoldSettings()\" />\n </div>\n </div>\n </section>\n\n <section class=\"card integrations-card\">\n <h2 class=\"section-title\">Integrations</h2>\n <div class=\"integrations-split\">\n <div class=\"integrations-left\">\n <button class=\"integrations-button\" type=\"button\" disabled>Connect to ERP</button>\n </div>\n <div class=\"integrations-right\">\n <p class=\"add-mold-info\">Can't find the mold you're looking for?</p>\n <button class=\"preset-select-btn\" type=\"button\" ng-click=\"openAddMold()\">Add Mold</button>\n </div>\n </div>\n </section>\n <section class=\"card mold-card\">\n <h2 class=\"section-title\">Production Schedule</h2>\n \n <!-- Shifts -->\n <div class=\"form-grid\">\n <div class=\"form-field\" style=\"grid-column: span 2;\">\n <label>Shifts (Max 3)</label>\n </div>\n </div>\n \n <div ng-repeat=\"shift in shifts track by $index\" class=\"form-grid\" style=\"margin-bottom: 0.5rem;\">\n <div class=\"form-field\">\n <label>Shift {{$index + 1}} Start</label>\n <input type=\"time\" ng-model=\"shift.start\" ng-change=\"updateShiftConfig()\" />\n </div>\n <div class=\"form-field\">\n <label>Shift {{$index + 1}} End</label>\n <input type=\"time\" ng-model=\"shift.end\" ng-change=\"updateShiftConfig()\" />\n </div>\n <div class=\"form-field\" style=\"align-self: end;\" ng-if=\"shifts.length > 1\">\n <button class=\"integrations-button\" style=\"cursor: pointer; border-style: solid; color: var(--bad);\" ng-click=\"removeShift($index)\">Remove</button>\n </div>\n </div>\n \n <button class=\"preset-select-btn\" style=\"margin-top: 0.75rem;\" ng-click=\"addShift()\" ng-disabled=\"shifts.length >= 3\">\n + Add Shift\n </button>\n \n <!-- Compensations -->\n <div class=\"form-grid\" style=\"margin-top: 1rem;\">\n <div class=\"form-field\">\n <label>Shift Change Compensation (min)</label>\n <input type=\"number\" ng-model=\"shiftChangeComp\" ng-change=\"updateShiftConfig()\" min=\"0\" max=\"30\" />\n </div>\n <div class=\"form-field\">\n <label>Lunch Break (min)</label>\n <input type=\"number\" ng-model=\"lunchBreak\" ng-change=\"updateShiftConfig()\" min=\"0\" max=\"120\" />\n </div>\n </div>\n </section>\n <div style=\"display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center;\">\n <button class=\"preset-select-btn\" ng-click=\"saveAllSettings()\" ng-disabled=\"isSaving\">\n {{ isSaving ? 'Saving...' : 'Save Settings' }}\n </button>\n <span ng-if=\"saveStatus\" style=\"color: var(--good); font-size: 0.85rem;\">\n {{ saveStatus }}\n </span>\n </div>\n \n <section class=\"card mold-card\">\n <h2 class=\"section-title\">OEE Thresholds</h2>\n <div class=\"form-grid\">\n <div class=\"form-field\">\n <label>Stoppage Threshold Multiplier</label>\n <input type=\"number\" ng-model=\"thresholdMultiplier\" ng-change=\"updateThresholdConfig()\" min=\"1.1\" max=\"5\" step=\"0.1\" />\n <p style=\"font-size: 0.75rem; color: var(--muted); margin-top: 0.25rem;\">\n Gap > (Cycle Time × {{thresholdMultiplier || 1.5}}) = Stoppage\n </p>\n </div>\n <div class=\"form-field\">\n <label>OEE Alert Threshold (%)</label>\n <input type=\"number\" ng-model=\"oeeThreshold\" ng-change=\"updateThresholdConfig()\" min=\"50\" max=\"100\" />\n </div>\n </div>\n </section>\n </div>\n </main>\n</div>\n\n<script>\n (function(scope) {\n // Initialize state\n scope.moldTotal = 0;\n scope.moldActive = 0;\n scope.selectedManufacturer = '';\n scope.moldsForManufacturer = [];\n scope.selectedMold = null;\n scope.manufacturers = [];\n scope.showAddMoldForm = false;\n scope.newMold = { manufacturer: '', mold_name: '', cavities: '', active: '' };\n scope.isSavingMold = false;\n \n // Debounce timers\n scope._moldSettingsDebounceTimer = null;\n scope._saveMoldTimeout = null;\n \n // Track processed messages by topic + timestamp to prevent loops\n scope._processedMessages = {};\n \n // Clean old processed messages every 10 seconds\n setInterval(function() {\n const now = Date.now();\n Object.keys(scope._processedMessages).forEach(function(key) {\n if (now - scope._processedMessages[key] > 10000) {\n delete scope._processedMessages[key];\n }\n });\n }, 10000);\n \n // Navigation\n scope.gotoTab = function(tabName) {\n scope.isSavingMold = false;\n scope.send({ ui_control: { tab: tabName } });\n };\n \n // Load manufacturers on init\n scope.loadManufacturers = function() {\n console.log('Loading manufacturers...');\n scope.send({ topic: 'getManufacturers' });\n };\n \n // When manufacturer changes\n scope.onManufacturerChange = function() {\n scope.selectedMold = null;\n scope.moldsForManufacturer = [];\n \n const manufacturer = scope.selectedManufacturer || '';\n if (!manufacturer) {\n return;\n }\n \n console.log('Loading molds for:', manufacturer);\n scope.send({\n topic: 'getMoldsByManufacturer',\n payload: { manufacturer: manufacturer }\n });\n };\n \n // When mold changes\n scope.onMoldChange = function() {\n if (!scope.selectedMold) return;\n scope.selectPreset(scope.selectedMold);\n };\n \n // Select a preset\n scope.selectPreset = function(preset) {\n scope.send({\n topic: 'selectMoldPreset',\n payload: preset\n });\n \n scope.moldTotal = preset.theoretical_cavities;\n scope.moldActive = preset.functional_cavities;\n };\n \n // Update mold settings with debounce\n scope.updateMoldSettings = function() {\n if (scope._moldSettingsDebounceTimer) {\n clearTimeout(scope._moldSettingsDebounceTimer);\n }\n \n scope._moldSettingsDebounceTimer = setTimeout(function() {\n const total = Number(scope.moldTotal || 0);\n const active = Number(scope.moldActive || 0);\n \n if (total === (scope._lastMoldTotal || 0) && active === (scope._lastMoldActive || 0)) {\n return;\n }\n \n scope._lastMoldTotal = total;\n scope._lastMoldActive = active;\n \n scope.send({\n topic: 'moldSettings',\n payload: { total, active }\n });\n }, 500);\n };\n \n // Add mold form\n scope.openAddMold = function() {\n scope.showAddMoldForm = true;\n scope.newMold = {\n manufacturer: scope.selectedManufacturer || '',\n mold_name: '',\n cavities: '',\n active: ''\n };\n };\n \n scope.cancelAddMold = function() {\n scope.showAddMoldForm = false;\n scope.isSavingMold = false;\n scope.newMold = { manufacturer: '', mold_name: '', cavities: '', active: '' };\n };\n \n // Save new mold with proper debouncing\n scope.saveNewMold = function() {\n if (scope.isSavingMold) {\n console.log('Already saving, ignoring click');\n return;\n }\n \n const manufacturer = (scope.newMold.manufacturer || '').trim();\n const moldName = (scope.newMold.mold_name || '').trim();\n const theoretical = (scope.newMold.cavities || '').trim();\n const active = (scope.newMold.active || '').trim();\n \n if (!manufacturer || !moldName || !theoretical || !active) {\n alert('Please enter all values.');\n return;\n }\n \n scope.isSavingMold = true;\n \n // Clear any pending timeout\n if (scope._saveMoldTimeout) {\n clearTimeout(scope._saveMoldTimeout);\n }\n \n // Send after 100ms to consolidate rapid clicks\n scope._saveMoldTimeout = setTimeout(function() {\n console.log('Sending addMoldPreset request');\n scope.send({\n topic: 'addMoldPreset',\n payload: {\n manufacturer: manufacturer,\n mold_name: moldName,\n theoretical: theoretical,\n active: active\n }\n });\n scope._saveMoldTimeout = null;\n }, 100);\n };\n \n // Watch for incoming messages - FIXED\n // Watch for incoming messages\nscope.$watch('msg', function(newMsg, oldMsg) {\n if (!newMsg || !newMsg.topic) return;\n \n // Create unique key for this message\n const msgKey = newMsg.topic + '_' + (newMsg.payload ? JSON.stringify(newMsg.payload).substring(0, 100) : '');\n const now = Date.now();\n \n // Check if we've processed this message recently (within 1 second)\n if (scope._processedMessages[msgKey] && (now - scope._processedMessages[msgKey]) < 1000) {\n console.log('Duplicate message ignored:', newMsg.topic);\n return;\n }\n \n // Mark as processed\n scope._processedMessages[msgKey] = now;\n \n console.log('Processing message:', newMsg.topic, newMsg.payload);\n \n // =============================================\n // MOLD PRESET HANDLERS\n // =============================================\n if (newMsg.topic === 'manufacturersList') {\n scope.manufacturers = Array.isArray(newMsg.payload) ? newMsg.payload : [];\n console.log('Manufacturers loaded:', scope.manufacturers.length);\n return;\n }\n \n if (newMsg.topic === 'moldPresetsList') {\n scope.moldsForManufacturer = Array.isArray(newMsg.payload) ? newMsg.payload : [];\n console.log('Molds loaded:', scope.moldsForManufacturer.length);\n return;\n }\n \n if (newMsg.topic === 'moldPresetSelected') {\n const data = newMsg.payload || {};\n scope.moldTotal = data.total || 0;\n scope.moldActive = data.active || 0;\n console.log('Preset selected:', data);\n return;\n }\n \n if (newMsg.topic === 'addMoldResult') {\n console.log('Mold added successfully');\n scope.isSavingMold = false;\n scope.showAddMoldForm = false;\n scope.newMold = { manufacturer: '', mold_name: '', cavities: '', active: '' };\n \n // Refresh the molds list if manufacturer is selected\n if (scope.selectedManufacturer) {\n setTimeout(function() {\n scope.onManufacturerChange();\n }, 500);\n }\n return;\n }\n \n // =============================================\n // SHIFT CONFIG HANDLER - THIS IS THE FIX\n // =============================================\n if (newMsg.topic === 'shiftConfigData') {\n var config = newMsg.payload || {};\n \n // Populate shifts - ensure they're simple strings\n scope.shifts = (config.shifts || [{ start: '08:00', end: '16:00' }]).map(function(s) {\n return {\n start: s.start || '08:00',\n end: s.end || '16:00'\n };\n });\n \n scope.shiftChangeComp = config.shiftChangeCompensation || 10;\n scope.lunchBreak = config.lunchBreakMinutes || 30;\n scope.thresholdMultiplier = config.thresholdMultiplier || 1.5;\n scope.oeeThreshold = config.oeeAlertThreshold || 90;\n \n console.log('[SETTINGS] Loaded config:', scope.shifts.length, 'shifts');\n console.log('[SETTINGS] Shifts:', JSON.stringify(scope.shifts));\n return;\n }\n});\n \n // Initialize by loading manufacturers\n console.log('Initializing Settings page...');\n scope.loadManufacturers();\n\n // Initialize shift config\nscope.shifts = scope.shifts || [{ start: '08:00', end: '16:00' }];\nscope.shiftChangeComp = scope.shiftChangeComp || 10;\nscope.lunchBreak = scope.lunchBreak || 30;\nscope.thresholdMultiplier = scope.thresholdMultiplier || 1.5;\nscope.oeeThreshold = scope.oeeThreshold || 90;\n\n// Load saved config on init\nscope.loadShiftConfig = function() {\n scope.send({ topic: 'getShiftConfig' });\n};\n\n// Add shift\nscope.addShift = function() {\n if (scope.shifts.length < 3) {\n scope.shifts.push({ start: '16:00', end: '00:00' });\n scope.updateShiftConfig();\n }\n};\n\n// Remove shift\nscope.removeShift = function(index) {\n if (scope.shifts.length > 1) {\n scope.shifts.splice(index, 1);\n scope.updateShiftConfig();\n }\n};\n\n// Save shift config\nscope.updateShiftConfig = function() {\n if (scope._shiftDebounce) clearTimeout(scope._shiftDebounce);\n \n scope._shiftDebounce = setTimeout(function() {\n // Convert Date objects to \"HH:MM\" strings if needed\n const cleanShifts = scope.shifts.map(function(shift) {\n let startStr = shift.start;\n let endStr = shift.end;\n \n // If it's a Date object, extract time\n if (shift.start instanceof Date) {\n startStr = shift.start.toTimeString().slice(0, 5); // \"HH:MM\"\n } else if (typeof shift.start === 'string' && shift.start.includes('T')) {\n // ISO string - extract time portion\n startStr = shift.start.split('T')[1].slice(0, 5);\n }\n \n if (shift.end instanceof Date) {\n endStr = shift.end.toTimeString().slice(0, 5);\n } else if (typeof shift.end === 'string' && shift.end.includes('T')) {\n endStr = shift.end.split('T')[1].slice(0, 5);\n }\n \n return { start: startStr, end: endStr };\n });\n \n scope.send({\n topic: 'saveShiftConfig',\n payload: {\n shifts: cleanShifts,\n shiftChangeCompensation: Number(scope.shiftChangeComp) || 10,\n lunchBreakMinutes: Number(scope.lunchBreak) || 30\n }\n });\n }, 500);\n};\n\nscope.isSaving = false;\nscope.saveStatus = '';\n\nscope.saveAllSettings = function() {\n scope.isSaving = true;\n scope.saveStatus = '';\n \n // Clean shifts (handle Date objects)\n const cleanShifts = scope.shifts.map(function(shift) {\n let startStr = shift.start;\n let endStr = shift.end;\n \n if (shift.start instanceof Date) {\n startStr = shift.start.toTimeString().slice(0, 5);\n } else if (typeof shift.start === 'string' && shift.start.includes('T')) {\n startStr = shift.start.split('T')[1].slice(0, 5);\n }\n \n if (shift.end instanceof Date) {\n endStr = shift.end.toTimeString().slice(0, 5);\n } else if (typeof shift.end === 'string' && shift.end.includes('T')) {\n endStr = shift.end.split('T')[1].slice(0, 5);\n }\n \n return { start: startStr, end: endStr };\n });\n \n scope.send({\n topic: 'saveAllSettings',\n payload: {\n shifts: cleanShifts,\n shiftChangeCompensation: Number(scope.shiftChangeComp) || 10,\n lunchBreakMinutes: Number(scope.lunchBreak) || 30,\n thresholdMultiplier: Number(scope.thresholdMultiplier) || 1.5,\n oeeAlertThreshold: Number(scope.oeeThreshold) || 90\n }\n });\n \n // Show feedback after short delay\n setTimeout(function() {\n scope.isSaving = false;\n scope.saveStatus = '✓ Saved';\n scope.$applyAsync();\n \n // Clear status after 3 seconds\n setTimeout(function() {\n scope.saveStatus = '';\n scope.$applyAsync();\n }, 3000);\n }, 500);\n};\n\n// Load saved settings on init\nscope.loadSavedSettings = function() {\n scope.send({ topic: 'getShiftConfig' });\n};\n\n// Call on init\nscope.loadSavedSettings();\n\n\n// Save threshold config\nscope.updateThresholdConfig = function() {\n if (scope._thresholdDebounce) clearTimeout(scope._thresholdDebounce);\n \n scope._thresholdDebounce = setTimeout(function() {\n scope.send({\n topic: 'saveThresholdConfig',\n payload: {\n thresholdMultiplier: Number(scope.thresholdMultiplier) || 1.5,\n oeeAlertThreshold: Number(scope.oeeThreshold) || 90\n }\n });\n }, 500);\n};\n\n// Handle incoming config data\n// Add to existing $watch('msg', ...) block:\n/*\nif (msg.topic === 'shiftConfigData') {\n var config = msg.payload || {};\n scope.shifts = config.shifts || [{ start: '08:00', end: '16:00' }];\n scope.shiftChangeComp = config.shiftChangeCompensation || 10;\n scope.lunchBreak = config.lunchBreakMinutes || 30;\n scope.thresholdMultiplier = config.thresholdMultiplier || 1.5;\n scope.oeeThreshold = config.oeeAlertThreshold || 90;\n return;\n}\n\n// Call on init\nscope.loadShiftConfig();\n */\n})(scope);\n\n// Material theme cleanup\n(function ensureNoMaterialTint(){\n const root = document.getElementById('oee');\n if (!root) return;\n\n const targets = root.querySelectorAll([\n '.md-toolbar',\n 'md-toolbar',\n '.md-subheader',\n 'md-subheader',\n '.md-toolbar-tools',\n '.md-card-title'\n ].join(','));\n\n targets.forEach(el => {\n if (el && el.style) {\n if (el.style.background) {\n el.style.background = 'transparent';\n }\n if (el.style.backgroundColor) {\n el.style.backgroundColor = 'transparent';\n }\n }\n if (el && el.classList) {\n el.classList.remove(\n 'md-whiteframe-1dp','md-whiteframe-2dp','md-whiteframe-3dp',\n 'md-whiteframe-z1','md-whiteframe-z2','md-whiteframe-z3'\n );\n }\n });\n\n if (!document.getElementById('oee-theme-sentinel')) {\n const style = document.createElement('style');\n style.id = 'oee-theme-sentinel';\n style.textContent = `\n #oee .md-toolbar::before, #oee .md-toolbar::after,\n #oee .md-subheader::before, #oee .md-subheader::after,\n #oee md-toolbar::before, #oee md-toolbar::after,\n #oee md-subheader::before, #oee md-subheader::after {\n display: none !important;\n content: none !important;\n }\n `;\n document.head.appendChild(style);\n }\n})();\n</script>\n\n\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 390,
"y": 480,
"wires": [
[
"a7d58e15929b3d8c",
"0a5caf3e23c68e6e"
]
]
},
{
"id": "f1a2b3c4d5e6f7a8",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "e1f2a3b4c5d6e7f8",
"name": "WO Template",
"order": 1,
"width": "25",
"height": "25",
"format": "<style>\n@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&display=swap');\n\n/* SCALE: align 100% zoom with prior 125% appearance */\n:root {\n font-size: 125%;\n --bg-0: #0c1117;\n --bg-1: #131a23;\n --panel: #151e2b;\n --panel-hi: #1a2433;\n --text: #ecf3ff;\n --muted: #96a5b7;\n --accent: #2ea3ff;\n --accent-2: #1f8cf3;\n --good: #24d06f;\n --warn: #ffd100;\n --bad: #ff4d4f;\n --radius: 0.75rem;\n --shadow: 0 0.5rem 1rem rgba(0, 0, 0, .35), inset 0 0.0625rem 0 rgba(255, 255, 255, .05);\n --sidebar-width: 3.75rem;\n --sidebar-gap: clamp(0.75rem, 1.2vh, 1rem);\n --content-max: 100rem;\n --content-pad: clamp(1rem, 1.2vw, 1.4rem);\n --section-gap: clamp(0.75rem, 1vw, 1rem);\n --card-pad: clamp(1rem, 1.1vw, 1.25rem);\n --card-pad-lg: clamp(1.1rem, 1.2vw, 1.35rem);\n --fs-page-title: clamp(1.2rem, 1vw + 0.35rem, 1.7rem);\n --fs-section-title: clamp(0.95rem, 0.9vw + 0.25rem, 1.25rem);\n --fs-label: clamp(0.85rem, 0.7vw + 0.3rem, 1.05rem);\n --fs-body: clamp(0.8rem, 0.7vw + 0.28rem, 1rem);\n --progress-height: 0.875rem;\n}\n\n* {\n box-sizing: border-box;\n}\n\nhtml,\nbody,\n#oee {\n width: 100vw;\n height: 100vh;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n overflow-x: hidden;\n background: var(--bg-0);\n color: var(--text);\n font-family: 'Poppins', system-ui, 'Segoe UI', 'Roboto', sans-serif;\n font-weight: 600;\n}\n\nbody {\n overflow: hidden;\n}\n\nbody > md-content,\nbody > md-content > md-content,\n.nr-dashboard-template,\n.nr-dashboard-template md-content,\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-content {\n background: transparent !important;\n height: 100%;\n overflow: hidden;\n}\n\n.nr-dashboard-cardpanel,\n.nr-dashboard-cardpanel md-card,\n.nr-dashboard-cardpanel md-card-content {\n background: transparent !important;\n box-shadow: none !important;\n padding: 0 !important;\n}\n\n/* LAYOUT: lock sidebar + content grid across tabs */\n#oee {\n position: fixed;\n inset: 0;\n display: flex;\n overflow: hidden;\n}\n\n.sidebar {\n width: var(--sidebar-width);\n background: #0b1119;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: space-between;\n padding: clamp(0.625rem, 1.4vh, 0.9rem) clamp(0.5rem, 0.8vw, 0.75rem);\n}\n\n.side-top {\n display: flex;\n flex-direction: column;\n gap: var(--sidebar-gap);\n align-items: center;\n}\n\n.sb-btn {\n width: 2.75rem;\n height: 2.75rem;\n border-radius: 0.75rem;\n background: #0f1721;\n border: 1px solid #19222e;\n display: grid;\n place-items: center;\n color: #8fb3d9;\n cursor: pointer;\n transition: 0.16s box-shadow, 0.16s transform, 0.16s border-color;\n}\n\n.sb-btn.active {\n border-color: #2fd289;\n box-shadow: 0 0 0 0.125rem rgba(36, 208, 111, .25), 0 0.625rem 1.125rem rgba(36, 208, 111, .28);\n color: #2fd289;\n}\n\n.sb-ico {\n font-size: clamp(1.1rem, 1.2vw, 1.25rem);\n line-height: 1;\n}\n\n.sb-foot {\n font-size: clamp(0.6rem, 0.6vw, 0.7rem);\n color: #6c7b8d;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n}\n\n.main {\n flex: 1;\n overflow: auto;\n padding: var(--content-pad);\n}\n\n.container {\n max-width: var(--content-max);\n margin: 0 auto;\n display: flex;\n flex-direction: column;\n gap: var(--section-gap);\n}\n\n.card {\n background: linear-gradient(160deg, var(--panel) 0%, var(--panel-hi) 100%);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n}\n\n/* HEADER: keep title and CTA row on one line */\n.page-heading {\n display: flex;\n align-items: center;\n gap: var(--section-gap);\n padding: 0 0.25rem;\n flex-wrap: wrap;\n}\n\n.page-title {\n margin: 0;\n font-size: var(--fs-page-title);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--text);\n}\n\n.page-actions {\n margin-left: auto;\n display: flex;\n gap: clamp(0.65rem, 1vw, 0.9rem);\n flex-wrap: wrap;\n align-items: center;\n}\n\n.action-btn {\n background: linear-gradient(180deg, #32ff7e 0%, #2fd289 100%);\n box-shadow: 0 0 1.25rem rgba(36, 208, 111, .5), inset 0 0.0625rem 0 rgba(255, 255, 255, .1);\n border: none;\n border-radius: var(--radius);\n color: var(--text);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-weight: 700;\n padding: clamp(0.75rem, 1vw, 0.9rem) clamp(1rem, 1.4vw, 1.25rem);\n cursor: pointer;\n transition: 0.16s transform, 0.16s filter, 0.16s opacity;\n min-width: clamp(8.75rem, 12vw, 10rem);\n}\n\n.action-btn:hover:not(:disabled) {\n transform: translateY(-0.05rem);\n filter: brightness(1.08);\n}\n\n.action-btn:active:not(:disabled) {\n transform: none;\n filter: brightness(0.96);\n}\n\n.action-btn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n filter: grayscale(0.6);\n}\n\n.exit-multi-select {\n background: linear-gradient(180deg, #ff6b6b 0%, #ff5252 100%);\n box-shadow: 0 0 1.25rem rgba(255, 75, 75, .4), inset 0 0.0625rem 0 rgba(255, 255, 255, .1);\n}\n\n.multi-select-badge {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);\n color: var(--text);\n padding: 0.5rem 1rem;\n border-radius: 999px;\n font-size: clamp(0.8rem, 0.9vw, 0.95rem);\n font-weight: 700;\n letter-spacing: 0.05em;\n box-shadow: 0 0.25rem 0.75rem rgba(46, 163, 255, 0.4);\n animation: badge-appear 0.3s ease;\n}\n\n@keyframes badge-appear {\n from {\n opacity: 0;\n transform: scale(0.8);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}\n\n/* TABLE: keep density + typography aligned with Home */\n.table-card {\npadding: var(--card-pad-lg);\ndisplay: flex;\nflex-direction: column;\ngap: clamp(0.75rem, 0.9vw, 0.95rem);\n}\n\n.table-scroll {\noverflow: auto;\nborder-radius: 0.625rem;\nborder: 1px solid rgba(31, 44, 60, 0.55);\nbackground: rgba(13, 19, 27, 0.6);\n}\n\n.workorders-table {\nwidth: 100%;\ntable-layout: fixed;\nborder-collapse: separate;\nborder-spacing: 0;\nmin-width: 52rem;\nfont-variant-numeric: tabular-nums;\n}\n\n.workorders-table thead th {\nposition: sticky;\ntop: 0;\nz-index: 2;\ncolor: var(--text);\ntext-transform: uppercase;\nletter-spacing: 0.04em;\nfont-size: clamp(0.75rem, 0.9vw, 0.85rem);\npadding: clamp(0.65rem, 0.9vw, 0.9rem);\nbackground: rgba(21, 30, 43, 0.9);\ntext-align: left;\nborder-bottom: 1px solid rgba(47, 67, 95, 0.55);\n}\n\n.workorders-table tbody td {\npadding: clamp(0.65rem, 0.95vw, 0.95rem);\ncolor: var(--text);\nfont-size: var(--fs-body);\nbackground: rgba(17, 24, 34, 0.7);\nborder-bottom: 1px solid rgba(31, 44, 60, 0.45);\n}\n\n.workorders-table thead th:not(:last-child),\n.workorders-table tbody td:not(:last-child) {\nborder-right: 1px solid rgba(31, 44, 60, 0.35);\n}\n\n.workorders-table tbody tr:nth-child(odd) td {\nbackground: rgba(17, 27, 39, 0.82);\n}\n\n.workorders-table tbody tr:hover td {\nbackground: rgba(36, 56, 80, 0.55);\n}\n\n.workorders-table thead th:nth-child(4),\n.workorders-table thead th:nth-child(5),\n.workorders-table thead th:nth-child(6),\n.workorders-table thead th:nth-child(9),\n.workorders-table tbody td:nth-child(4),\n.workorders-table tbody td:nth-child(5),\n.workorders-table tbody td:nth-child(6),\n.workorders-table tbody td:nth-child(9) {\ntext-align: right;\n}\n\n.progress-cell {\nmin-width: 9.5rem;\n}\n\n.progress-bar {\nheight: var(--progress-height);\nborder-radius: 999px;\nbackground: #111a24;\nborder: 1px solid #243244;\noverflow: hidden;\n}\n\n.progress-bar__fill {\nheight: 100%;\nwidth: 0;\ntransition: width 0.4s ease;\n}\n\n.workorders-table tbody tr {\ncursor: pointer;\ntransition: background 0.18s ease, border-color 0.18s ease;\nuser-select: none;\n}\n\n.workorders-table tbody tr.row-selected td {\nbackground: rgba(46, 163, 255, 0.18);\nborder-bottom-color: rgba(46, 163, 255, 0.45);\nbox-shadow: inset 0 0 0 1px rgba(46, 163, 255, 0.28);\n}\n\n.checkbox-cell {\nwidth: 3rem;\npadding-left: clamp(0.75rem, 1vw, 1rem) !important;\nopacity: 0;\npointer-events: none;\ntransition: opacity 0.2s ease;\n}\n\n.multi-select-mode .checkbox-cell {\nopacity: 1;\npointer-events: auto;\n}\n\n.checkbox-cell input[type=\"checkbox\"] {\nwidth: 1.125rem;\nheight: 1.125rem;\ncursor: pointer;\naccent-color: var(--accent);\n}\n\n.progress-bar__fill--pending {\n background: linear-gradient(90deg, rgba(150, 165, 183, .5), rgba(150, 165, 183, .8));\n}\n\n.progress-bar__fill--running {\n background: linear-gradient(90deg, rgba(46, 163, 255, .6), rgba(46, 163, 255, .85));\n}\n\n.progress-bar__fill--done {\n background: linear-gradient(90deg, rgba(36, 208, 111, .75), rgba(36, 208, 111, .95));\n}\n\n.status-badge {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.375rem 0.875rem;\n border-radius: 999px;\n font-size: clamp(0.75rem, 0.9vw, 0.85rem);\n letter-spacing: 0.2em;\n text-transform: uppercase;\n font-weight: 700;\n}\n\n.status-badge--pending {\n color: var(--muted);\n background: rgba(20, 28, 40, .65);\n border: 1px solid rgba(150, 165, 183, .35);\n}\n\n.status-badge--running {\n color: var(--text);\n background: rgba(30, 52, 73, .7);\n border: 1px solid rgba(46, 163, 255, .45);\n}\n\n.status-badge--done {\n color: var(--good);\n background: rgba(20, 56, 44, .7);\n border: 1px solid rgba(36, 208, 111, .45);\n}\n\n.table-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n font-size: clamp(0.75rem, 0.9vw, 0.85rem);\n color: var(--muted);\n}\n\n.table-footer-hint {\n font-size: clamp(0.7rem, 0.8vw, 0.8rem);\n color: rgba(150, 165, 183, 0.6);\n font-style: italic;\n}\n\n@media (max-width: 68.75rem) {\n .page-actions {\n width: 100%;\n justify-content: flex-start;\n }\n}\n\n/* Override Node-RED Dashboard's gray backgrounds on text elements */\n.nr-dashboard-template p,\n.nr-dashboard-template h1,\n.nr-dashboard-template h2,\n.nr-dashboard-template h3,\n.nr-dashboard-template h4,\n.nr-dashboard-template label {\n background-color: transparent !important;\n}\n</style>\n<div id=\"oee\">\n <aside class=\"sidebar\">\n <div class=\"side-top\">\n <button class=\"sb-btn\" data-label=\"Home\" ng-click=\"gotoTab('Home')\"><span class=\"sb-ico\">🏠</span></button>\n <button class=\"sb-btn active\" data-label=\"Work Orders\" ng-click=\"gotoTab('Work Orders')\"><span class=\"sb-ico\">📋</span></button>\n <button class=\"sb-btn\" data-label=\"Alerts\" ng-click=\"gotoTab('Alerts')\"><span class=\"sb-ico\">⚠️</span></button>\n <button class=\"sb-btn\" data-label=\"Graphs\" ng-click=\"gotoTab('Graphs')\"><span class=\"sb-ico\">📊</span></button>\n <button class=\"sb-btn\" data-label=\"Help\" ng-click=\"gotoTab('Help')\"><span class=\"sb-ico\">❓</span></button>\n <button class=\"sb-btn\" data-label=\"Settings\" ng-click=\"gotoTab('Settings')\"><span class=\"sb-ico\">⚙️</span></button>\n </div>\n <div class=\"sb-foot\">OEE V1.0</div>\n </aside>\n\n <main class=\"main\">\n <div class=\"container\">\n <section class=\"page-heading\">\n <h1 class=\"page-title\">Work Orders</h1>\n <div class=\"page-actions\">\n <span id=\"multi-select-badge\" class=\"multi-select-badge\" style=\"display:none;\">\n <span id=\"selected-count\">0</span> selected\n </span>\n <button class=\"action-btn\" data-action=\"upload-excel\">Upload Excel</button>\n <button class=\"action-btn\" data-action=\"start-work-order\" id=\"load-btn\">Load</button>\n <button class=\"action-btn\" data-action=\"complete-work-order\" id=\"done-btn\">Done</button>\n <button class=\"action-btn\" data-action=\"refresh-work-orders\">Refresh</button>\n <button class=\"action-btn exit-multi-select\" id=\"exit-multi-select-btn\" style=\"display:none;\">Exit Multi-Select</button>\n <input type=\"file\" id=\"wo-file\" accept=\".xlsx\" style=\"display:none\" />\n </div>\n </section>\n\n <section class=\"card table-card\">\n <div class=\"table-scroll\">\n <table class=\"workorders-table\" id=\"workorders-table\">\n <thead>\n <tr>\n <th class=\"checkbox-cell\"></th>\n <th>ID</th>\n <th>SKU</th>\n <th>TARGET</th>\n <th>GOOD</th>\n <th>SCRAP</th>\n <th>PROGRESS</th>\n <th>STATUS</th>\n <th>LAST UPDATE</th>\n </tr>\n </thead>\n <tbody id=\"workorders-body\"></tbody>\n </table>\n </div>\n <div class=\"table-footer\">\n <span id=\"workorders-count\">0 items</span>\n <span class=\"table-footer-hint\">Tip: Long-press or right-click to multi-select</span>\n </div>\n </section>\n </div>\n </main>\n</div>\n\n<script>\n(function(scope) {\n // Multi-select state\n scope.multiSelectMode = false;\n scope.selectedOrders = [];\n scope.longPressTimer = null;\n scope.longPressDuration = 500; // ms\n\n scope.gotoTab = function(tabName) {\n scope.send({ ui_control: { tab: tabName } });\n };\n\n window.workOrders = window.workOrders || [\n { id:\"WO-101\", sku:\"ST-003\", target:250, good:0, scrap:0, progressPercent:0, status:\"PENDING\", lastUpdateIso:\"\" }\n ];\n\n var elements = {\n tbody: document.getElementById('workorders-body'),\n count: document.getElementById('workorders-count'),\n buttons: document.querySelectorAll('[data-action]'),\n table: document.getElementById('workorders-table'),\n loadBtn: document.getElementById('load-btn'),\n doneBtn: document.getElementById('done-btn'),\n exitMultiSelectBtn: document.getElementById('exit-multi-select-btn'),\n multiSelectBadge: document.getElementById('multi-select-badge'),\n selectedCount: document.getElementById('selected-count')\n };\n\n function formatNumber(value) {\n var num = Number(value);\n return Number.isFinite(num) ? num.toLocaleString() : '0';\n }\n\n function formatDate(isoString) {\n if (!isoString) {\n return '—';\n }\n var date = new Date(isoString);\n if (Number.isNaN(date.valueOf())) {\n return isoString;\n }\n return date.toLocaleString();\n }\n\n function progressFillClass(status) {\n switch ((status || '').toUpperCase()) {\n case 'DONE': return 'progress-bar__fill--done';\n case 'RUNNING': return 'progress-bar__fill--running';\n default: return 'progress-bar__fill--pending';\n }\n }\n\n function statusBadgeClass(status) {\n switch ((status || '').toUpperCase()) {\n case 'DONE': return 'status-badge status-badge--done';\n case 'RUNNING': return 'status-badge status-badge--running';\n default: return 'status-badge status-badge--pending';\n }\n }\n\n function enterMultiSelectMode() {\n scope.multiSelectMode = true;\n elements.table.classList.add('multi-select-mode');\n elements.exitMultiSelectBtn.style.display = 'inline-block';\n updateButtonStates();\n scope.$applyAsync();\n }\n\n function exitMultiSelectMode() {\n scope.multiSelectMode = false;\n scope.selectedOrders = [];\n elements.table.classList.remove('multi-select-mode');\n elements.exitMultiSelectBtn.style.display = 'none';\n elements.multiSelectBadge.style.display = 'none';\n\n // Uncheck all checkboxes\n var checkboxes = elements.tbody.querySelectorAll('input[type=\"checkbox\"]');\n checkboxes.forEach(function(cb) {\n cb.checked = false;\n });\n\n // Remove all row selections\n var rows = elements.tbody.querySelectorAll('tr');\n rows.forEach(function(row) {\n row.classList.remove('row-selected');\n });\n\n updateButtonStates();\n scope.$applyAsync();\n }\n\n function updateButtonStates() {\n var selectedCount = scope.selectedOrders.length;\n\n if (scope.multiSelectMode && selectedCount > 0) {\n elements.multiSelectBadge.style.display = 'inline-flex';\n elements.selectedCount.textContent = selectedCount;\n\n // Disable Load button if multiple selected\n if (selectedCount > 1) {\n elements.loadBtn.disabled = true;\n } else {\n elements.loadBtn.disabled = false;\n }\n\n elements.doneBtn.disabled = false;\n } else if (scope.multiSelectMode) {\n elements.multiSelectBadge.style.display = 'none';\n elements.loadBtn.disabled = true;\n elements.doneBtn.disabled = true;\n } else {\n elements.loadBtn.disabled = false;\n elements.doneBtn.disabled = false;\n }\n }\n\n function toggleOrderSelection(order, checkbox, row) {\n var index = scope.selectedOrders.findIndex(function(o) {\n return o.id === order.id;\n });\n\n if (index === -1) {\n // Add to selection\n scope.selectedOrders.push(order);\n checkbox.checked = true;\n row.classList.add('row-selected');\n } else {\n // Remove from selection\n scope.selectedOrders.splice(index, 1);\n checkbox.checked = false;\n row.classList.remove('row-selected');\n }\n\n updateButtonStates();\n }\n\n function renderWorkOrders() {\n var orders = Array.isArray(window.workOrders) ? window.workOrders : [];\n orders = orders.filter(function(order) {\n return (order.status || '').toUpperCase() !== 'DONE';\n });\n\n if (elements.tbody) {\n elements.tbody.textContent = '';\n\n orders.forEach(function(order) {\n var tr = document.createElement('tr');\n tr.dataset.orderId = order.id || '';\n\n function appendCell(content, alignRight, className) {\n var td = document.createElement('td');\n if (className) td.className = className;\n if (alignRight) td.style.textAlign = 'right';\n if (content instanceof Node) {\n td.appendChild(content);\n } else {\n td.textContent = content;\n }\n tr.appendChild(td);\n }\n\n // Checkbox cell\n var checkboxTd = document.createElement('td');\n checkboxTd.className = 'checkbox-cell';\n var checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.addEventListener('click', function(e) {\n e.stopPropagation();\n if (scope.multiSelectMode) {\n toggleOrderSelection(order, checkbox, tr);\n }\n });\n checkboxTd.appendChild(checkbox);\n tr.appendChild(checkboxTd);\n\n appendCell(order.id || '—');\n appendCell(order.sku || '—');\n appendCell(formatNumber(order.target), true);\n appendCell(formatNumber(order.good), true);\n appendCell(formatNumber(order.scrap), true);\n\n var progressCell = document.createElement('td');\n progressCell.className = 'progress-cell';\n var bar = document.createElement('div');\n bar.className = 'progress-bar';\n var fill = document.createElement('div');\n fill.className = 'progress-bar__fill ' + progressFillClass(order.status);\n var percent = Math.max(0, Math.min(100, Math.round(Number(order.progressPercent) || 0)));\n fill.style.width = percent + '%';\n bar.appendChild(fill);\n progressCell.appendChild(bar);\n tr.appendChild(progressCell);\n\n var statusCell = document.createElement('td');\n var badge = document.createElement('span');\n badge.className = statusBadgeClass(order.status);\n badge.textContent = (order.status || 'PENDING').toUpperCase();\n statusCell.appendChild(badge);\n tr.appendChild(statusCell);\n\n appendCell(formatDate(order.lastUpdateIso));\n\n // Long-press functionality for entering multi-select mode\n tr.addEventListener('mousedown', function(e) {\n if (scope.multiSelectMode) return;\n\n scope.longPressTimer = setTimeout(function() {\n enterMultiSelectMode();\n toggleOrderSelection(order, checkbox, tr);\n }, scope.longPressDuration);\n });\n\n tr.addEventListener('mouseup', function() {\n if (scope.longPressTimer) {\n clearTimeout(scope.longPressTimer);\n scope.longPressTimer = null;\n }\n });\n\n tr.addEventListener('mouseleave', function() {\n if (scope.longPressTimer) {\n clearTimeout(scope.longPressTimer);\n scope.longPressTimer = null;\n }\n });\n\n // Right-click to enter multi-select mode\n tr.addEventListener('contextmenu', function(e) {\n e.preventDefault();\n if (!scope.multiSelectMode) {\n enterMultiSelectMode();\n }\n toggleOrderSelection(order, checkbox, tr);\n });\n\n // Regular click behavior\n tr.addEventListener('click', function(e) {\n if (scope.multiSelectMode) {\n toggleOrderSelection(order, checkbox, tr);\n } else {\n // Single-select mode (original behavior)\n if (scope.selectedRow && scope.selectedRow !== tr) {\n scope.selectedRow.classList.remove('row-selected');\n }\n scope.selectedRow = tr;\n scope.selectedOrder = order;\n tr.classList.add('row-selected');\n scope.$applyAsync();\n }\n });\n\n elements.tbody.appendChild(tr);\n });\n }\n\n if (elements.count) {\n var countText = orders.length + (orders.length === 1 ? ' item' : ' items');\n elements.count.textContent = countText;\n }\n }\n\n scope.renderWorkOrders = renderWorkOrders;\n renderWorkOrders();\n\n // Exit multi-select button\n elements.exitMultiSelectBtn.addEventListener('click', function() {\n exitMultiSelectMode();\n });\n\n if (elements.buttons) {\n elements.buttons.forEach(function(btn) {\n btn.addEventListener('click', function() {\n const action = btn.getAttribute(\"data-action\");\n switch (action) {\n case \"upload-excel\": handleUploadExcel(); break;\n case \"refresh-work-orders\": handleRefresh(); break;\n case \"start-work-order\": handleStart(); break;\n case \"complete-work-order\": handleComplete(); break;\n default: scope.send({ action }); break;\n }\n });\n });\n }\n\n function handleUploadExcel() {\n const input = document.getElementById(\"wo-file\");\n input.value = \"\";\n input.onchange = function (event) {\n const file = event.target.files[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = function (e) {\n const base64 = e.target.result.split(\",\")[1];\n scope.send({\n action: \"upload-excel\",\n filename: file.name,\n payload: base64\n });\n };\n reader.readAsDataURL(file);\n };\n input.click();\n }\n\n function handleRefresh() {\n scope.send({ action: \"refresh-work-orders\" });\n }\n\n function handleStart() {\n if (scope.multiSelectMode) {\n if (scope.selectedOrders.length === 0) {\n return alert(\"Please select a work order first.\");\n }\n if (scope.selectedOrders.length > 1) {\n return alert(\"Cannot load multiple work orders at once.\");\n }\n scope.send({ action: \"start-work-order\", payload: scope.selectedOrders[0] });\n exitMultiSelectMode();\n } else {\n if (!scope.selectedOrder) return alert(\"Please select a work order first.\");\n scope.send({ action: \"start-work-order\", payload: scope.selectedOrder });\n }\n handleRefresh();\n }\n\n function handleComplete() {\n if (scope.multiSelectMode) {\n if (scope.selectedOrders.length === 0) {\n return alert(\"No work orders selected.\");\n }\n\n // Confirm multi-complete\n var confirmMsg = \"Mark \" + scope.selectedOrders.length + \" work order(s) as done?\";\n if (!confirm(confirmMsg)) return;\n\n // Send all selected orders for completion\n scope.selectedOrders.forEach(function(order) {\n scope.send({ action: \"complete-work-order\", payload: order });\n });\n\n exitMultiSelectMode();\n } else {\n if (!scope.selectedOrder) return alert(\"No active work order selected.\");\n scope.send({ action: \"complete-work-order\", payload: scope.selectedOrder });\n }\n handleRefresh();\n }\n\n scope.$watch(\"msg\", function(msg) {\n if (!msg) return;\n\n if (msg.topic === \"workOrderCycle\") {\n var update = msg.payload || {};\n var orders = Array.isArray(window.workOrders) ? window.workOrders.slice() : [];\n var found = false;\n\n orders = orders.map(function(order) {\n if ((order.id || \"\") === (update.id || \"\")) {\n found = true;\n return Object.assign({}, order, {\n good: Number(update.good) || 0,\n scrap: Number(update.scrap) || 0,\n target: Number(update.target) || 0,\n progressPercent: Number(update.progressPercent) || 0,\n status: update.status ? update.status.toUpperCase() : (order.status || \"PENDING\"),\n lastUpdateIso: update.lastUpdateIso || order.lastUpdateIso\n });\n }\n return order;\n });\n\n if (!found && update.id) {\n orders.push(update);\n }\n\n window.workOrders = orders;\n scope.renderWorkOrders();\n return;\n }\n\n if (msg.topic === \"workOrdersList\") {\n window.workOrders = msg.payload || [];\n scope.renderWorkOrders();\n }\n\n if (msg.topic === \"uploadStatus\") {\n alert(msg.payload.message || \"Upload complete\");\n handleRefresh();\n }\n });\n\n})(scope);\n\n(function ensureNoMaterialTint(){\n const root = document.getElementById('oee');\n if (!root) return;\n\n const targets = root.querySelectorAll([\n '.md-toolbar',\n 'md-toolbar',\n '.md-subheader',\n 'md-subheader',\n '.md-toolbar-tools',\n '.md-card-title'\n ].join(','));\n\n targets.forEach(el => {\n if (el && el.style) {\n if (el.style.background) {\n el.style.background = 'transparent';\n }\n if (el.style.backgroundColor) {\n el.style.backgroundColor = 'transparent';\n }\n }\n if (el && el.classList) {\n el.classList.remove(\n 'md-whiteframe-1dp','md-whiteframe-2dp','md-whiteframe-3dp',\n 'md-whiteframe-z1','md-whiteframe-z2','md-whiteframe-z3'\n );\n }\n });\n\n if (!document.getElementById('oee-theme-sentinel')) {\n const style = document.createElement('style');\n style.id = 'oee-theme-sentinel';\n style.textContent = `\n #oee .md-toolbar::before, #oee .md-toolbar::after,\n #oee .md-subheader::before, #oee .md-subheader::after,\n #oee md-toolbar::before, #oee md-toolbar::after,\n #oee md-subheader::before, #oee md-subheader::after {\n display: none !important;\n content: none !important;\n }\n `;\n document.head.appendChild(style);\n }\n})();\n\nscope.$evalAsync(scope.renderWorkOrders);\n</script>\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 380,
"y": 320,
"wires": [
[
"a7d58e15929b3d8c",
"010de5af3ced0ae3"
]
]
},
{
"id": "a7d58e15929b3d8c",
"type": "function",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "Tab navigation",
"func": "if (msg.ui_control && msg.ui_control.tab) {\n msg.payload = { tab: msg.ui_control.tab };\n delete msg.ui_control;\n return msg;\n}\nreturn null;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 620,
"y": 380,
"wires": [
[
"cc81a9dbfd443d62"
]
]
},
{
"id": "cc81a9dbfd443d62",
"type": "ui_ui_control",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "",
"events": "all",
"x": 780,
"y": 380,
"wires": [
[]
]
},
{
"id": "06f9769e8b0d5355",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"group": "",
"name": "General Style",
"order": 0,
"width": 0,
"height": 0,
"format": "<style>\n ::selection {\n background-color: blue; /* Or a specific blue hex code like #0000FF */\n color: white; /* Text color when selected */\n }\n /* Removes shadows and background tints from UI groups/cards */\n .nr-dashboard-theme ui-card-panel,\n body.nr-dashboard-theme md-content md-card {\n box-shadow: none !important;\n background-color: transparent !important;\n /* or set your desired background color */\n border-radius: 0px !important;\n /* Removes rounded corners if present */\n text-shadow: none !important;\n }\n\n /* Removes tints/shadows from specific widgets like buttons */\n .nr-dashboard-theme .nr-dashboard-button .md-button {\n box-shadow: none !important;\n background-color: transparent !important;\n }\n\n /* Adjust the main background of the page if needed */\n .masonry-container {\n background: none !important;\n /* Removes any linear gradients or colors */\n }\n\n /* ... your existing CSS rules ... */\n \n /* Change the background color when text is highlighted by the user */\n ::selection {\n background-color: transparent !important; /* Makes the background clear */\n color: inherit !important; /* Keeps the original text color */\n }\n \n /* For older browsers (Firefox specific) */\n ::-moz-selection {\n background-color: transparent !important;\n color: inherit !important;\n }\n\n /* ... your previous CSS rules ... */\n \n /* ... your previous CSS rules ... */\n \n /* Override the background color for the 'How to Start / Stop Production' title */\n .help-title {\n background-color: transparent !important;\n }\n .card help-card {\n background-color: transparent !important;\n }\n .panel-title {\n background-color: transparent !important;\n }\n\n/* Override Node-RED Dashboard's gray backgrounds on text elements */\n.nr-dashboard-template p,\n.nr-dashboard-template h1,\n.nr-dashboard-template h2,\n.nr-dashboard-template h3,\n.nr-dashboard-template h4,\n.nr-dashboard-template label {\n background-color: transparent !important;\n}\n</style>",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "global",
"className": "",
"x": 740,
"y": 340,
"wires": [
[]
]
},
{
"id": "anomaly_alert_ui_global",
"type": "ui_template",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"group": "919b5b8d778e2b6c",
"name": "Anomaly Alert System (Global)",
"order": 1,
"width": "25",
"height": "25",
"format": "<style>\n /* ============================================================ */\n /* ANOMALY ALERT SYSTEM - GLOBAL FLOATING PANEL & NOTIFICATIONS */\n /* ============================================================ */\n\n /* Floating Alert Panel Container */\n #anomaly-alert-panel {\n position: fixed;\n top: 0;\n right: 0;\n width: 400px;\n height: 100vh;\n background: linear-gradient(160deg, #151e2b 0%, #1a2433 100%);\n box-shadow: -0.5rem 0 2rem rgba(0, 0, 0, 0.5);\n z-index: 9999;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n display: flex;\n flex-direction: column;\n font-family: 'Poppins', sans-serif;\n border-left: 2px solid #ff4d4f;\n }\n\n #anomaly-alert-panel.expanded {\n transform: translateX(0);\n }\n\n /* Alert Toggle Button */\n #anomaly-toggle-btn {\n position: fixed;\n top: 50%;\n right: 0;\n transform: translateY(-50%);\n background: linear-gradient(180deg, #ff5252 0%, #ff4d4f 100%);\n border: none;\n border-radius: 0.75rem 0 0 0.75rem;\n padding: 1rem 0.75rem;\n cursor: pointer;\n z-index: 10000;\n box-shadow: -0.25rem 0.5rem 1rem rgba(255, 77, 79, 0.6);\n transition: all 0.2s ease;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 0.5rem;\n color: #ecf3ff;\n font-weight: 700;\n font-size: 0.75rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n writing-mode: vertical-rl;\n }\n\n #anomaly-toggle-btn:hover {\n transform: translateY(-50%) translateX(-0.25rem);\n filter: brightness(1.1);\n }\n\n #anomaly-toggle-btn.has-alerts {\n animation: alert-pulse 2s infinite;\n }\n\n @keyframes alert-pulse {\n\n 0%,\n 100% {\n box-shadow: -0.25rem 0.5rem 1rem rgba(255, 77, 79, 0.6);\n }\n\n 50% {\n box-shadow: -0.25rem 0.5rem 2rem rgba(255, 77, 79, 1);\n }\n }\n\n .alert-badge {\n background: #fff;\n color: #ff4d4f;\n border-radius: 50%;\n width: 24px;\n height: 24px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.7rem;\n font-weight: 700;\n writing-mode: horizontal-tb;\n }\n\n /* Panel Header */\n .anomaly-panel-header {\n padding: 1.5rem;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n .anomaly-panel-title {\n font-size: 1.1rem;\n font-weight: 700;\n color: #ecf3ff;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n margin: 0;\n }\n\n .anomaly-close-btn {\n background: transparent;\n border: none;\n color: #96a5b7;\n font-size: 1.5rem;\n cursor: pointer;\n transition: color 0.2s;\n }\n\n .anomaly-close-btn:hover {\n color: #ecf3ff;\n }\n\n /* Alert List */\n .anomaly-alert-list {\n flex: 1;\n overflow-y: auto;\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n }\n\n .anomaly-alert-item {\n background: rgba(17, 24, 34, 0.7);\n border-radius: 0.75rem;\n padding: 1rem;\n border-left: 4px solid;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n transition: all 0.2s;\n }\n\n .anomaly-alert-item:hover {\n background: rgba(36, 56, 80, 0.55);\n }\n\n .anomaly-alert-item.critical {\n border-left-color: #ff4d4f;\n }\n\n .anomaly-alert-item.warning {\n border-left-color: #ffd100;\n }\n\n .anomaly-alert-item.info {\n border-left-color: #2ea3ff;\n }\n\n .anomaly-alert-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n gap: 0.5rem;\n }\n\n .anomaly-alert-title {\n font-size: 0.9rem;\n font-weight: 700;\n color: #ecf3ff;\n margin: 0;\n flex: 1;\n }\n\n .anomaly-severity-badge {\n font-size: 0.65rem;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n padding: 0.25rem 0.5rem;\n border-radius: 999px;\n }\n\n .anomaly-severity-badge.critical {\n background: rgba(255, 77, 79, 0.2);\n color: #ff4d4f;\n }\n\n .anomaly-severity-badge.warning {\n background: rgba(255, 209, 0, 0.2);\n color: #ffd100;\n }\n\n .anomaly-severity-badge.info {\n background: rgba(46, 163, 255, 0.2);\n color: #2ea3ff;\n }\n\n .anomaly-alert-desc {\n font-size: 0.8rem;\n color: #96a5b7;\n margin: 0;\n line-height: 1.4;\n }\n\n .anomaly-alert-time {\n font-size: 0.7rem;\n color: #6c7b8d;\n margin: 0;\n }\n\n .anomaly-alert-actions {\n display: flex;\n gap: 0.5rem;\n margin-top: 0.5rem;\n }\n\n .anomaly-ack-btn {\n background: linear-gradient(180deg, #32ff7e 0%, #2fd289 100%);\n border: none;\n border-radius: 0.5rem;\n color: #ecf3ff;\n padding: 0.5rem 1rem;\n font-size: 0.75rem;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .anomaly-ack-btn:hover {\n transform: translateY(-1px);\n filter: brightness(1.1);\n }\n\n .anomaly-empty-state {\n text-align: center;\n padding: 3rem 1rem;\n color: #6c7b8d;\n }\n\n .anomaly-empty-icon {\n font-size: 3rem;\n margin-bottom: 1rem;\n }\n\n /* Pop-up Notification */\n #anomaly-popup-container {\n position: fixed;\n top: 2rem;\n left: 50%;\n transform: translateX(-50%);\n z-index: 10001;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n pointer-events: none;\n }\n\n .anomaly-popup {\n background: linear-gradient(160deg, #151e2b 0%, #1a2433 100%);\n border-radius: 0.75rem;\n padding: 1.5rem;\n box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.7);\n min-width: 400px;\n max-width: 600px;\n border-left: 6px solid;\n pointer-events: auto;\n animation: popup-appear 0.3s ease;\n position: relative;\n }\n\n @keyframes popup-appear {\n from {\n opacity: 0;\n transform: translateY(-2rem);\n }\n\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n .anomaly-popup.critical {\n border-left-color: #ff4d4f;\n }\n\n .anomaly-popup.warning {\n border-left-color: #ffd100;\n }\n\n .anomaly-popup.info {\n border-left-color: #2ea3ff;\n }\n\n .anomaly-popup-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 0.75rem;\n }\n\n .anomaly-popup-title {\n font-size: 1.1rem;\n font-weight: 700;\n color: #ecf3ff;\n margin: 0;\n flex: 1;\n }\n\n .anomaly-popup-close {\n background: transparent;\n border: none;\n color: #96a5b7;\n font-size: 1.5rem;\n cursor: pointer;\n line-height: 1;\n padding: 0;\n transition: color 0.2s;\n }\n\n .anomaly-popup-close:hover {\n color: #ecf3ff;\n }\n\n .anomaly-popup-desc {\n font-size: 0.9rem;\n color: #96a5b7;\n margin: 0 0 1rem 0;\n line-height: 1.5;\n }\n\n .anomaly-popup-ack {\n background: linear-gradient(180deg, #ff5252 0%, #ff4d4f 100%);\n border: none;\n border-radius: 0.5rem;\n color: #ecf3ff;\n padding: 0.75rem 1.5rem;\n font-size: 0.85rem;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n cursor: pointer;\n transition: all 0.2s;\n width: 100%;\n }\n\n .anomaly-popup-ack:hover {\n transform: translateY(-2px);\n filter: brightness(1.1);\n }\n\n /* Scrollbar styling */\n .anomaly-alert-list::-webkit-scrollbar {\n width: 8px;\n }\n\n .anomaly-alert-list::-webkit-scrollbar-track {\n background: rgba(0, 0, 0, 0.2);\n border-radius: 4px;\n }\n\n .anomaly-alert-list::-webkit-scrollbar-thumb {\n background: rgba(150, 165, 183, 0.3);\n border-radius: 4px;\n }\n\n .anomaly-alert-list::-webkit-scrollbar-thumb:hover {\n background: rgba(150, 165, 183, 0.5);\n }\n</style>\n\n<!-- Floating Toggle Button -->\n<button id=\"anomaly-toggle-btn\" ng-click=\"toggleAnomalyPanel()\">\n <span class=\"alert-badge\" ng-if=\"activeAnomalyCount > 0\">{{activeAnomalyCount}}</span>\n <span ng-if=\"activeAnomalyCount === 0\">⚠️</span>\n <span style=\"writing-mode: vertical-rl;\">ALERTS</span>\n</button>\n\n<!-- Floating Alert Panel -->\n<div id=\"anomaly-alert-panel\" ng-class=\"{expanded: anomalyPanelExpanded}\">\n <div class=\"anomaly-panel-header\">\n <h2 class=\"anomaly-panel-title\">Active Alerts</h2>\n <button class=\"anomaly-close-btn\" ng-click=\"toggleAnomalyPanel()\">×</button>\n </div>\n\n <div class=\"anomaly-alert-list\">\n <div class=\"anomaly-empty-state\" ng-if=\"activeAnomalies.length === 0\">\n <div class=\"anomaly-empty-icon\">✅</div>\n <p>No active alerts</p>\n <p style=\"font-size: 0.8rem; margin-top: 0.5rem;\">All systems operating normally</p>\n </div>\n\n <div class=\"anomaly-alert-item {{anomaly.severity}}\" ng-repeat=\"anomaly in activeAnomalies\">\n <div class=\"anomaly-alert-header\">\n <h3 class=\"anomaly-alert-title\">{{anomaly.title}}</h3>\n <span class=\"anomaly-severity-badge {{anomaly.severity}}\">{{anomaly.severity}}</span>\n </div>\n <p class=\"anomaly-alert-desc\">{{anomaly.description}}</p>\n <p class=\"anomaly-alert-time\">{{formatTimestamp(anomaly.timestamp)}}</p>\n <div class=\"anomaly-alert-actions\">\n <button class=\"anomaly-ack-btn\" ng-click=\"acknowledgeAnomaly(anomaly)\">Acknowledge</button>\n </div>\n </div>\n </div>\n</div>\n\n<!-- Pop-up Notification Container -->\n<div id=\"anomaly-popup-container\">\n <div class=\"anomaly-popup {{popup.severity}}\" ng-repeat=\"popup in popupNotifications\">\n <div class=\"anomaly-popup-header\">\n <h3 class=\"anomaly-popup-title\">{{popup.title}}</h3>\n <button class=\"anomaly-popup-close\" ng-click=\"closePopup(popup)\">×</button>\n </div>\n <p class=\"anomaly-popup-desc\">{{popup.description}}</p>\n <button class=\"anomaly-popup-ack\" ng-click=\"acknowledgePopup(popup)\">Acknowledge Alert</button>\n </div>\n</div>\n\n<script>\n (function(scope) {\n // Initialize state\n scope.anomalyPanelExpanded = false;\n scope.activeAnomalies = [];\n scope.popupNotifications = [];\n scope.activeAnomalyCount = 0;\n \n // Toggle panel\n scope.toggleAnomalyPanel = function() {\n scope.anomalyPanelExpanded = !scope.anomalyPanelExpanded;\n \n // Update button class\n var btn = document.getElementById('anomaly-toggle-btn');\n if (btn) {\n if (scope.activeAnomalyCount > 0) {\n btn.classList.add('has-alerts');\n } else {\n btn.classList.remove('has-alerts');\n }\n }\n };\n \n // Format timestamp\n scope.formatTimestamp = function(timestamp) {\n if (!timestamp) return '';\n var date = new Date(timestamp);\n return date.toLocaleString();\n };\n \n // Acknowledge anomaly from panel\n scope.acknowledgeAnomaly = function(anomaly) {\n if (!anomaly) return;\n var eventId = anomaly.event_id || anomaly.event_timestamp || anomaly.timestamp;\n \n // Send acknowledgment message\n scope.send({\n topic: 'acknowledge-anomaly',\n payload: {\n event_id: eventId,\n timestamp: Date.now()\n }\n });\n \n // Remove from active list\n var index = scope.activeAnomalies.findIndex(function(a) {\n return a.event_id === anomaly.event_id || \n (a.anomaly_type === anomaly.anomaly_type && a.timestamp === anomaly.timestamp);\n });\n \n if (index !== -1) {\n scope.activeAnomalies.splice(index, 1);\n scope.activeAnomalyCount = scope.activeAnomalies.length;\n }\n \n scope.$applyAsync();\n };\n \n // Close popup notification\n scope.closePopup = function(popup) {\n var index = scope.popupNotifications.indexOf(popup);\n if (index !== -1) {\n scope.popupNotifications.splice(index, 1);\n }\n };\n \n // Acknowledge popup\n scope.acknowledgePopup = function(popup) {\n var eventId = popup.event_id || popup.event_timestamp || popup.timestamp;\n // Send acknowledgment\n scope.send({\n topic: 'acknowledge-anomaly',\n payload: {\n event_id: eventId,\n timestamp: Date.now()\n }\n });\n \n // Remove popup\n scope.closePopup(popup);\n \n // Also remove from panel if exists\n var panelIndex = scope.activeAnomalies.findIndex(function(a) {\n return a.event_id === popup.event_id || \n (a.anomaly_type === popup.anomaly_type && a.timestamp === popup.timestamp);\n });\n \n if (panelIndex !== -1) {\n scope.activeAnomalies.splice(panelIndex, 1);\n scope.activeAnomalyCount = scope.activeAnomalies.length;\n }\n \n scope.$applyAsync();\n };\n \n // Handle incoming messages\n scope.$watch('msg', function(msg) {\n if (!msg || !msg.topic) return;\n \n // Handle anomaly UI updates from Event Logger output 2\n if (msg.topic === 'anomaly-ui-update') {\n var payload = msg.payload || {};\n \n // Update active anomalies\n scope.activeAnomalies = payload.activeAnomalies || [];\n scope.activeAnomalyCount = payload.activeCount || 0;\n \n // Create pop-up notifications for new critical/warning alerts\n if (payload.updates && Array.isArray(payload.updates)) {\n payload.updates.forEach(function(update) {\n var anomaly = update.anomaly || update;\n\n if (update.status !== 'resolved' &&\n (anomaly.severity === 'critical' || anomaly.severity === 'warning')) {\n\n var existingPopup = scope.popupNotifications.find(function(p) {\n return p.event_timestamp === anomaly.event_timestamp &&\n p.anomaly_type === anomaly.anomaly_type;\n });\n\n if (!existingPopup) {\n scope.popupNotifications.push(anomaly);\n }\n }\n });\n }\n\n \n // Update button state\n var btn = document.getElementById('anomaly-toggle-btn');\n if (btn) {\n if (scope.activeAnomalyCount > 0) {\n btn.classList.add('has-alerts');\n } else {\n btn.classList.remove('has-alerts');\n }\n }\n \n scope.$applyAsync();\n }\n });\n \n})(scope);\n</script>",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 430,
"y": 60,
"wires": [
[
"anomaly_acknowledge_handler"
]
]
},
{
"id": "anomaly_acknowledge_handler",
"type": "function",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"name": "Handle Anomaly Acknowledgment",
"func": "// Handle acknowledgment from UI - USES INLINE SQL VALUES\nif (msg.topic === 'acknowledge-anomaly') {\n const ackData = msg.payload || {};\n const eventId = ackData.event_id;\n const ackTimestamp = ackData.timestamp || Date.now();\n \n if (!eventId) {\n node.warn('[ANOMALY ACK] No event_id provided');\n return null;\n }\n \n // Skip temp IDs (they don't have DB records yet)\n if (String(eventId).startsWith('temp_')) {\n node.warn(`[ANOMALY ACK] Skipping temp event '${eventId}'`);\n // Remove from activeAnomalies\n let activeAnomalies = global.get('activeAnomalies') || [];\n const idx = activeAnomalies.findIndex(a => a.event_id === eventId);\n if (idx !== -1) {\n activeAnomalies.splice(idx, 1);\n global.set('activeAnomalies', activeAnomalies);\n }\n return null;\n }\n \n // Update database with INLINE VALUES (no placeholders)\n const updateQuery =\n `UPDATE anomaly_events ` +\n `SET status = 'acknowledged', acknowledged_at = ${ackTimestamp} ` +\n `WHERE event_timestamp = ${eventId}`;\n \n msg.topic = updateQuery;\n msg.payload = [];\n \n node.warn(`[ANOMALY ACK] Event ${eventId} acknowledged`);\n \n \n // Also remove from activeAnomalies\n let activeAnomalies = global.get('activeAnomalies') || [];\n node.warn(`Matching against event_id: ${eventId}`);\n node.warn(`Active anomaly IDs: ${activeAnomalies.map(a => a.event_id).join(', ')}`);\n const idx = activeAnomalies.findIndex(a => a.event_id === eventId || a.event_id == eventId);\n if (idx !== -1) {\n activeAnomalies.splice(idx, 1);\n global.set('activeAnomalies', activeAnomalies);\n }\n \n return msg;\n}\n\nreturn null;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 720,
"y": 60,
"wires": [
[
"anomaly_mysql_node_id",
"27520d815c7f9785"
]
]
},
{
"id": "6ad64dedab2042b9",
"type": "inject",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "Simula Inyectora",
"props": [
{
"p": "payload"
}
],
"repeat": "1",
"crontab": "",
"once": true,
"onceDelay": 0.1,
"topic": "",
"payload": "1",
"payloadType": "num",
"x": 1330,
"y": 200,
"wires": [
[
"0f5ee343ed17976c",
"317c9c75095b9499"
]
]
},
{
"id": "0f5ee343ed17976c",
"type": "function",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "1,0",
"func": "// Get current global value (default to 0 if not set)\nlet estado = global.get('Estado_maquina') || 0;\nlet stop = flow.get('stop') || false;\n\nif (stop) {\n // Manual stop active → force 0, don't reschedule\n global.set('Estado_maquina', 0);\n msg.payload = 0;\n node.send(msg);\n return;\n}\n\n// Toggle between 1 and 0\nestado = estado === 1 ? 0 : 1;\n\n// Update the global variable\nglobal.set('Estado_maquina', estado);\n\n// Send it out\nmsg.payload = estado;\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1650,
"y": 220,
"wires": [
[
"0d023d87a13bf56f"
]
]
},
{
"id": "4e025693949ec4bd",
"type": "inject",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "Manual Stop",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 1310,
"y": 240,
"wires": [
[
"b55c91c096a366db"
]
]
},
{
"id": "b55c91c096a366db",
"type": "change",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "",
"rules": [
{
"t": "set",
"p": "stop",
"pt": "flow",
"to": "true",
"tot": "bool"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 1490,
"y": 240,
"wires": [
[
"0f5ee343ed17976c"
]
]
},
{
"id": "33d1f41119e0e262",
"type": "inject",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "Manual Start",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 1310,
"y": 280,
"wires": [
[
"f98ae23b2430c206"
]
]
},
{
"id": "f98ae23b2430c206",
"type": "change",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "set flow.start",
"rules": [
{
"t": "set",
"p": "stop",
"pt": "flow",
"to": "false",
"tot": "bool"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 1480,
"y": 280,
"wires": [
[
"0f5ee343ed17976c"
]
]
},
{
"id": "eaebd8c719c3d135",
"type": "function",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "Cavities Settings",
"func": "if (msg.topic === \"moldSettings\" && msg.payload) {\n const total = Number(msg.payload.total || 0);\n const active = Number(msg.payload.active || 0);\n\n // Store globally\n global.set(\"moldTotal\", total);\n global.set(\"moldActive\", active);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Saved: ${active}/${total}` });\n\n msg.payload = { saved: true, total, active };\n return msg;\n}\n\n// Handle preset selection\nif (msg.topic === \"selectMoldPreset\" && msg.payload) {\n const preset = msg.payload;\n const total = Number(preset.theoretical_cavities || 0);\n const active = Number(preset.functional_cavities || 0);\n\n // Store globally\n global.set(\"moldTotal\", total);\n global.set(\"moldActive\", active);\n\n node.status({ fill: \"blue\", shape: \"dot\", text: `Preset: ${preset.mold_name}` });\n\n // Send to UI to update fields\n msg.topic = \"moldPresetSelected\";\n msg.payload = { total, active, presetName: preset.mold_name };\n return msg;\n}\n\nnode.status({ fill: \"red\", shape: \"ring\", text: \"Invalid payload\" });\nreturn null;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 600,
"wires": [
[
"b2c3d4e5f6a7b8c9"
]
]
},
{
"id": "a1b2c3d4e5f6a7b8",
"type": "function",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "Mold Presets Handler",
"func": "const topic = msg.topic || '';\nconst payload = msg.payload || {};\n\n// ===== IGNORE NON-MOLD TOPICS SILENTLY =====\n// These are KPI/dashboard messages not meant for this handler\nconst ignoredTopics = [\n 'machineStatus',\n 'kpis',\n 'chartsData',\n 'activeWorkOrder',\n 'workOrderCycle',\n 'workOrdersList',\n 'scrapPrompt',\n 'uploadStatus'\n];\n\nif (ignoredTopics.includes(topic) || topic === '') {\n return null; // Silent ignore\n}\n\n// Log only mold-related requests\nnode.warn(`Received: ${topic}`);\n\n// CRITICAL: Use a processing lock to prevent simultaneous requests\nlet dedupeKey = topic;\nif (topic === 'addMoldPreset') {\n dedupeKey = `add_${payload.manufacturer}_${payload.mold_name}`;\n} else if (topic === 'getMoldsByManufacturer') {\n dedupeKey = `getmolds_${payload.manufacturer}`;\n}\n\nconst lockKey = `lock_${dedupeKey}`;\nconst lastRequestKey = `last_request_${dedupeKey}`;\n\n// Check if currently processing this request\nif (flow.get(lockKey) === true) {\n node.warn(`${topic} already processing - duplicate blocked`);\n return null;\n}\n\n// Check timing\nconst now = Date.now();\nconst lastRequestTime = flow.get(lastRequestKey) || 0;\nif (now - lastRequestTime < 2000) {\n node.warn(`Duplicate ${topic} request ignored (within 2s)`);\n return null;\n}\n\n// Set lock IMMEDIATELY before any async operations\nflow.set(lockKey, true);\nflow.set(lastRequestKey, now);\n\n// Release lock after 3 seconds (safety timeout)\nsetTimeout(() => {\n flow.set(lockKey, false);\n}, 3000);\n\n// Load all presets (legacy)\nif (topic === 'loadMoldPresets') {\n msg._originalTopic = 'loadMoldPresets';\n msg.topic = 'SELECT * FROM mold_presets ORDER BY manufacturer, mold_name;';\n node.warn('Querying all presets');\n return msg;\n}\n\n// Search/filter presets (legacy)\nif (topic === 'searchMoldPresets') {\n const filters = msg.payload || {};\n const searchTerm = (filters.searchTerm || '').trim().replace(/['\\\"\\\\\\\\]/g, '');\n const manufacturer = (filters.manufacturer || '').replace(/['\\\"\\\\\\\\]/g, '');\n const theoreticalCavities = filters.theoreticalCavities || '';\n\n let query = 'SELECT * FROM mold_presets WHERE 1=1';\n\n if (searchTerm) {\n const searchPattern = `%${searchTerm}%`;\n query += ` AND (mold_name LIKE '${searchPattern.replace(/'/g, \"''\")}' OR manufacturer LIKE '${searchPattern.replace(/'/g, \"''\")}')`;\n }\n\n if (manufacturer && manufacturer !== 'All') {\n query += ` AND manufacturer = '${manufacturer.replace(/'/g, \"''\")}'`;\n }\n\n if (theoreticalCavities && theoreticalCavities !== '') {\n const cavities = Number(theoreticalCavities);\n if (!isNaN(cavities)) {\n query += ` AND theoretical_cavities = ${cavities}`;\n }\n }\n\n query += ' ORDER BY manufacturer, mold_name;';\n\n msg._originalTopic = 'searchMoldPresets';\n msg.topic = query;\n return msg;\n}\n\n// Get unique manufacturers for dropdown\nif (topic === 'getManufacturers') {\n msg._originalTopic = 'getManufacturers';\n msg.topic = 'SELECT DISTINCT manufacturer FROM mold_presets ORDER BY manufacturer;';\n node.warn('Querying manufacturers');\n return msg;\n}\n\n// Get molds for a specific manufacturer\nif (topic === 'getMoldsByManufacturer') {\n const data = msg.payload || {};\n const manufacturerRaw = (data.manufacturer || '').trim();\n if (!manufacturerRaw) {\n node.warn('No manufacturer provided');\n return null;\n }\n\n const manufacturerSafe = manufacturerRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n\n msg._originalTopic = 'getMoldsByManufacturer';\n msg.topic = `SELECT * FROM mold_presets WHERE manufacturer = '${manufacturerSafe}' ORDER BY mold_name;`;\n node.warn(`Querying molds for: ${manufacturerSafe}`);\n return msg;\n}\n\n// Add a new mold preset - CRITICAL: Strong deduplication\nif (topic === 'addMoldPreset') {\n const data = msg.payload || {};\n const manufacturerRaw = (data.manufacturer || '').trim();\n const moldNameRaw = (data.mold_name || '').trim();\n const theoreticalRaw = (data.theoretical || '').trim();\n const activeRaw = (data.active || '').trim();\n\n if (!manufacturerRaw || !moldNameRaw || !theoreticalRaw || !activeRaw) {\n node.status({ fill: 'red', shape: 'ring', text: 'Missing value' });\n node.warn('Missing required fields');\n return null;\n }\n\n // Additional safety check for already-processed flag\n if (msg._addMoldProcessed) {\n node.warn('addMoldPreset already processed flag detected, ignoring');\n return null;\n }\n msg._addMoldProcessed = true;\n\n const manufacturerSafe = manufacturerRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const moldNameSafe = moldNameRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const theoreticalSafe = theoreticalRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const activeSafe = activeRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n\n msg._originalTopic = 'addMoldPreset';\n msg.topic =\n \"INSERT INTO mold_presets (manufacturer, mold_name, theoretical_cavities, functional_cavities) \" +\n \"VALUES ('\" + manufacturerSafe + \"', '\" + moldNameSafe + \"', \" + theoreticalSafe + \", \" + activeSafe + \");\";\n\n node.status({ fill: 'blue', shape: 'dot', text: 'Inserting mold...' });\n node.warn(`Inserting: ${manufacturerSafe} - ${moldNameSafe}`);\n return msg;\n}\n\nnode.warn(`Unknown topic: ${topic}`);\nreturn null;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 400,
"y": 660,
"wires": [
[
"c9d8e7f6a5b4c3d2"
]
]
},
{
"id": "c9d8e7f6a5b4c3d2",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"mydb": "00d8ad2b0277f906",
"name": "Mold Presets DB",
"x": 610,
"y": 660,
"wires": [
[
"b2c3d4e5f6a7b8c9"
]
]
},
{
"id": "b2c3d4e5f6a7b8c9",
"type": "function",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "Process DB Results",
"func": "// Replace function in \"Process DB Results\" node\n\nconst originalTopic = msg._originalTopic || '';\nconst dbResults = Array.isArray(msg.payload) ? msg.payload : [];\n\nif (!originalTopic) {\n return null;\n}\n\n// IMPORTANT: Clear socketid to prevent loops back to sender\ndelete msg._socketid;\ndelete msg.socketid;\n\n// Manufacturers query → list for first dropdown\nif (originalTopic === 'getManufacturers') {\n const manufacturers = dbResults\n .map(row => row.manufacturer)\n .filter((mfg, index, arr) => mfg && arr.indexOf(mfg) === index)\n .sort();\n\n msg.topic = 'manufacturersList';\n msg.payload = manufacturers;\n\n node.status({ fill: 'green', shape: 'dot', text: `${manufacturers.length} manufacturers` });\n return msg;\n}\n\n// Preset lists (legacy load/search)\nif (originalTopic === 'loadMoldPresets' || originalTopic === 'searchMoldPresets') {\n const presets = dbResults.map(row => ({\n mold_name: row.mold_name || '',\n manufacturer: row.manufacturer || '',\n theoretical_cavities: Number(row.theoretical_cavities) || 0,\n functional_cavities: Number(row.functional_cavities) || 0\n }));\n\n msg.topic = 'moldPresetsList';\n msg.payload = presets;\n\n node.status({ fill: 'green', shape: 'dot', text: `${presets.length} presets found` });\n return msg;\n}\n\n// Molds for selected manufacturer\nif (originalTopic === 'getMoldsByManufacturer') {\n const presets = dbResults.map(row => ({\n mold_name: row.mold_name || '',\n manufacturer: row.manufacturer || '',\n theoretical_cavities: Number(row.theoretical_cavities) || 0,\n functional_cavities: Number(row.functional_cavities) || 0\n }));\n\n msg.topic = 'moldPresetsList';\n msg.payload = presets;\n\n node.status({ fill: 'blue', shape: 'dot', text: `${presets.length} molds for manufacturer` });\n return msg;\n}\n\n// Result of inserting a new mold\nif (originalTopic === 'addMoldPreset') {\n msg.topic = 'addMoldResult';\n msg.payload = {\n success: true,\n result: msg.payload\n };\n\n node.status({ fill: 'green', shape: 'dot', text: 'Mold added' });\n return msg;\n}\n\nnode.status({ fill: 'yellow', shape: 'ring', text: 'Unknown topic: ' + originalTopic });\nreturn null;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 740,
"y": 600,
"wires": [
[
"0b5740c4a2b298b7"
]
]
},
{
"id": "0a5caf3e23c68e6e",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link out 1",
"mode": "link",
"links": [
"7311641fd09b4d3a",
"95f2b9ce958d6785"
],
"x": 505,
"y": 480,
"wires": []
},
{
"id": "7311641fd09b4d3a",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "link in 1",
"links": [
"0a5caf3e23c68e6e"
],
"x": 315,
"y": 600,
"wires": [
[
"eaebd8c719c3d135",
"a1b2c3d4e5f6a7b8"
]
]
},
{
"id": "9bbd4fade968036d",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Work Order buttons",
"func": "switch (msg.action) {\n case \"upload-excel\":\n msg._mode = \"upload\";\n return [msg, null, null, null];\n case \"refresh-work-orders\":\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY created_at DESC;\";\n return [null, msg, null, null];\n case \"start-work-order\": {\n msg._mode = \"start-check-progress\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for start\", msg);\n return [null, null, null, null];\n }\n\n // Store order data temporarily for after DB query\n flow.set(\"pendingWorkOrder\", order);\n\n // Query database to check for existing progress\n msg.topic = \"SELECT cycle_count, good_parts, scrap_parts, progress_percent, target_qty FROM work_orders WHERE work_order_id = ? LIMIT 1\";\n msg.payload = [order.id];\n\n node.warn(`[START-WO] Checking progress for WO ${order.id}`);\n return [null, msg, null, null];\n }\n case \"resume-work-order\": {\n const now = Date.now();\n global.set(\"productionStartTime\", now);\n global.set(\"actualRunTime\", 0);\n global.set(\"lastStartChangeTime\", now)\n msg._mode = \"resume\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for resume\", msg);\n return [null, null, null, null];\n }\n\n node.warn(`[RESUME-WO] Resuming WO ${order.id} with existing progress`);\n\n // Set status to RUNNING without resetting progress\n msg.topic = \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END WHERE status <> 'DONE'\";\n msg.payload = [order.id, order.id];\n msg.startOrder = order;\n\n // Load existing values into global state\n // IMPORTANT: Also set scrap so good_parts calculation is correct\n order.scrap = Number(order.scrap) || 0;\n order.good = Number(order.good_parts) || 0;\n\n global.set(\"activeWorkOrder\", order);\n global.set(\"cycleCount\", Number(order.cycle_count) || 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n\n node.warn(`[RESUME-WO] Set cycleCount=${order.cycle_count}, scrap=${order.scrap}, good=${order.good}`);\n return [null, null, msg, null];\n }\n case \"restart-work-order\": {\n const now = Date.now();\n global.set(\"productionStartTime\", now);\n global.set(\"actualRunTime\", 0);\n global.set(\"lastStartChangeTime\", now)\n msg._mode = \"restart\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for restart\", msg);\n return [null, null, null, null];\n }\n\n node.warn(`[RESTART-WO] Restarting WO ${order.id} - resetting progress to 0`);\n\n // Reset progress in database AND set status to RUNNING\n msg.topic = \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, cycle_count = 0, good_parts = 0, scrap_parts = 0, progress_percent = 0, updated_at = NOW() WHERE work_order_id = ? OR status = 'RUNNING'\";\n msg.payload = [order.id, order.id];\n msg.startOrder = order;\n\n // Initialize global state to 0\n order.scrap = 0;\n order.good = 0;\n\n global.set(\"activeWorkOrder\", order);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n\n node.warn(`[RESTART-WO] Reset cycleCount=0, scrap=0, good=0`);\n return [null, null, msg, null];\n }\n case \"complete-work-order\": {\n global.set(\"productionStartTime\", null);\n msg._mode = \"complete\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for complete\", msg);\n return [null, null, null, null];\n }\n\n // Get final values from global state before clearing\n const activeOrder = global.get(\"activeWorkOrder\") || {};\n const finalCycleCount = Number(global.get(\"cycleCount\") || 0);\n const finalGoodParts = Number(activeOrder.good) || 0;\n const finalScrapParts = Number(activeOrder.scrap) || 0;\n\n node.warn(`[COMPLETE] Persisting final values: cycles=${finalCycleCount}, good=${finalGoodParts}, scrap=${finalScrapParts}`);\n\n msg.completeOrder = order;\n\n // SQL: Persist final counts AND set status to DONE\n msg.topic = \"UPDATE work_orders SET status = 'DONE', cycle_count = ?, good_parts = ?, scrap_parts = ?, progress_percent = 100, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [finalCycleCount, finalGoodParts, finalScrapParts, order.id];\n\n // Clear ALL state on completion\n global.set(\"activeWorkOrder\", null);\n global.set(\"trackingEnabled\", false);\n global.set(\"productionStarted\", false);\n global.set(\"kpiStartupMode\", false);\n global.set(\"operatingTime\", 0);\n global.set(\"lastCycleTime\", null);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n global.set(\"actualRunTime\", 0);\n global.set(\"lastStateChangeTime\", null);\n \n // ============================================================\n // HIGH SCRAP DETECTION\n // ============================================================\n const targetQty = Number(activeOrder.target) || 0;\n const scrapCount = finalScrapParts;\n const scrapPercent = targetQty > 0 ? (scrapCount / targetQty) * 100 : 0;\n\n // Trigger: Scrap > 10% of target quantity\n let anomalyMsg = null;\n if (scrapPercent > 10 && targetQty > 0) {\n const severity = scrapPercent > 25 ? 'critical' : 'warning';\n\n const highScrapAnomaly = {\n anomaly_type: 'high-scrap',\n severity: severity,\n title: `High Waste Detected`,\n description: `Work order completed with ${scrapCount} scrap parts (${scrapPercent.toFixed(1)}% of target ${targetQty}). Why is there so much waste?`,\n data: {\n scrap_count: scrapCount,\n target_quantity: targetQty,\n scrap_percent: Math.round(scrapPercent * 10) / 10,\n good_parts: finalGoodParts,\n total_cycles: finalCycleCount\n },\n kpi_snapshot: {\n oee: (msg.kpis && msg.kpis.oee) || global.get(\"currentKPIs\")?.oee || 0,\n availability: (msg.kpis && msg.kpis.availability) || global.get(\"currentKPIs\")?.availability || 0,\n performance: (msg.kpis && msg.kpis.performance) || global.get(\"currentKPIs\")?.performance || 0,\n quality: (msg.kpis && msg.kpis.quality) || global.get(\"currentKPIs\")?.quality || 0\n },\n work_order_id: order.id,\n cycle_count: finalCycleCount,\n timestamp: Date.now(),\n status: 'active'\n };\n\n node.warn(`[HIGH SCRAP] Detected ${scrapPercent.toFixed(1)}% scrap on work order ${order.id}`);\n\n // Send to Event Logger (output 5)\n anomalyMsg = {\n topic: \"anomaly-detected\",\n payload: [highScrapAnomaly]\n };\n }\n\n node.warn('[COMPLETE] Cleared all state flags');\n return [null, null, null, msg, anomalyMsg];\n }\n case \"get-current-state\": {\n // Return current state for UI sync on tab switch\n const activeOrder = global.get(\"activeWorkOrder\") || null;\n const trackingEnabled = global.get(\"trackingEnabled\") || false;\n const productionStarted = global.get(\"productionStarted\") || false;\n const kpis = global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n msg._mode = \"current-state\";\n msg.payload = {\n activeWorkOrder: activeOrder,\n trackingEnabled: trackingEnabled,\n productionStarted: productionStarted,\n kpis: kpis\n };\n\n return [null, msg, null, null];\n }\n case \"restore-session\": {\n // Query DB for any RUNNING work order on startup\n msg._mode = \"restore-query\";\n msg.topic = \"SELECT * FROM work_orders WHERE status = 'RUNNING' LIMIT 1\";\n msg.payload = [];\n node.warn('[RESTORE] Checking for running work order on startup');\n return [null, msg, null, null];\n }\n case \"scrap-entry\": {\n const { id, scrap } = msg.payload || {};\n const scrapNum = Number(scrap) || 0;\n\n if (!id) {\n node.error(\"No work order id supplied for scrap entry\", msg);\n return [null, null, null, null];\n }\n\n const activeOrder = global.get(\"activeWorkOrder\");\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrap = (Number(activeOrder.scrap) || 0) + scrapNum;\n global.set(\"activeWorkOrder\", activeOrder);\n }\n\n global.set(\"scrapPromptIssuedFor\", null);\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum };\n // SQL with bound parameters for safety\n msg.topic = \"UPDATE work_orders SET scrap_parts = scrap_parts + ?, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [scrapNum, id];\n\n return [null, null, msg, null];\n }\n case \"scrap-skip\": {\n const { id, remindAgain } = msg.payload || {};\n\n if (!id) {\n node.error(\"No work order id supplied for scrap skip\", msg);\n return [null, null, null, null];\n }\n\n if (remindAgain) {\n global.set(\"scrapPromptIssuedFor\", null);\n }\n\n msg._mode = \"scrap-skipped\";\n return [null, null, null, null];\n }\n case \"start\": {\n // START with KPI timestamp init - FIXED\n const now = Date.now();\n const shifts = global.get('shifts') || [{ start: '06:00', end: '13:00' }];\n const shiftChangeComp = global.get('shiftChangeCompensation') || 10; // minutes\n const lunchBreak = global.get('lunchBreakMinutes') || 30; // minutes\n\n\n let totalShiftSeconds = 0;\n shifts.forEach(shift => {\n const [startH, startM] = (shift.start || '06:00').split(':').map(Number);\n const [endH, endM] = (shift.end || '15:00').split(':').map(Number);\n\n let startMinutes = startH * 60 + startM;\n let endMinutes = endH * 60 + endM;\n\n // Handle overnight shifts\n if (endMinutes <= startMinutes) {\n endMinutes += 24 * 60;\n }\n\n totalShiftSeconds += (endMinutes - startMinutes) * 60;\n });\n const compensationSeconds = shifts.length * shiftChangeComp * 60; // shift change per shift\n const lunchSeconds = lunchBreak * 60; \n const plannedProductionTime = Math.max(0, totalShiftSeconds - compensationSeconds - lunchSeconds);\n global.set(\"plannedProductionTime\", plannedProductionTime);\n\n global.set(\"stopTime\", 0); // Initialize stop time\n global.set(\"trackingEnabled\", true);\n global.set(\"trackingEnabled\", true);\n global.set(\"productionStarted\", true);\n global.set(\"kpiStartupMode\", true);\n global.set(\"kpiBuffer\", []);\n global.set(\"lastKPIRecordTime\", now - 60000);\n global.set(\"productionStartTime\", now);\n global.set(\"lastMachineCycleTime\", now);\n global.set(\"lastCycleTime\", now);\n global.set(\"operatingTime\", 0);\n global.set(\"actualRunTime\", 0);\n global.set(\"lastStartChangeTime\", now)\n global.set(\"lastCycleCompletionTime\", null); // Will be set on first cycle\n node.warn('[START] Initialized: trackingEnabled=true, productionStarted=true, kpiStartupMode=true, operatingTime=0');\n node.warn(`[START] Planned production time: ${(plannedProductionTime / 3600).toFixed(2)} hours`);\n\n const activeOrder = global.get(\"activeWorkOrder\") || {};\n msg._mode = \"production-state\";\n\n msg.payload = msg.payload || {};\n\n msg.trackingEnabled = true;\n msg.productionStarted = true;\n msg.machineOnline = true;\n\n msg.payload.trackingEnabled = true;\n msg.payload.productionStarted = true;\n msg.payload.machineOnline = true;\n\n return [null, msg, null, null];\n }\n case \"stop\": {\n global.set(\"trackingEnabled\", false);\n global.set(\"productionStarted\", false);\n node.warn('[STOP] Set trackingEnabled=false, productionStarted=false');\n\n // Send UI update so button state reflects change\n msg._mode = \"production-state\";\n msg.payload = msg.payload || {};\n msg.trackingEnabled = false;\n msg.productionStarted = false;\n msg.machineOnline = true;\n msg.payload.trackingEnabled = false;\n msg.payload.productionStarted = false;\n msg.payload.machineOnline = true;\n\n return [null, msg, null, null];\n }\n case \"start-tracking\": {\n const activeOrder = global.get('activeOrder') || {};\n\n if (!activeOrder.id) {\n node.warn('[START] Cannot start tracking: No active order loaded.');\n return [null, { topic: \"alert\", payload: \"Error: No active work order loaded.\" }, null, null];\n }\n\n const now = Date.now();\n global.set(\"trackingEnabled\", true);\n global.set(\"kpiBuffer\", []);\n global.set(\"lastKPIRecordTime\", now - 60000);\n global.set(\"lastMachineCycleTime\", now);\n global.set(\"lastCycleTime\", now);\n global.set(\"operatingTime\", 0.001);\n node.warn('[START] Cleared kpiBuffer for fresh production run');\n\n // FIX: Use work_order_id consistently\n const dbMsg = {\n topic: `UPDATE work_orders SET production_start_time = ${now}, is_tracking = 1 WHERE work_order_id = '${activeOrder.id}'`,\n payload: []\n };\n\n const stateMsg = {\n topic: \"machineStatus\",\n payload: msg.payload || {}\n };\n\n stateMsg.payload.trackingEnabled = true;\n stateMsg.payload.productionStarted = true;\n stateMsg.payload.machineOnline = true;\n\n return [dbMsg, stateMsg, null, null];\n }\n}",
"outputs": 5,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1410,
"y": 560,
"wires": [
[
"15a6b7b6d8f39fe4"
],
[
"f6ad294bc02618c9",
"00b6132848964bd9",
"f2bab26e27e2023d"
],
[
"f6ad294bc02618c9",
"00b6132848964bd9"
],
[
"f6ad294bc02618c9"
],
[
"event_logger_node_id"
]
]
},
{
"id": "010de5af3ced0ae3",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link out 2",
"mode": "link",
"links": [
"65ddb4cca6787bde"
],
"x": 525,
"y": 300,
"wires": []
},
{
"id": "65ddb4cca6787bde",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link in 2",
"links": [
"010de5af3ced0ae3"
],
"x": 1275,
"y": 560,
"wires": [
[
"9bbd4fade968036d"
]
]
},
{
"id": "596b390d7aaf69fb",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Build Insert SQL",
"func": "const rows = Array.isArray(msg.payload) ? msg.payload : [];\nconst vals = rows.map(r => `(\n'${r[\"Work Order ID\"]}',\n'${r[\"SKU\"]}',\n${Number(r[\"Target Quantity\"]) || 0},\n${Number(r[\"Theoretical Cycle Time (Seconds)\"]) || 0},\n'PENDING')`).join(',\\n');\n\nmsg.topic = `\nINSERT INTO work_orders (work_order_id, sku, target_qty, cycle_time, status)\nVALUES\n${vals}\nON DUPLICATE KEY UPDATE\n sku=VALUES(sku),\n target_qty=VALUES(target_qty),\n cycle_time=VALUES(cycle_time);\n`;\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1800,
"y": 500,
"wires": [
[
"f6ad294bc02618c9"
]
]
},
{
"id": "f6ad294bc02618c9",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"mydb": "00d8ad2b0277f906",
"name": "mariaDB",
"x": 1680,
"y": 560,
"wires": [
[
"progress_check_handler_node"
]
]
},
{
"id": "graph_mariadb_node",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "7620ddfb5d167d4b",
"mydb": "00d8ad2b0277f906",
"name": "mariaDB (Graph Data)",
"x": 1600,
"y": 80,
"wires": [
[
"8fa69225b6ab996f"
]
]
},
{
"id": "f2bab26e27e2023d",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Back to UI",
"func": "const mode = msg._mode || '';\nconst started = msg.startOrder || null;\nconst completed = msg.completeOrder || null;\n\ndelete msg._mode;\ndelete msg.startOrder;\ndelete msg.completeOrder;\ndelete msg.action;\ndelete msg.filename;\n\n// ========================================================\n// MODE: UPLOAD\n// ========================================================\nif (mode === \"upload\") {\n msg.topic = \"uploadStatus\";\n msg.payload = { message: \"✅ Work orders uploaded successfully.\" };\n return [msg, null, null, null];\n}\n\n// ========================================================\n// MODE: SELECT (Load Work Orders)\n// ========================================================\nif (mode === \"select\") {\n const rawRows = Array.isArray(msg.payload) ? msg.payload : [];\n msg.topic = \"workOrdersList\";\n msg.payload = rawRows.map(row => ({\n id: row.work_order_id ?? row.id ?? \"\",\n sku: row.sku ?? \"\",\n target: Number(row.target_qty ?? row.target ?? 0),\n good: Number(row.good_parts ?? row.good ?? 0),\n scrap: Number(row.scrap_count ?? row.scrap ?? 0),\n progressPercent: Number(row.progress_percent ?? row.progress ?? 0),\n status: (row.status ?? \"PENDING\").toUpperCase(),\n lastUpdateIso: row.updated_at ?? row.last_update ?? null,\n cycleTime: Number(row.cycle_time ?? row.theoretical_cycle_time ?? 0)\n }));\n return [msg, null, null, null];\n}\n\n// ========================================================\n// MODE: START WORK ORDER\n// ========================================================\nif (mode === \"start\") {\n const order = started || {};\n const kpis = msg.kpis || global.get(\"currentKPIs\") || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n good: Number(order.good) || 0,\n scrap: Number(order.scrap) || 0,\n cycleTime: Number(order.cycleTime || order.theoreticalCycleTime || 0),\n progressPercent: Number(order.progressPercent) || 0,\n lastUpdateIso: order.lastUpdateIso || null,\n kpis: kpis\n }\n };\n\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: COMPLETE WORK ORDER\n// ========================================================\nif (mode === \"complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: CYCLE UPDATE DURING PRODUCTION\n// ========================================================\nif (mode === \"cycle\") {\n const cycle = msg.cycle || {};\n\n const workOrderMsg = {\n topic: \"workOrderCycle\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n good: Number(cycle.good) || 0,\n scrap: Number(cycle.scrap) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: cycle.lastUpdateIso || new Date().toISOString(),\n status: cycle.progressPercent >= 100 ? \"DONE\" : \"RUNNING\"\n }\n };\n\n const kpis = msg.kpis || global.get(\"currentKPIs\") || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n good: Number(cycle.good) || 0,\n scrap: Number(cycle.scrap) || 0,\n cycleTime: Number(cycle.cycleTime) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: cycle.lastUpdateIso || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return [workOrderMsg, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: MACHINE PRODUCTION STATE\n// ========================================================\nif (mode === \"production-state\") {\n const homeMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: msg.machineOnline ?? true,\n productionStarted: !!msg.productionStarted,\n trackingEnabled: msg.payload?.trackingEnabled ?? msg.trackingEnabled ?? false\n }\n };\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: CURRENT STATE (for tab switch sync)\n// ========================================================\nif (mode === \"current-state\") {\n const state = msg.payload || {};\n const homeMsg = {\n topic: \"currentState\",\n payload: {\n activeWorkOrder: state.activeWorkOrder,\n trackingEnabled: state.trackingEnabled,\n productionStarted: state.productionStarted,\n kpis: state.kpis\n }\n };\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: RESTORE QUERY (startup state recovery)\n// ========================================================\nif (mode === \"restore-query\") {\n const rows = Array.isArray(msg.payload) ? msg.payload : [];\n \n if (rows.length > 0) {\n const row = rows[0];\n const restoredOrder = {\n id: row.work_order_id || row.id || \"\",\n sku: row.sku || \"\",\n target: Number(row.target_qty || row.target || 0),\n good: Number(row.good_parts || row.good || 0),\n scrap: Number(row.scrap_parts || row.scrap || 0),\n progressPercent: Number(row.progress_percent || 0),\n cycleTime: Number(row.cycle_time || 0),\n lastUpdateIso: row.updated_at || null\n };\n \n // Restore global state\n global.set(\"activeWorkOrder\", restoredOrder);\n global.set(\"cycleCount\", Number(row.cycle_count) || 0);\n // Don't auto-start tracking - user must click START\n global.set(\"trackingEnabled\", false);\n global.set(\"productionStarted\", false);\n \n node.warn('[RESTORE] Restored work order: ' + restoredOrder.id + ' with ' + global.get(\"cycleCount\") + ' cycles');\n \n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: restoredOrder\n };\n\n // Set status back to RUNNING in database (if not already DONE)\n // This prevents user from having to \"Load\" the work order again\n const dbMsg = {\n topic: \"UPDATE work_orders SET status = 'RUNNING', updated_at = NOW() WHERE work_order_id = ? AND status != 'DONE'\",\n payload: [restoredOrder.id]\n };\n\n \n return [dbMsg, homeMsg, null, null];\n } else {\n node.warn('[RESTORE] No running work order found');\n }\n return [null, null, null, null];\n}\n\n// ========================================================\n// MODE: SCRAP PROMPT\n// ========================================================\nif (mode === \"scrap-prompt\") {\n const prompt = msg.scrapPrompt || {};\n\n const homeMsg = { topic: \"scrapPrompt\", payload: prompt };\n const tabMsg = { ui_control: { tab: \"Home\" } };\n\n // output1: nothing\n // output2: home template\n // output3: tab navigation\n // output4: graphs template (unused here)\n return [null, homeMsg, tabMsg, null];\n}\n\n// ========================================================\n// MODE: SCRAP UPDATE\n// ========================================================\nif (mode === \"scrap-update\") {\n const activeOrder = global.get(\"activeWorkOrder\") || {};\n const kpis = msg.kpis || global.get(\"currentKPIs\") || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: activeOrder.id || \"\",\n sku: activeOrder.sku || \"\",\n target: Number(activeOrder.target) || 0,\n good: Number(activeOrder.good) || 0,\n scrap: Number(activeOrder.scrap) || 0,\n cycleTime: Number(activeOrder.cycleTime) || 0,\n progressPercent: Number(activeOrder.progressPercent) || 0,\n lastUpdateIso: activeOrder.lastUpdateIso || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: SCRAP COMPLETE\n// ========================================================\nif (mode === \"scrap-complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// MODE: CHARTS → SEND REAL DATA TO GRAPH TEMPLATE\n// ========================================================\n//if (mode === \"charts\") {\n\n// const realOEE = msg.realOEE || global.get(\"realOEE\") || [];\n// const realAvailability = msg.realAvailability || global.get(\"realAvailability\") || [];\n// const realPerformance = msg.realPerformance || global.get(\"realPerformance\") || [];\n// const realQuality = msg.realQuality || global.get(\"realQuality\") || [];\n\n// const chartsMsg = {\n// topic: \"chartsData\",\n// payload: {\n// oee: realOEE,\n// availability: realAvailability,\n// performance: realPerformance,\n// quality: realQuality\n// }\n// };\n\n // Send ONLY to output #4\n// return [null, null, null, chartsMsg];\n//}\n\n\n// ========================================================\n// MODE: RESUME-PROMPT\n// ========================================================\nif (mode === \"resume-prompt\") {\n // Forward the resume prompt to Home UI\n // Also set activeWorkOrder so Start button becomes enabled\n const order = msg.payload.order || null;\n\n if (order) {\n // Set activeWorkOrder in global so Start button is enabled\n global.set(\"activeWorkOrder\", order);\n node.warn(`[RESUME-PROMPT] Set activeWorkOrder to ${order.id} - Start button should now be enabled`);\n }\n\n // Send prompt message to Home template\n const homeMsg = {\n topic: msg.topic || \"resumePrompt\",\n payload: msg.payload\n };\n\n return [null, homeMsg, null, null];\n}\n\n// ========================================================\n// DEFAULT\n// ========================================================\nreturn [null, null, null, null];\n",
"outputs": 4,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 2190,
"y": 560,
"wires": [
[
"0779932734d8201c"
],
[
"64661fe6aa2cb83d"
],
[
"fd32602c52d896e9"
],
[]
]
},
{
"id": "0779932734d8201c",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 3",
"mode": "link",
"links": [
"6f9de736a538d0d1"
],
"x": 2305,
"y": 520,
"wires": []
},
{
"id": "6f9de736a538d0d1",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 3",
"links": [
"0779932734d8201c"
],
"x": 275,
"y": 320,
"wires": [
[
"f1a2b3c4d5e6f7a8"
]
]
},
{
"id": "3772c25d07b07407",
"type": "book",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "",
"raw": false,
"x": 1810,
"y": 460,
"wires": [
[
"c2b272494952cd98"
]
]
},
{
"id": "c2b272494952cd98",
"type": "sheet",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "",
"sheetName": "Sheet1",
"x": 1930,
"y": 460,
"wires": [
[
"87d85c86e4773aa5"
]
]
},
{
"id": "87d85c86e4773aa5",
"type": "sheet-to-json",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "",
"raw": "false",
"range": "",
"header": "default",
"blankrows": false,
"x": 2070,
"y": 460,
"wires": [
[
"596b390d7aaf69fb"
]
]
},
{
"id": "15a6b7b6d8f39fe4",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Base64",
"func": "const filename =\n msg.filename ||\n (msg.meta && msg.meta.filename) ||\n (msg.payload && msg.payload.filename) ||\n msg.name ||\n 'upload.xlsx';\n\nconst candidates = [];\nif (typeof msg.payload === 'string') candidates.push(msg.payload);\nif (msg.payload && typeof msg.payload.payload === 'string') candidates.push(msg.payload.payload);\nif (msg.payload && typeof msg.payload.file === 'string') candidates.push(msg.payload.file);\nif (msg.payload && typeof msg.payload.base64 === 'string') candidates.push(msg.payload.base64);\nif (typeof msg.file === 'string') candidates.push(msg.file);\nif (typeof msg.data === 'string') candidates.push(msg.data);\n\nfunction stripDataUrl(s) {\n return (s && s.startsWith('data:')) ? s.split(',')[1] : s;\n}\n\nlet b64 = candidates.map(stripDataUrl).find(s => typeof s === 'string' && s.length > 0);\nif (!b64 && Buffer.isBuffer(msg.payload)) { msg.filename = filename; return msg; }\nif (!b64) { node.error('No base64 data found on msg', msg); return null; }\n\nmsg.payload = Buffer.from(b64, 'base64');\nmsg.filename = filename;\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1680,
"y": 460,
"wires": [
[
"3772c25d07b07407"
]
]
},
{
"id": "64661fe6aa2cb83d",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 4",
"mode": "link",
"links": [
"16af50d6fce977a8"
],
"x": 2305,
"y": 560,
"wires": []
},
{
"id": "16af50d6fce977a8",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 4",
"links": [
"64661fe6aa2cb83d"
],
"x": 275,
"y": 280,
"wires": [
[
"1821c4842945ecd8"
]
]
},
{
"id": "578c92e75bf0f266",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Refresh Trigger",
"func": "if (msg._mode === \"start\" || msg._mode === \"complete\" || msg._mode === \"resume\" || msg._mode === \"restart\") {\n // Preserve original message for Back to UI (output 2)\n const originalMsg = {...msg};\n // Create select message for refreshing WO table (output 1)\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, originalMsg];\n}\nif (msg._mode === \"cycle\" || msg._mode === \"production-state\") {\n return [null, msg];\n}\nif (msg._mode === \"scrap-prompt\") {\n return [null, msg];\n}\nif (msg._mode === \"restore-query\") {\n // Pass restore query results to Back to UI\n return [null, msg];\n}\nif (msg._mode === \"current-state\") {\n // Pass current state to Back to UI\n return [null, msg];\n}\nif (msg._mode === \"scrap-complete\") {\n // Preserve original message for Back to UI (output 2)\n const originalMsg = {...msg};\n // Create select message for refreshing WO table (output 1)\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, originalMsg];\n}\nreturn [null, msg];",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1960,
"y": 580,
"wires": [
[
"f6ad294bc02618c9"
],
[
"f2bab26e27e2023d"
]
]
},
{
"id": "0d023d87a13bf56f",
"type": "function",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "Machine cycles",
"func": "const current = Number(msg.payload) || 0;\nconst now = Date.now();\n\nlet zeroStreak = flow.get(\"zeroStreak\") || 0;\nzeroStreak = current === 0 ? zeroStreak + 1 : 0;\nflow.set(\"zeroStreak\", zeroStreak);\n\nconst prev = flow.get(\"lastMachineState\") ?? 0;\n\n// =============================================\n// TRACK ACTUAL RUN TIME (must happen BEFORE any early returns)\n// Only track if we have an active work order and tracking is enabled\n// =============================================\nconst activeOrder = global.get(\"activeWorkOrder\");\nconst trackingEnabled = !!global.get(\"trackingEnabled\");\n\nif (trackingEnabled && activeOrder && activeOrder.id) {\n // =============================================\n // THRESHOLD-BASED STOPPAGE DETECTION\n // Runs on EVERY state change to detect gaps\n // =============================================\n const lastCycleTime = global.get(\"lastCycleCompletionTime\") || now;\n const timeSinceLastCycle = now - lastCycleTime;\n const deltaSeconds = timeSinceLastCycle / 1000;\n \n const thresholdMultiplier = global.get(\"thresholdMultiplier\") || 1.5;\n const Tideal = Number(activeOrder.cycleTime) || 5; // seconds\n const ThresholdParo = Tideal * thresholdMultiplier;\n \n // Only analyze gaps when we have a previous cycle to compare against\n // and when transitioning TO state=1 (cycle completion)\n if (current === 1 && prev === 0 && global.get(\"lastCycleCompletionTime\")) {\n let operatingTime = global.get(\"operatingTime\") || 0;\n let stopTime = global.get(\"stopTime\") || 0;\n \n if (deltaSeconds <= ThresholdParo) {\n // Normal operation - all time counts as running\n operatingTime += deltaSeconds;\n } else {\n // Gap detected - split into running + stopped\n operatingTime += Tideal; // Credit one ideal cycle worth (machine was running for that)\n stopTime += (deltaSeconds - Tideal); // Rest is unplanned downtime\n node.warn(`[STOPPAGE] Detected ${(deltaSeconds - Tideal).toFixed(1)}s downtime (gap: ${deltaSeconds.toFixed(1)}s, threshold: ${ThresholdParo.toFixed(1)}s)`);\n }\n \n global.set(\"operatingTime\", operatingTime);\n global.set(\"stopTime\", stopTime);\n }\n \n // =============================================\n // LEGACY: State-based run time tracking (keep as backup/comparison)\n // =============================================\n if (prev === 1) {\n const lastStateChange = global.get(\"lastStateChangeTime\") || now;\n const runDuration = (now - lastStateChange) / 1000;\n\n if (runDuration > 0 && runDuration < 3600) {\n let actualRunTime = global.get(\"actualRunTime\") || 0;\n actualRunTime += runDuration;\n global.set(\"actualRunTime\", actualRunTime);\n }\n }\n}\n\n// ALWAYS update state tracking (before any early returns)\nglobal.set(\"lastStateChangeTime\", now);\nflow.set(\"lastMachineState\", current);\n\n// =============================================\n// MACHINE ONLINE STATUS\n// =============================================\nglobal.set(\"machineOnline\", true);\n\nlet productionRunning = !!global.get(\"productionStarted\");\nlet stateChanged = false;\n\nif (current === 1 && !productionRunning) {\n productionRunning = true;\n stateChanged = true;\n} else if (current === 0 && zeroStreak >= 2 && productionRunning) {\n productionRunning = false;\n stateChanged = true;\n}\n\nglobal.set(\"productionStarted\", productionRunning);\n\nconst stateMsg = stateChanged\n ? {\n _mode: \"production-state\",\n machineOnline: true,\n productionStarted: productionRunning\n }\n : null;\n\n// =============================================\n// EARLY EXIT CONDITIONS\n// =============================================\nconst cavities = Number(global.get(\"moldActive\")) || 1;\n\nif (!activeOrder || !activeOrder.id || cavities <= 0) {\n return [null, stateMsg, { _triggerKPI: true }, null];\n}\n\nif (!trackingEnabled) {\n return [null, stateMsg, { _triggerKPI: true }, null];\n}\n\n// Only count cycles on rising edge (0→1)\nif (prev === 1 || current !== 1) {\n return [null, stateMsg, { _triggerKPI: true }, null];\n}\n\n// =============================================\n// CYCLE COUNTING (only reached on 0→1 transition)\n// =============================================\nlet cycles = Number(global.get(\"cycleCount\") || 0) + 1;\nglobal.set(\"cycleCount\", cycles);\n// Track when this cycle completed (for gap analysis)\nglobal.set(\"lastCycleCompletionTime\", now);\n\n// Clear startup mode after first real cycle\nif (global.get(\"kpiStartupMode\")) {\n global.set(\"kpiStartupMode\", false);\n node.warn('[MACHINE CYCLE] First cycle - cleared kpiStartupMode');\n}\n\nglobal.set(\"lastMachineCycleTime\", now);\n\n// =============================================\n// PRODUCTION METRICS\n// =============================================\nconst scrapTotal = Number(activeOrder.scrap) || 0;\nconst totalProduced = cycles * cavities;\nconst produced = Math.max(0, totalProduced - scrapTotal);\nconst target = Number(activeOrder.target) || 0;\nconst progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0;\n\nactiveOrder.good = produced;\nactiveOrder.progressPercent = progress;\nactiveOrder.lastUpdateIso = new Date().toISOString();\nglobal.set(\"activeWorkOrder\", activeOrder);\n\n// =============================================\n// SCRAP PROMPT CHECK\n// =============================================\nconst promptIssued = global.get(\"scrapPromptIssuedFor\") || null;\nif (!promptIssued && target > 0 && produced >= target) {\n global.set(\"scrapPromptIssuedFor\", activeOrder.id);\n msg._mode = \"scrap-prompt\";\n msg.scrapPrompt = {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n produced\n };\n return [null, msg, null, null];\n}\n\n// =============================================\n// DATABASE UPDATE\n// =============================================\nconst dbMsg = {\n _mode: \"cycle\",\n cycle: {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n good: produced,\n scrap: scrapTotal,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: progress,\n lastUpdateIso: activeOrder.lastUpdateIso,\n machineOnline: true,\n productionStarted: productionRunning\n },\n topic: \"UPDATE work_orders SET good_parts = ?, progress_percent = ?, updated_at = NOW() WHERE work_order_id = ?\",\n payload: [produced, progress, activeOrder.id]\n};\n\nconst kpiTrigger = { _triggerKPI: true };\n\nconst persistWorkOrder = {\n topic: \"UPDATE work_orders SET cycle_count = ?, good_parts = ?, scrap_parts = ?, progress_percent = ?, updated_at = NOW() WHERE work_order_id = ?\",\n payload: [cycles, produced, scrapTotal, progress, activeOrder.id]\n};\n\nreturn [dbMsg, stateMsg, kpiTrigger, persistWorkOrder];",
"outputs": 4,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1800,
"y": 220,
"wires": [
[
"dbc7a5ee041845ed"
],
[
"00b6132848964bd9"
],
[
"00b6132848964bd9"
],
[
"db_guard_db_guard_cycles"
]
]
},
{
"id": "dbc7a5ee041845ed",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "link out 5",
"mode": "link",
"links": [
"76ce53cf1ae40e9c"
],
"x": 1915,
"y": 200,
"wires": []
},
{
"id": "76ce53cf1ae40e9c",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link in 5",
"links": [
"dbc7a5ee041845ed"
],
"x": 1565,
"y": 540,
"wires": [
[
"f6ad294bc02618c9"
]
]
},
{
"id": "e15d6c1f78b644a2",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "link out 6",
"mode": "link",
"links": [
"0d6ec01f421acdef"
],
"x": 1905,
"y": 280,
"wires": []
},
{
"id": "0d6ec01f421acdef",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link in 6",
"links": [
"e15d6c1f78b644a2"
],
"x": 1755,
"y": 580,
"wires": [
[
"578c92e75bf0f266"
]
]
},
{
"id": "fd32602c52d896e9",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 7",
"mode": "link",
"links": [
"2f04a72fdeb67f3f"
],
"x": 2305,
"y": 600,
"wires": []
},
{
"id": "2f04a72fdeb67f3f",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 7",
"links": [
"fd32602c52d896e9"
],
"x": 535,
"y": 420,
"wires": [
[
"a7d58e15929b3d8c"
]
]
},
{
"id": "0b5740c4a2b298b7",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "link out 8",
"mode": "link",
"links": [
"8f890f97aa9257c7"
],
"x": 955,
"y": 660,
"wires": []
},
{
"id": "8f890f97aa9257c7",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 8",
"links": [
"0b5740c4a2b298b7",
"010f9764b4dd50a1"
],
"x": 275,
"y": 480,
"wires": [
[
"f5a6b7c8d9e0f1a2"
]
]
},
{
"id": "00b6132848964bd9",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Calculate KPIs",
"func": "// ========================================\n// OEE KPI ENGINE - INDUSTRY STANDARD\n// ========================================\nif (msg._mode === \"current-state\") {\n node.warn('[KPI] Passing through current-state message to Refresh Trigger');\n return msg;\n}\nconst trackingEnabled = global.get(\"trackingEnabled\") || false;\nconst activeOrder = global.get(\"activeWorkOrder\") || {};\nconst cycleCount = global.get(\"cycleCount\") || 0;\nconst cavities = Number(global.get(\"moldActive\")) || 1;\n\nif (!trackingEnabled || !activeOrder.id) {\n return null;\n}\n\n// ========================================\n// TIMING DATA\n// ========================================\nconst now = Date.now();\nconst productionStartTime = global.get(\"productionStartTime\");\n\n// Guard: If no production start time, we can't calculate\nif (!productionStartTime) {\n node.warn('[KPI] No productionStartTime set - cannot calculate KPIs');\n return null;\n}\n\nconst plannedShiftTime = global.get(\"plannedShiftTime\") || (8 * 60 * 60); // 8 hours default\nconst plannedBreakTime = global.get(\"plannedBreakTime\") || (1 * 60 * 60); // 1 hour break default\nconst plannedProductionTime = plannedShiftTime - plannedBreakTime;\n\n// ========================================\n// INPUT VALUES\n// ========================================\nconst idealCycleTime = Number(activeOrder.cycleTime) || 0; // seconds per cycle\nconst totalPartsProduced = cycleCount * cavities;\nconst scrapParts = Number(activeOrder.scrap) || 0;\nconst goodParts = Math.max(0, totalPartsProduced - scrapParts);\n\n// Time calculations\nconst elapsedSeconds = (now - productionStartTime) / 1000;\nconst actualRunTime = global.get(\"actualRunTime\") || 0;\n\n// ========================================\n// STARTUP MODE - Show 100% until first cycle\n// ========================================\nif (global.get(\"kpiStartupMode\")) {\n msg.kpis = { oee: 100, availability: 100, performance: 100, quality: 100 };\n global.set(\"currentKPIs\", msg.kpis);\n return msg;\n}\n\n// ========================================\n// 1) AVAILABILITY (Industry Standard)\n// Actual Run Time / Planned Production Time\n// ========================================\nlet availability = 0;\nconst operatingTime = global.get(\"operatingTime\") || 0;\nconst configuredPlannedTime = global.get(\"plannedProductionTime\") || 0;\n\nif (configuredPlannedTime > 0 && operatingTime > 0) {\n // Use configured planned production time from shift settings\n // But cap at elapsed time if shift hasn't completed yet\n const relevantPlannedTime = Math.min(elapsedSeconds, configuredPlannedTime);\n availability = (operatingTime / relevantPlannedTime) * 100;\n} else if (elapsedSeconds > 0 && operatingTime > 0) {\n // Fallback: No shift config, use elapsed time as denominator\n availability = (operatingTime / elapsedSeconds) * 100;\n}\navailability = Math.min(100, Math.max(0, availability));\n// ========================================\n// 2) PERFORMANCE (Industry Standard)\n// (Ideal Cycle Time × Cycle Count) / Actual Run Time\n// ========================================\nlet performance = 0;\nconst operatingTimeForPerf = global.get(\"operatingTime\") || 0;\n\nif (operatingTimeForPerf > 0 && idealCycleTime > 0 && cycleCount > 0) {\n const idealTimeForCycles = idealCycleTime * cycleCount;\n performance = (idealTimeForCycles / operatingTimeForPerf) * 100;\n}\nperformance = Math.min(100, Math.max(0, performance));\n\n// ========================================\n// 3) QUALITY (Industry Standard)\n// Good Parts / Total Parts Produced\n// ========================================\nlet quality = 100;\nif (totalPartsProduced > 0) {\n quality = (goodParts / totalPartsProduced) * 100;\n}\nquality = Math.min(100, Math.max(0, quality));\n\n// ========================================\n// 4) OEE (Industry Standard)\n// Availability × Performance × Quality\n// ========================================\nconst oee = (availability * performance * quality) / 10000;\n\n// Round to 1 decimal\nmsg.kpis = {\n availability: Math.round(availability * 10) / 10,\n performance: Math.round(performance * 10) / 10,\n quality: Math.round(quality * 10) / 10,\n oee: Math.round(oee * 10) / 10\n};\n\nglobal.set(\"currentKPIs\", msg.kpis);\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1480,
"y": 640,
"wires": [
[
"578c92e75bf0f266",
"dc9b9a26af05dfa8",
"cycle_kpi_data_merger",
"5834d8be57b34837"
]
]
},
{
"id": "alert_process_function",
"type": "function",
"z": "cac3a4383120cb57",
"g": "2d0188d56fd5c86a",
"name": "Process Alert for DB",
"func": "// Process incoming alert\nif (msg.payload && msg.payload.action === 'alert') {\n const alert = msg.payload;\n\n // Format timestamp for MySQL DATETIME\n const timestamp = alert.timestamp ?\n new Date(alert.timestamp).toISOString().slice(0, 19).replace('T', ' ') :\n new Date().toISOString().slice(0, 19).replace('T', ' ');\n\n // Prepare INSERT query\n const alertType = (alert.type || 'Unknown').replace(/'/g, \"''\"); // Escape quotes\n const description = (alert.description || '').replace(/'/g, \"''\"); // Escape quotes\n\n msg.topic = `\n INSERT INTO alerts_log (timestamp, alert_type, description)\n VALUES ('${timestamp}', '${alertType}', '${description}')\n `;\n\n node.status({\n fill: 'green',\n shape: 'dot',\n text: `Logging: ${alertType}`\n });\n\n // Store original message for passthrough\n msg._originalAlert = alert;\n\n return msg;\n}\n\nreturn null;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 440,
"y": 860,
"wires": [
[
"alert_insert_mysql"
]
]
},
{
"id": "alert_insert_mysql",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "2d0188d56fd5c86a",
"mydb": "00d8ad2b0277f906",
"name": "Log Alert to DB",
"x": 640,
"y": 860,
"wires": [
[]
]
},
{
"id": "394cfca6b72f6444",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 9",
"links": [
"d39000415ba85495",
"8e589c36104b3f0d"
],
"x": 275,
"y": 400,
"wires": [
[
"f3a4b5c6d7e8f9a0"
]
]
},
{
"id": "d39000415ba85495",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 9",
"mode": "link",
"links": [
"394cfca6b72f6444"
],
"x": 1935,
"y": 660,
"wires": []
},
{
"id": "dc9b9a26af05dfa8",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Record KPI History",
"func": "// Complete Record KPI History function with robust initialization and averaging\n\n// ========== INITIALIZATION ==========\n// Initialize buffer\nlet buffer = global.get(\"kpiBuffer\");\nif (!buffer || !Array.isArray(buffer)) {\n buffer = [];\n global.set(\"kpiBuffer\", buffer);\n node.warn('[KPI History] Initialized kpiBuffer');\n}\n\n// Initialize last record time\nlet lastRecordTime = global.get(\"lastKPIRecordTime\");\nif (!lastRecordTime || typeof lastRecordTime !== 'number') {\n // Set to 1 minute ago to ensure immediate recording on startup\n lastRecordTime = Date.now() - 60000;\n global.set(\"lastKPIRecordTime\", lastRecordTime);\n node.warn('[KPI History] Initialized lastKPIRecordTime');\n}\n\n// ========== ACCUMULATE ==========\nconst kpis = msg.payload?.kpis || msg.kpis;\nif (!kpis) {\n node.warn('[KPI History] No KPIs in message, skipping');\n return null;\n}\n\nbuffer.push({\n timestamp: Date.now(),\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n});\n\n// Prevent buffer from growing too large (safety limit)\nif (buffer.length > 100) {\n buffer = buffer.slice(-60); // Keep last 60 entries\n node.warn('[KPI History] Buffer exceeded 100 entries, trimmed to 60');\n}\n\nglobal.set(\"kpiBuffer\", buffer);\n\n// ========== CHECK IF TIME TO RECORD ==========\nconst now = Date.now();\nconst timeSinceLastRecord = now - lastRecordTime;\nconst ONE_MINUTE = 60 * 1000;\n\nif (timeSinceLastRecord < ONE_MINUTE) {\n // Not time to record yet\n return null; // Don't send to charts yet\n}\n\n// ========== CALCULATE AVERAGES ==========\nif (buffer.length === 0) {\n node.warn('[KPI History] Buffer empty at recording time, skipping');\n return null;\n}\n\nconst avg = {\n oee: buffer.reduce((sum, d) => sum + d.oee, 0) / buffer.length,\n availability: buffer.reduce((sum, d) => sum + d.availability, 0) / buffer.length,\n performance: buffer.reduce((sum, d) => sum + d.performance, 0) / buffer.length,\n quality: buffer.reduce((sum, d) => sum + d.quality, 0) / buffer.length\n};\n\nnode.warn(`[KPI History] Recording averaged KPIs from ${buffer.length} samples: OEE=${avg.oee.toFixed(1)}%`);\n\n// ========== RECORD TO HISTORY ==========\n// Load history arrays\nlet oeeHist = global.get(\"realOEE\") || [];\nlet availHist = global.get(\"realAvailability\") || [];\nlet perfHist = global.get(\"realPerformance\") || [];\nlet qualHist = global.get(\"realQuality\") || [];\n\n// Append averaged values\noeeHist.push({ timestamp: now, value: Math.round(avg.oee * 10) / 10 });\navailHist.push({ timestamp: now, value: Math.round(avg.availability * 10) / 10 });\nperfHist.push({ timestamp: now, value: Math.round(avg.performance * 10) / 10 });\nqualHist.push({ timestamp: now, value: Math.round(avg.quality * 10) / 10 });\n\n// Trim arrays (avoid memory explosion)\noeeHist = oeeHist.slice(-300);\navailHist = availHist.slice(-300);\nperfHist = perfHist.slice(-300);\nqualHist = qualHist.slice(-300);\n\n// Save\nglobal.set(\"realOEE\", oeeHist);\nglobal.set(\"realAvailability\", availHist);\nglobal.set(\"realPerformance\", perfHist);\nglobal.set(\"realQuality\", qualHist);\n\n// Update global state\nglobal.set(\"lastKPIRecordTime\", now);\nglobal.set(\"kpiBuffer\", []); // Clear buffer\n\n// Send to graphs\nreturn {\n topic: \"chartsData\",\n payload: {\n oee: oeeHist,\n availability: availHist,\n performance: perfHist,\n quality: qualHist\n }\n};",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1750,
"y": 660,
"wires": [
[
"d39000415ba85495"
]
]
},
{
"id": "fcee023b62d44e58",
"type": "inject",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "Init on Deploy",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 380,
"y": 220,
"wires": [
[
"952cd0a9a4504f2b"
]
]
},
{
"id": "952cd0a9a4504f2b",
"type": "function",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "Initialize Global Variables",
"func": "// Initialize Global Variables - Run on Deploy\nnode.warn('[INIT] Initializing global variables');\n\n// KPI Buffer for averaging\nif (!global.get(\"kpiBuffer\")) {\n global.set(\"kpiBuffer\", []);\n node.warn('[INIT] Set kpiBuffer to []');\n}\n\n// Last KPI record time - set to 1 min ago for immediate first record\nif (!global.get(\"lastKPIRecordTime\")) {\n global.set(\"lastKPIRecordTime\", Date.now() - 60000);\n node.warn('[INIT] Set lastKPIRecordTime');\n}\n\n// Last machine cycle time - set to now to prevent immediate 0% availability\nif (!global.get(\"lastMachineCycleTime\")) {\n global.set(\"lastMachineCycleTime\", Date.now());\n node.warn('[INIT] Set lastMachineCycleTime to prevent 0% availability on startup');\n}\n\n// Last KPI values\nif (!global.get(\"lastKPIValues\")) {\n global.set(\"lastKPIValues\", {});\n node.warn('[INIT] Set lastKPIValues to {}');\n}\n\n// KPI Startup Mode - ensure clean state on deploy\nglobal.set(\"kpiStartupMode\", false);\nnode.warn('[INIT] Set kpiStartupMode to false');\n\n// Tracking flags - ensure clean state\nif (global.get(\"trackingEnabled\") === undefined) {\n global.set(\"trackingEnabled\", false);\n}\nif (global.get(\"productionStarted\") === undefined) {\n global.set(\"productionStarted\", false);\n}\n\nif (!global.get(\"shifts\")) {\n global.set(\"shifts\", [{ start: '06:00', end: '15:00' }]);\n node.warn('[INIT] Set default shift: 06:00-15:00');\n}\n\nif (!global.get(\"shiftChangeCompensation\")) {\n global.set(\"shiftChangeCompensation\", 10);\n}\n\nif (global.get(\"operatingTime\") === undefined) {\n global.set(\"operatingTime\", 0);\n}\n\nif (!global.get(\"lunchBreakMinutes\")) {\n global.set(\"lunchBreakMinutes\", 30);\n}\n\nif (!global.get(\"thresholdMultiplier\")) {\n global.set(\"thresholdMultiplier\", 1.5);\n}\n\nif (global.get(\"stopTime\") === undefined) {\n global.set(\"stopTime\", 0);\n}\n\nif (!global.get(\"plannedProductionTime\")) {\n global.set(\"plannedProductionTime\", 0);\n}\nif (global.get(\"lastCycleCompletionTime\") === undefined) {\n global.set(\"lastCycleCompletionTime\", null);\n}\n\nnode.warn('[INIT] Global variable initialization complete');\n\n// Trigger restore-session to check for running work orders\nconst restoreMsg = { action: \"restore-session\" };\nreturn [null, restoreMsg];\n",
"outputs": 2,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 630,
"y": 220,
"wires": [
[],
[
"010de5af3ced0ae3"
]
]
},
{
"id": "db_guard_db_guard_cycles",
"type": "switch",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "DB Guard (Cycles)",
"property": "topic",
"propertyType": "msg",
"rules": [
{
"t": "istype",
"v": "string",
"vt": "string"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
"x": 1690,
"y": 380,
"wires": [
[
"f6ad294bc02618c9"
]
]
},
{
"id": "8e589c36104b3f0d",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "7620ddfb5d167d4b",
"name": "link out 10",
"mode": "link",
"links": [
"394cfca6b72f6444"
],
"x": 1955,
"y": 80,
"wires": []
},
{
"id": "progress_check_handler_node",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Progress Check Handler",
"func": "// Handle DB result from start-work-order progress check\nif (msg._mode === \"start-check-progress\") {\n const order = flow.get(\"pendingWorkOrder\");\n\n if (!order || !order.id) {\n node.error(\"No pending work order found\", msg);\n return [null, null];\n }\n\n // Get progress from DB query result\n const dbRow = (Array.isArray(msg.payload) && msg.payload.length > 0) ? msg.payload[0] : null;\n const cycleCount = dbRow ? (Number(dbRow.cycle_count) || 0) : 0;\n const goodParts = dbRow ? (Number(dbRow.good_parts) || 0) : 0;\n const scrapParts = dbRow ? (Number(dbRow.scrap_parts) || 0) : 0;\n const targetQty = dbRow ? (Number(dbRow.target_qty) || 0) : (Number(order.target) || 0);\n\n node.warn(`[PROGRESS-CHECK] WO ${order.id}: cycles=${cycleCount}, good=${goodParts}, target=${targetQty}`);\n\n // Check if work order has existing progress\n if (cycleCount > 0 || goodParts > 0) {\n // Work order has progress - send prompt to UI\n node.warn(`[PROGRESS-CHECK] Work order has existing progress - sending prompt to UI`);\n\n const promptMsg = {\n _mode: \"resume-prompt\",\n topic: \"resumePrompt\",\n payload: {\n id: order.id,\n sku: order.sku || \"\",\n cycleCount: cycleCount,\n goodParts: goodParts,\n targetQty: targetQty,\n progressPercent: targetQty > 0 ? Math.round((goodParts / targetQty) * 100) : 0,\n // Include full order object for resume/restart actions\n order: {...order, cycle_count: cycleCount, good_parts: goodParts, scrap: scrapParts}\n }\n };\n\n return [null, promptMsg];\n } else {\n // No existing progress - proceed with normal start\n // But still use DB values (even if 0) to ensure DB is source of truth\n node.warn(`[PROGRESS-CHECK] No existing progress - proceeding with normal start`);\n\n // Update order object with DB values (makes DB the source of truth)\n order.cycle_count = cycleCount; // Will be 0 from DB\n order.good_parts = goodParts; // Will be 0 from DB\n order.scrap = scrapParts; // Will be 0 from DB\n order.good = goodParts; // For consistency\n order.target = targetQty; // From DB\n\n const startMsg = {\n _mode: \"start\",\n startOrder: order,\n topic: \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END WHERE status <> 'DONE'\",\n payload: [order.id, order.id]\n };\n\n // Initialize global state with DB values (even if 0)\n global.set(\"activeWorkOrder\", order);\n global.set(\"cycleCount\", cycleCount); // Use DB value instead of hardcoded 0\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n\n node.warn(`[PROGRESS-CHECK] Initialized from DB: cycles=${cycleCount}, good=${goodParts}, scrap=${scrapParts}`);\n\n return [startMsg, null];\n }\n}\n\n// Pass through all other messages\nreturn [msg, null];",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1910,
"y": 540,
"wires": [
[
"578c92e75bf0f266"
],
[
"f2bab26e27e2023d"
]
]
},
{
"id": "cycle_kpi_data_merger",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Merge Cycle + KPI Data",
"func": "// ============================================================\n// DATA MERGER - Combines Cycle + KPI data for Anomaly Detector\n// ============================================================\n\n// Get KPIs from incoming message (from Calculate KPIs node)\nconst kpis = msg.kpis || msg.payload?.kpis || {};\n\n// Get cycle data from global context\nconst activeOrder = global.get(\"activeWorkOrder\") || {};\nconst cycleCount = global.get(\"cycleCount\") || 0;\nconst cavities = Number(global.get(\"moldActive\")) || 1;\n\n// Build cycle object with all necessary data\nconst cycle = {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n cycles: cycleCount,\n goodParts: Number(activeOrder.good) || 0,\n scrapParts: Number(activeOrder.scrap) || 0,\n target: Number(activeOrder.target) || 0,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: Number(activeOrder.progressPercent) || 0,\n cavities: cavities\n};\n\n// Merge both into the message\nmsg.cycle = cycle;\nmsg.kpis = kpis;\n\n//node.warn(`[DATA MERGER] Merged cycle (count: ${cycleCount}) + KPIs (OEE: ${kpis.oee || 0}%) for anomaly detection`);\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1330,
"y": 480,
"wires": [
[
"anomaly_detector_node_id"
]
]
},
{
"id": "anomaly_detector_node_id",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Anomaly Detector",
"func": "// ============================================================\n// ANOMALY DETECTOR - Enhanced with Tier 1 & 2 Detections\n// Detects production anomalies in real-time\n// ============================================================\n\nconst cycle = msg.cycle || {};\nconst kpis = msg.kpis || {};\nconst activeOrder = global.get(\"activeWorkOrder\") || {};\n\n// Must have active work order to detect anomalies\nif (!activeOrder.id) {\n return null;\n}\n\nconst theoreticalCycleTime = Number(activeOrder.cycleTime) || 0;\nconst now = Date.now();\n\n// Get or initialize anomaly tracking state\nlet anomalyState = global.get(\"anomalyState\") || {\n lastCycleTime: now,\n activeStoppageEvent: null,\n oeeHistory: [],\n performanceHistory: [],\n qualityHistory: [],\n activeOeeDrop: false,\n oeeLowStreak: 0,\n activeQualitySpike: false,\n qualityHighStreak: 0\n\n};\n\n// Configuration\nconst OEE_THRESHOLD = global.get(\"oeeAlertThreshold\") || 90; // Customizable in settings\nconst HISTORY_WINDOW = 20; // Keep last 20 data points for trend analysis\nconst QUALITY_SPIKE_THRESHOLD = 5; // Alert if scrap rate increases by 5%+\nconst PERFORMANCE_THRESHOLD = 85; // NEW: Configurable threshold\n\n\nconst detectedAnomalies = [];\n\n// ============================================================\n// TIER 1: SLOW CYCLE DETECTION\n// Trigger: Actual cycle time > 1.5x theoretical\n// ============================================================\nif (theoreticalCycleTime > 0) {\n const timeSinceLastCycle = now - anomalyState.lastCycleTime;\n const actualCycleTime = timeSinceLastCycle / 1000; // Convert to seconds\n const threshold = theoreticalCycleTime * 1.5;\n\n if (actualCycleTime > threshold && anomalyState.lastCycleTime > 0) {\n const deltaPercent = ((actualCycleTime - theoreticalCycleTime) / theoreticalCycleTime) * 100;\n\n // Determine severity\n let severity = 'warning';\n if (actualCycleTime > theoreticalCycleTime * 2.0) {\n severity = 'critical'; // 100%+ slower\n }\n\n detectedAnomalies.push({\n anomaly_type: 'slow-cycle',\n severity: severity,\n title: `Slow Cycle Detected`,\n description: `Cycle took ${actualCycleTime.toFixed(1)}s (${deltaPercent.toFixed(0)}% slower than expected ${theoreticalCycleTime}s)`,\n data: {\n actual_cycle_time: actualCycleTime,\n theoretical_cycle_time: theoreticalCycleTime,\n delta_percent: Math.round(deltaPercent),\n threshold_multiplier: actualCycleTime / theoreticalCycleTime\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now\n });\n\n node.warn(`[ANOMALY] Slow cycle: ${actualCycleTime.toFixed(1)}s (expected ${theoreticalCycleTime}s)`);\n }\n}\n\n// ============================================================\n// TIER 1: PRODUCTION STOPPAGE DETECTION\n// Trigger: No cycle in > 3x theoretical cycle time\n// ============================================================\nif (theoreticalCycleTime > 0) {\n const timeSinceLastCycle = now - anomalyState.lastCycleTime;\n const stoppageThreshold = theoreticalCycleTime * 3 * 1000; // Convert to ms\n\n // If we have an active stoppage event and a new cycle arrived, resolve it\n if (anomalyState.activeStoppageEvent) {\n // Cycle resumed - mark stoppage as resolved\n anomalyState.activeStoppageEvent.resolved_at = now;\n anomalyState.activeStoppageEvent.auto_resolved = true;\n anomalyState.activeStoppageEvent.status = 'resolved';\n\n const stoppageDuration = (now - anomalyState.activeStoppageEvent.timestamp) / 1000;\n node.warn(`[ANOMALY] Production resumed after ${stoppageDuration.toFixed(0)}s stoppage`);\n\n // Send resolution event\n detectedAnomalies.push(anomalyState.activeStoppageEvent);\n anomalyState.activeStoppageEvent = null;\n }\n\n // Check if production has stopped (only if no active stoppage event)\n if (!anomalyState.activeStoppageEvent && timeSinceLastCycle > stoppageThreshold && anomalyState.lastCycleTime > 0) {\n const stoppageSeconds = timeSinceLastCycle / 1000;\n\n // Determine severity\n let severity = 'warning';\n if (stoppageSeconds > theoreticalCycleTime * 5) {\n severity = 'critical'; // Stopped for 5x+ theoretical time\n }\n\n const stoppageEvent = {\n anomaly_type: 'production-stopped',\n severity: severity,\n title: `Production Stoppage`,\n description: `No cycles detected for ${stoppageSeconds.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n stoppage_duration_seconds: Math.round(stoppageSeconds),\n theoretical_cycle_time: theoreticalCycleTime,\n last_cycle_timestamp: anomalyState.lastCycleTime,\n threshold_multiplier: stoppageSeconds / theoreticalCycleTime\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: 'active'\n };\n\n detectedAnomalies.push(stoppageEvent);\n anomalyState.activeStoppageEvent = stoppageEvent;\n\n node.warn(`[ANOMALY] Production stopped: ${stoppageSeconds.toFixed(0)}s since last cycle`);\n }\n}\n\n// ============================================================\n// TIER 2: OEE DROP DETECTION\n// Trigger: OEE falls below threshold\n// ============================================================\nconst currentOEE = Number(kpis.oee) || 0;\n\nif (currentOEE > 0) {\n const lowThreshold = OEE_THRESHOLD; // e.g. 90\n const recoveryThreshold = OEE_THRESHOLD + 2; // some hysteresis\n\n if (currentOEE < lowThreshold) {\n // Count consecutive low points\n anomalyState.oeeLowStreak = (anomalyState.oeeLowStreak || 0) + 1;\n\n const REQUIRED_STREAK = 3; // only alert after 3 bad readings\n\n // Only fire when we ENTER the bad zone\n if (!anomalyState.activeOeeDrop &&\n anomalyState.oeeLowStreak >= REQUIRED_STREAK) {\n\n let severity = 'warning';\n if (currentOEE < 75) severity = 'critical';\n\n detectedAnomalies.push({\n anomaly_type: 'oee-drop',\n severity,\n title: 'OEE Below Threshold',\n description: `OEE at ${currentOEE.toFixed(1)}% (threshold: ${OEE_THRESHOLD}%)`,\n data: {\n current_oee: currentOEE,\n threshold: OEE_THRESHOLD,\n delta: OEE_THRESHOLD - currentOEE\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: 'active'\n });\n\n anomalyState.activeOeeDrop = true;\n node.warn(`[ANOMALY] OEE drop started at ${currentOEE.toFixed(1)}%`);\n }\n\n } else if (currentOEE >= recoveryThreshold) {\n // We are OUT of the bad zone\n anomalyState.oeeLowStreak = 0;\n\n if (anomalyState.activeOeeDrop) {\n detectedAnomalies.push({\n anomaly_type: 'oee-drop',\n severity: 'info',\n title: 'OEE Recovered',\n description: `OEE recovered to ${currentOEE.toFixed(1)}% (threshold: ${OEE_THRESHOLD}%)`,\n data: {\n current_oee: currentOEE,\n threshold: OEE_THRESHOLD\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: 'resolved'\n });\n\n anomalyState.activeOeeDrop = false;\n node.warn(`[ANOMALY] OEE recovered to ${currentOEE.toFixed(1)}%`);\n }\n }\n}\n\n// Update OEE history for trend analysis\nanomalyState.oeeHistory.push({ timestamp: now, value: currentOEE });\nif (anomalyState.oeeHistory.length > HISTORY_WINDOW) {\n anomalyState.oeeHistory.shift(); // Keep only recent history\n}\n\n// ============================================================\n// TIER 2: QUALITY SPIKE DETECTION\n// Trigger: Sudden increase in scrap/defect rate\n// ============================================================\nconst totalParts = (cycle.goodParts || 0) + (cycle.scrapParts || 0);\nconst currentScrapRate =\n totalParts > 0 ? ((cycle.scrapParts || 0) / totalParts) * 100 : 0;\n\n// Keep history for trend\nanomalyState.qualityHistory.push({ timestamp: now, value: currentScrapRate });\nif (anomalyState.qualityHistory.length > HISTORY_WINDOW) {\n anomalyState.qualityHistory.shift();\n}\n\n// Only evaluate when we have enough data and enough volume in this cycle\nconst MIN_SAMPLES = 5;\nconst MIN_PARTS_THIS_CYCLE = 10;\n\nif (\n anomalyState.qualityHistory.length >= MIN_SAMPLES &&\n totalParts >= MIN_PARTS_THIS_CYCLE\n) {\n const recentHistory = anomalyState.qualityHistory.slice(0, -1); // exclude current\n const avgScrapRate =\n recentHistory.reduce((sum, p) => sum + p.value, 0) /\n recentHistory.length;\n\n const scrapRateIncrease = currentScrapRate - avgScrapRate;\n\n const SPIKE_DELTA = QUALITY_SPIKE_THRESHOLD || 5; // your existing config\n const MIN_SCRAP_RATE = 5; // ignore small scrap percentages\n const RECOVERY_MARGIN = 2; // how far back towards avg to consider \"recovered\"\n\n // ----- When scrap is HIGH / SPIKE ZONE -----\n if (\n currentScrapRate > MIN_SCRAP_RATE &&\n scrapRateIncrease > SPIKE_DELTA\n ) {\n // count how many consecutive \"bad\" cycles\n anomalyState.qualityHighStreak =\n (anomalyState.qualityHighStreak || 0) + 1;\n\n const REQUIRED_STREAK = 2; // require 2 consecutive bad cycles\n\n // fire ONLY when we ENTER the spike (not every cycle)\n if (\n !anomalyState.activeQualitySpike &&\n anomalyState.qualityHighStreak >= REQUIRED_STREAK\n ) {\n let severity = \"warning\";\n if (scrapRateIncrease > 10 || currentScrapRate > 15) {\n severity = \"critical\";\n }\n\n detectedAnomalies.push({\n anomaly_type: \"quality-spike\",\n severity,\n title: \"Quality Issue Detected\",\n description: `Scrap rate at ${currentScrapRate.toFixed(\n 1\n )}% (avg: ${avgScrapRate.toFixed(\n 1\n )}%, +${scrapRateIncrease.toFixed(1)}%)`,\n data: {\n current_scrap_rate: currentScrapRate,\n average_scrap_rate: avgScrapRate,\n increase: scrapRateIncrease,\n scrap_parts: cycle.scrapParts || 0,\n total_parts: totalParts\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: \"active\"\n });\n\n anomalyState.activeQualitySpike = true;\n node.warn(\n `[ANOMALY] Quality spike started: scrap ${currentScrapRate.toFixed(\n 1\n )}% (avg ${avgScrapRate.toFixed(1)}%)`\n );\n }\n } else {\n // ----- When scrap is NORMAL / RECOVERY -----\n anomalyState.qualityHighStreak = 0;\n\n // if we had an active spike, send a single \"resolved\" event\n if (\n anomalyState.activeQualitySpike &&\n currentScrapRate <= avgScrapRate + RECOVERY_MARGIN\n ) {\n detectedAnomalies.push({\n anomaly_type: \"quality-spike\",\n severity: \"info\",\n title: \"Quality Issue Resolved\",\n description: `Scrap rate back to ${currentScrapRate.toFixed(\n 1\n )}% (avg: ${avgScrapRate.toFixed(1)}%)`,\n data: {\n current_scrap_rate: currentScrapRate,\n average_scrap_rate: avgScrapRate\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: \"resolved\"\n });\n\n anomalyState.activeQualitySpike = false;\n node.warn(\n `[ANOMALY] Quality spike resolved: scrap ${currentScrapRate.toFixed(\n 1\n )}%`\n );\n }\n }\n}\n\n// ============================================================\n// TIER 2: PERFORMANCE DEGRADATION\n// Trigger: Consistent underperformance over time\n// ============================================================\nconst currentPerformance = Number(kpis.performance) || 0;\nanomalyState.performanceHistory.push({ timestamp: now, value: currentPerformance });\nif (anomalyState.performanceHistory.length > HISTORY_WINDOW) {\n anomalyState.performanceHistory.shift();\n}\n\n// Check for sustained poor performance (at least 10 data points)\nif (anomalyState.performanceHistory.length >= 10) {\n const recent10 = anomalyState.performanceHistory.slice(-10);\n const avgPerformance = recent10.reduce((sum, point) => sum + point.value, 0) / recent10.length;\n\n const PERF_LOW_THRESHOLD = PERFORMANCE_THRESHOLD; // 85%\n const PERF_RECOVERY_THRESHOLD = PERFORMANCE_THRESHOLD + 3; // 88% to recover\n\n // Check if we're in degraded state\n if (avgPerformance > 0 && avgPerformance < PERF_LOW_THRESHOLD) {\n // Count consecutive low readings\n anomalyState.performanceLowStreak = (anomalyState.performanceLowStreak || 0) + 1;\n\n const REQUIRED_STREAK = 3; // Need 3 consecutive low readings\n\n // Only fire ONCE when entering degraded state\n if (!anomalyState.activePerformanceDegradation &&\n anomalyState.performanceLowStreak >= REQUIRED_STREAK) {\n\n let severity = 'warning';\n if (avgPerformance < 75) {\n severity = 'critical';\n }\n\n detectedAnomalies.push({\n anomaly_type: 'performance-degradation',\n severity: severity,\n title: `Performance Degradation`,\n description: `Performance at ${avgPerformance.toFixed(1)}% (sustained over last 10 cycles)`,\n data: {\n average_performance: avgPerformance,\n current_performance: currentPerformance,\n threshold: PERF_LOW_THRESHOLD,\n sample_size: 10\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: 'active'\n });\n\n // Mark as active so we don't spam\n anomalyState.activePerformanceDegradation = true;\n node.warn(`[ANOMALY] Performance degradation STARTED: ${avgPerformance.toFixed(1)}%`);\n }\n\n } else if (avgPerformance >= PERF_RECOVERY_THRESHOLD) {\n // Performance recovered\n anomalyState.performanceLowStreak = 0;\n\n // Only send recovery message if we were previously in degraded state\n if (anomalyState.activePerformanceDegradation) {\n detectedAnomalies.push({\n anomaly_type: 'performance-degradation',\n severity: 'info',\n title: 'Performance Recovered',\n description: `Performance recovered to ${avgPerformance.toFixed(1)}% (threshold: ${PERF_LOW_THRESHOLD}%)`,\n data: {\n average_performance: avgPerformance,\n current_performance: currentPerformance,\n threshold: PERF_LOW_THRESHOLD\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now,\n status: 'resolved'\n });\n\n anomalyState.activePerformanceDegradation = false;\n node.warn(`[ANOMALY] Performance degradation RESOLVED: ${avgPerformance.toFixed(1)}%`);\n }\n }\n}\n\n// ============================================================\n// TIER 3: PREDICTIVE ALERTS (Trend Analysis)\n// Predict issues before they become critical\n// ============================================================\nif (anomalyState.oeeHistory.length >= 15) {\n // Simple linear trend analysis on OEE\n const recent15 = anomalyState.oeeHistory.slice(-15);\n const firstHalf = recent15.slice(0, 7);\n const secondHalf = recent15.slice(-7);\n\n const avgFirstHalf = firstHalf.reduce((sum, p) => sum + p.value, 0) / firstHalf.length;\n const avgSecondHalf = secondHalf.reduce((sum, p) => sum + p.value, 0) / secondHalf.length;\n\n const oeeTrend = avgSecondHalf - avgFirstHalf;\n\n // Predict if OEE is trending downward significantly\n if (oeeTrend < -5 && avgSecondHalf > OEE_THRESHOLD * 0.95 && avgSecondHalf < OEE_THRESHOLD * 1.05) {\n detectedAnomalies.push({\n anomaly_type: 'predictive-oee-decline',\n severity: 'info',\n title: `Declining OEE Trend Detected`,\n description: `OEE trending down ${Math.abs(oeeTrend).toFixed(1)}% over last 15 cycles. Current: ${avgSecondHalf.toFixed(1)}%`,\n data: {\n trend: oeeTrend,\n first_half_avg: avgFirstHalf,\n second_half_avg: avgSecondHalf,\n prediction: 'OEE may drop below threshold soon'\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n timestamp: now\n });\n\n node.warn(`[PREDICTIVE] OEE trending down: ${oeeTrend.toFixed(1)}%`);\n }\n}\n\n// Update last cycle time for next iteration\nanomalyState.lastCycleTime = now;\nglobal.set(\"anomalyState\", anomalyState);\n\n// ============================================================\n// OUTPUT\n// ============================================================\nif (detectedAnomalies.length > 0) {\n node.warn(`[ANOMALY DETECTOR] Detected ${detectedAnomalies.length} anomaly/ies`);\n\n return {\n topic: \"anomaly-detected\",\n payload: detectedAnomalies,\n originalMsg: msg // Pass through original message for other flows\n };\n}\n\nreturn null; // No anomalies detected\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1350,
"y": 440,
"wires": [
[
"event_logger_node_id",
"e2eda117b5af3874"
]
]
},
{
"id": "event_logger_node_id",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Event Logger (Simplified)",
"func": "// ============================================================\n// EVENT LOGGER - SIMPLIFIED (INSERTS ONLY)\n// Every anomaly gets inserted as a new row\n// ============================================================\n\nconst anomalies = msg.payload || [];\n\nif (!Array.isArray(anomalies) || anomalies.length === 0) {\n return null;\n}\n\n// SQL escape helper\nconst esc = (v) => {\n if (v === null || v === undefined) return 'NULL';\n return \"'\" + String(v).replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"''\") + \"'\";\n};\n\nconst dbInserts = [];\nconst activeAnomalies = [];\n\nanomalies.forEach(anomaly => {\n const ts = Number(anomaly.timestamp) || Date.now();\n const woId = anomaly.work_order_id || '';\n const aType = anomaly.anomaly_type || 'unknown';\n const sev = anomaly.severity || 'warning';\n const title = anomaly.title || '';\n const desc = anomaly.description || '';\n const dataJson = JSON.stringify(anomaly.data || {});\n const kpiJson = JSON.stringify(anomaly.kpi_snapshot || {});\n const cycle = Number(anomaly.cycle_count) || 0;\n\n // Build INSERT query\n const insertQuery = \n \"INSERT INTO anomaly_events \" +\n \"(`event_timestamp`, `work_order_id`, `anomaly_type`, `severity`, `title`, `description`, \" +\n \"`data_json`, `kpi_snapshot_json`, `status`, `cycle_count`, `occurrence_count`, `last_occurrence`) VALUES (\" +\n ts + \", \" +\n esc(woId) + \", \" +\n esc(aType) + \", \" +\n esc(sev) + \", \" +\n esc(title) + \", \" +\n esc(desc) + \", \" +\n esc(dataJson) + \", \" +\n esc(kpiJson) + \", \" +\n \"'active', \" +\n cycle + \", \" +\n \"1, \" +\n ts + \")\";\n\n dbInserts.push({ topic: insertQuery, payload: [] });\n\n // Add to active list for UI\n activeAnomalies.push({\n event_timestamp: ts,\n work_order_id: woId,\n anomaly_type: aType,\n severity: sev,\n title: title,\n description: desc,\n status: 'active',\n timestamp: ts,\n kpi_snapshot: anomaly.kpi_snapshot || {}\n });\n\n node.warn(`[EVENT LOGGER] Inserting ${aType}: ${title}`);\n});\n\n// UI update message\nconst uiMsg = {\n topic: \"anomaly-ui-update\",\n payload: {\n activeCount: activeAnomalies.length,\n activeAnomalies: activeAnomalies,\n updates: activeAnomalies.map(a => ({ status: 'new', anomaly: a }))\n }\n};\n\nreturn [dbInserts, uiMsg];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1370,
"y": 400,
"wires": [
[
"anomaly_split_node_id",
"b3c222d8a0bb64ec"
],
[
"ab9ff66e69294c7c"
]
]
},
{
"id": "anomaly_split_node_id",
"type": "split",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Split DB Inserts",
"splt": "\\n",
"spltType": "str",
"arraySplt": 1,
"arraySpltType": "len",
"stream": false,
"addname": "",
"x": 1940,
"y": 380,
"wires": [
[
"7b79ac1f60867037"
]
]
},
{
"id": "anomaly_mysql_node_id",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"mydb": "00d8ad2b0277f906",
"name": "Anomaly Events DB",
"x": 1020,
"y": 60,
"wires": [
[]
]
},
{
"id": "init_oee_threshold",
"type": "inject",
"z": "cac3a4383120cb57",
"name": "Initialize OEE Threshold (90%)",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": 0.1,
"topic": "",
"payload": "90",
"payloadType": "num",
"x": 360,
"y": 940,
"wires": [
[
"set_oee_threshold_global"
]
]
},
{
"id": "set_oee_threshold_global",
"type": "function",
"z": "cac3a4383120cb57",
"name": "Set OEE Threshold Global",
"func": "// Initialize OEE alert threshold\nconst threshold = Number(msg.payload) || 90;\nglobal.set(\"oeeAlertThreshold\", threshold);\n\nnode.warn(`[CONFIG] OEE Alert Threshold set to ${threshold}%`);\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 680,
"y": 940,
"wires": [
[]
]
},
{
"id": "5834d8be57b34837",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Save KPIs to Database",
"func": "// ============================================================\n// EVENT LOGGER - SIMPLIFIED (INSERTS ONLY)\n// Every anomaly gets inserted as a new row\n// ============================================================\n\nconst kpis = msg.kpis || [];\n\n//if (!Array.isArray(kpis) || kpis.length === 0) {\n// return null;\n//}\n\n// SQL escape helper\n//const esc = (v) => {\n// if (v === null || v === undefined) return 'NULL';\n// return \"'\" + String(v).replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"''\") + \"'\";\n//};\n\nconst dbInserts = [];\nconst currentdkpis = [];\nconst oee = Number(kpis.oee);\nconst performance = Number(kpis.performance);\nconst availability = Number(kpis.availability);\nconst quality = Number(kpis.quality);\nconst ts = Date.now();\n// Build INSERT query\nconst insertQuery = \n \"INSERT INTO kpi_snapshots \" +\n \"(`oee_percent`, `performance_percent`, `availability_percent`, `quality_percent`, `timestamp`) VALUES (\" +\n oee + \", \" +\n performance + \", \" +\n availability + \", \" +\n quality + \", \" + \n ts + \")\";\n\ndbInserts.push({ topic: insertQuery, payload: [] });\n\n\n //node.warn(`[EVENT LOGGER] Inserting ${aType}: ${title}`);\n//});\n\n\nreturn [dbInserts];\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1670,
"y": 700,
"wires": [
[
"4ed29a43614c50e8"
]
]
},
{
"id": "4ed29a43614c50e8",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"mydb": "00d8ad2b0277f906",
"name": "Save kpis to database",
"x": 1900,
"y": 700,
"wires": [
[]
]
},
{
"id": "27056a025a7bce27",
"type": "template",
"z": "cac3a4383120cb57",
"g": "7620ddfb5d167d4b",
"name": "Format query 1",
"field": "topic",
"fieldType": "msg",
"format": "handlebars",
"syntax": "mustache",
"template": "SELECT\n oee_percent,\n availability_percent,\n quality_percent,\n performance_percent,\n timestamp\nFROM (\n SELECT\n oee_percent,\n availability_percent,\n quality_percent,\n performance_percent,\n timestamp\n FROM kpi_snapshots\n ORDER BY timestamp DESC\n LIMIT 50\n) AS t\nORDER BY timestamp ASC;\n",
"output": "str",
"x": 1380,
"y": 80,
"wires": [
[
"graph_mariadb_node"
]
]
},
{
"id": "361bb7e976470121",
"type": "change",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Format data",
"rules": [
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "(\t $series := [\t {\t \"field\": \"oee_percent\",\t \"label\": \"OEE %\" \t },\t {\t \"field\": \"availability_percent\",\t \"label\": \"Availability %\" \t }\t ];\t $xaxis := \"timestamp\";\t [\t {\t \"series\": $series.label,\t \"data\": $series.[\t (\t $yaxis := $.field;\t $$.payload.{\t \"x\": $lookup($, $xaxis),\t \"y\": $lookup($, $yaxis)\t }\t )\t ]\t }\t ]\t)",
"tot": "jsonata"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 1410,
"y": 720,
"wires": [
[]
]
},
{
"id": "8fa69225b6ab996f",
"type": "function",
"z": "cac3a4383120cb57",
"g": "7620ddfb5d167d4b",
"name": "Format Graph Data",
"func": "// Format Graph Data for KPI charts\n\n // Build labels and data arrays\n const labels = [];\n const oeeData = [];\n const availData = [];\n const perfData = [];\n const qualData = [];\n\n \n msg.payload.forEach(row => {\n let x_value = new Date(row.timestamp); \n\n oeeData.push({ x: x_value, y: row.oee_percent });\n availData.push({ x: x_value, y: row.availability_percent });\n perfData.push({ x: x_value, y: row.performance_percent });\n qualData.push({ x: x_value, y: row.quality_percent });\n });\n\n msg.graphData = {\n labels: labels,\n datasets: [\n { label: 'OEE %', data: oeeData },\n { label: 'Availability %', data: availData },\n { label: 'Performance %', data: perfData },\n { label: 'Quality %', data: qualData }\n ]\n };\n\n //node.warn(`[GRAPH DATA] Formatted ${labels.length} KPI history points`);\n\n delete msg.topic;\n delete msg.payload;\n return msg;\n\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1830,
"y": 80,
"wires": [
[
"8e589c36104b3f0d"
]
]
},
{
"id": "9f2a5d3a891a5130",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link out 11",
"mode": "link",
"links": [
"13d0fa52d4b27889"
],
"x": 485,
"y": 360,
"wires": []
},
{
"id": "13d0fa52d4b27889",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "2d0188d56fd5c86a",
"name": "link in 10",
"links": [
"9f2a5d3a891a5130"
],
"x": 275,
"y": 860,
"wires": [
[
"alert_process_function"
]
]
},
{
"id": "ab9ff66e69294c7c",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 12",
"mode": "link",
"links": [
"2c97cebc1585f1ac"
],
"x": 1685,
"y": 420,
"wires": []
},
{
"id": "2c97cebc1585f1ac",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"name": "link in 11",
"links": [
"ab9ff66e69294c7c"
],
"x": 265,
"y": 60,
"wires": [
[
"anomaly_alert_ui_global"
]
]
},
{
"id": "7b79ac1f60867037",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 13",
"mode": "link",
"links": [
"6068cf22f01b287a"
],
"x": 2065,
"y": 380,
"wires": []
},
{
"id": "6068cf22f01b287a",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"name": "link in 12",
"links": [
"7b79ac1f60867037",
"b3c222d8a0bb64ec"
],
"x": 895,
"y": 80,
"wires": [
[
"anomaly_mysql_node_id"
]
]
},
{
"id": "b3c222d8a0bb64ec",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 14",
"mode": "link",
"links": [
"6068cf22f01b287a"
],
"x": 2215,
"y": 420,
"wires": []
},
{
"id": "317c9c75095b9499",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "link out 15",
"mode": "link",
"links": [
"6352d57b1d9746aa"
],
"x": 1475,
"y": 200,
"wires": []
},
{
"id": "6352d57b1d9746aa",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "7620ddfb5d167d4b",
"name": "link in 13",
"links": [
"317c9c75095b9499"
],
"x": 1235,
"y": 80,
"wires": [
[
"27056a025a7bce27"
]
]
},
{
"id": "a976e4f98bc8a253",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"name": "link in 14",
"links": [
"e2eda117b5af3874"
],
"x": 265,
"y": 120,
"wires": [
[
"7f5c75b3e90c5d27"
]
]
},
{
"id": "e2eda117b5af3874",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 16",
"mode": "link",
"links": [
"a976e4f98bc8a253"
],
"x": 1465,
"y": 440,
"wires": []
},
{
"id": "7f5c75b3e90c5d27",
"type": "function",
"z": "cac3a4383120cb57",
"g": "72d151da50327bc8",
"name": "Build Anomaly prompt",
"func": "// msg.payload = array of anomalies coming from the detector\nconst anomalies = Array.isArray(msg.payload) ? msg.payload : [];\nif (!anomalies.length) {\n return null;\n}\n\n// Pick which anomaly to show (here: the first one)\nconst anomaly = anomalies[0];\n\n// Build the prompt object for the UI\nmsg._mode = \"anomaly-prompt\"; // tells the Mode Router what to do\nmsg.anomalyPrompt = {\n id: anomaly.timestamp, // or some unique id\n title: anomaly.title,\n description: anomaly.description,\n severity: anomaly.severity,\n anomaly_type: anomaly.anomaly_type,\n work_order_id: anomaly.work_order_id,\n kpi_snapshot: anomaly.kpi_snapshot,\n data: anomaly.data || {}\n};\n\n// You can keep topic if you like, the Mode Router keys off _mode\nmsg.topic = \"anomalyPrompt\";\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 400,
"y": 120,
"wires": [
[]
]
},
{
"id": "27520d815c7f9785",
"type": "debug",
"z": "cac3a4383120cb57",
"name": "debug 1",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"statusVal": "",
"statusType": "auto",
"x": 1030,
"y": 440,
"wires": []
},
{
"id": "7ae8e8e77eb05158",
"type": "function",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "Shift Config Handler",
"func": "// Shift Config Handler\nconst topic = msg.topic || '';\n\n// Save shift config to global\nif (topic === 'saveShiftConfig') {\n const config = msg.payload || {};\n global.set('shifts', config.shifts || []);\n global.set('shiftChangeCompensation', config.shiftChangeCompensation || 10);\n global.set('lunchBreakMinutes', config.lunchBreakMinutes || 30);\n\n node.status({ fill: 'green', shape: 'dot', text: `${config.shifts.length} shift(s) saved` });\n return null;\n}\n\n// Save threshold config to global\nif (topic === 'saveThresholdConfig') {\n const config = msg.payload || {};\n global.set('thresholdMultiplier', config.thresholdMultiplier || 1.5);\n global.set('oeeAlertThreshold', config.oeeAlertThreshold || 90);\n\n node.status({ fill: 'green', shape: 'dot', text: `Threshold: ${config.thresholdMultiplier}x` });\n return null;\n}\n\n// Load shift config\nif (topic === 'getShiftConfig') {\n msg.topic = 'shiftConfigData';\n msg.payload = {\n shifts: global.get('shifts') || [{ start: '08:00', end: '16:00' }],\n shiftChangeCompensation: global.get('shiftChangeCompensation') || 10,\n lunchBreakMinutes: global.get('lunchBreakMinutes') || 30,\n thresholdMultiplier: global.get('thresholdMultiplier') || 1.5,\n oeeAlertThreshold: global.get('oeeAlertThreshold') || 90\n };\n return msg; // Send back to UI\n}\n// Save all settings at once\nif (topic === 'saveAllSettings') {\n const config = msg.payload || {};\n \n global.set('shifts', config.shifts || []);\n global.set('shiftChangeCompensation', config.shiftChangeCompensation || 10);\n global.set('lunchBreakMinutes', config.lunchBreakMinutes || 30);\n global.set('thresholdMultiplier', config.thresholdMultiplier || 1.5);\n global.set('oeeAlertThreshold', config.oeeAlertThreshold || 90);\n \n node.status({ fill: 'green', shape: 'dot', text: `Saved: ${config.shifts.length} shift(s), threshold ${config.thresholdMultiplier}x` });\n node.warn(`[SHIFT CONFIG] Saved all settings: ${JSON.stringify(config.shifts)}`);\n return null;\n}\n\nreturn null;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 400,
"y": 720,
"wires": [
[
"010f9764b4dd50a1"
]
]
},
{
"id": "95f2b9ce958d6785",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "link in 15",
"links": [
"0a5caf3e23c68e6e"
],
"x": 275,
"y": 720,
"wires": [
[
"7ae8e8e77eb05158"
]
]
},
{
"id": "010f9764b4dd50a1",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "link out 17",
"mode": "link",
"links": [
"8f890f97aa9257c7"
],
"x": 525,
"y": 720,
"wires": []
},
{
"id": "d205c5ce.1feca8",
"type": "ui_chart",
"z": "6309170c5f36ea14",
"name": "24 hours data",
"group": "8880d363.148ac",
"order": 2,
"width": "0",
"height": "0",
"label": "Chart",
"chartType": "line",
"legend": "false",
"xformat": "HH:mm",
"interpolate": "linear",
"nodata": "",
"dot": false,
"ymin": "",
"ymax": "",
"removeOlder": "24",
"removeOlderPoints": "",
"removeOlderUnit": "3600",
"cutout": 0,
"colors": [
"#00e68c",
"#2d2da8",
"#ff7f0e",
"#2ca02c",
"#98df8a",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5"
],
"outputs": 1,
"x": 820,
"y": 360,
"wires": [
[]
]
},
{
"id": "2a63c806.ae4db8",
"type": "mysql",
"z": "6309170c5f36ea14",
"mydb": "a844720c.608d6",
"name": "MYSQL",
"x": 438,
"y": 409,
"wires": [
[
"3d0af460.41906c",
"a03b0066.3ff5a"
]
]
},
{
"id": "86ab4360.50c6c",
"type": "function",
"z": "6309170c5f36ea14",
"name": "Criteria",
"func": "var timeE = msg.payload;\n//Restrict the query to pull the last 24hrs\n//of data instead of the whole db\nmsg.payload = (timeE - (1000*60*60*24));\n node.status({text:msg.payload});\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 255,
"y": 410,
"wires": [
[
"308dd6b1.2a193a"
]
]
},
{
"id": "11c8f8a2.d97147",
"type": "template",
"z": "6309170c5f36ea14",
"name": "Format query 2",
"field": "topic",
"fieldType": "msg",
"format": "handlebars",
"syntax": "mustache",
"template": "SELECT\n CEILING(time/3600000)*3600000 AS timestamp,\n AVG(data1) AS `data1`,\n AVG(data2) AS `data2`\nFROM dbasename\nWHERE time > {{payload}}\nGROUP BY `timestamp`;",
"output": "str",
"x": 444,
"y": 463,
"wires": [
[]
]
},
{
"id": "3d0af460.41906c",
"type": "debug",
"z": "6309170c5f36ea14",
"name": "",
"active": true,
"console": "false",
"complete": "false",
"x": 641,
"y": 409,
"wires": []
},
{
"id": "272494b.eb3d36c",
"type": "inject",
"z": "6309170c5f36ea14",
"name": "Timestamp",
"repeat": "",
"crontab": "",
"once": false,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 200,
"y": 360,
"wires": [
[
"86ab4360.50c6c"
]
]
},
{
"id": "755e08a5.08de88",
"type": "comment",
"z": "6309170c5f36ea14",
"name": "Flow to query database and format for chart",
"info": "",
"x": 361,
"y": 310,
"wires": []
},
{
"id": "433d9ca4.076774",
"type": "mysql",
"z": "6309170c5f36ea14",
"mydb": "a844720c.608d6",
"name": "MYSQL",
"x": 414,
"y": 240,
"wires": [
[]
]
},
{
"id": "d59a31e8.d3772",
"type": "template",
"z": "6309170c5f36ea14",
"name": "Format data",
"field": "topic",
"fieldType": "msg",
"format": "handlebars",
"syntax": "mustache",
"template": "INSERT INTO `dbasename` (`data1`,`data2`,`time`) VALUES ({{data1}},{{data2}},{{time}})",
"output": "str",
"x": 261,
"y": 240,
"wires": [
[
"433d9ca4.076774"
]
]
},
{
"id": "2b71188f.3e2428",
"type": "comment",
"z": "6309170c5f36ea14",
"name": "Flow to insert data into the database",
"info": "",
"x": 330,
"y": 190,
"wires": []
},
{
"id": "308dd6b1.2a193a",
"type": "template",
"z": "6309170c5f36ea14",
"name": "Format query 1",
"field": "topic",
"fieldType": "msg",
"format": "handlebars",
"syntax": "mustache",
"template": "SELECT data1,data2,time FROM dbasename WHERE time > {{payload}}",
"output": "str",
"x": 440,
"y": 359,
"wires": [
[
"2a63c806.ae4db8"
]
]
},
{
"id": "a03b0066.3ff5a",
"type": "change",
"z": "6309170c5f36ea14",
"name": "Format data",
"rules": [
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "(\t $series := [\t { \"field\": \"data1\", \"label\": \"data1 label\" },\t { \"field\": \"data2\", \"label\": \"data2 label\" }\t ];\t $xaxis := \"timestamp\";\t [\t {\t \"series\": $series.label,\t \"data\": $series.[\t (\t $yaxis := $.field;\t $$.payload.{\t \"x\": $lookup($, $xaxis),\t \"y\": $lookup($, $yaxis)\t }\t )\t ]\t }\t ]\t)",
"tot": "jsonata"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 644,
"y": 360,
"wires": [
[
"d205c5ce.1feca8"
]
]
}
]