Files
Inyeccion_Dashboard/flows.json.backup_scrap_20251120_001129
2025-11-20 15:27:34 -06:00

1292 lines
142 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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": "16bb591480852f51",
"type": "group",
"z": "cac3a4383120cb57",
"name": "Start ",
"style": {
"stroke": "#92d04f",
"fill": "#addb7b",
"label": true
},
"nodes": [
"6ad64dedab2042b9",
"0f5ee343ed17976c",
"4e025693949ec4bd",
"b55c91c096a366db",
"33d1f41119e0e262",
"f98ae23b2430c206",
"0d023d87a13bf56f",
"dbc7a5ee041845ed",
"e15d6c1f78b644a2"
],
"x": 34,
"y": 339,
"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"
],
"x": 34,
"y": 19,
"w": 632,
"h": 282
},
{
"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"
],
"x": 804,
"y": 279,
"w": 902,
"h": 202
},
{
"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"
],
"x": 714,
"y": 19,
"w": 722,
"h": 142
},
{
"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": "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.75rem;\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.5rem;\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: 1rem;\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</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\">START</button>\n </article>\n </section>\n </div>\n </main>\n</div>\n<div id=\"scrap-modal\" class=\"modal\" ng-show=\"scrapPrompt.show\">\n <div class=\"modal-card\">\n <h2>Work Order {{ ::scrapPrompt.orderId }}</h2>\n <p>Produced {{ scrapPrompt.produced | number }} pieces (target {{ scrapPrompt.target }})</p>\n <p>Were there any scrap parts?</p>\n <div class=\"prompt-actions\">\n <button class=\"prompt-no\" ng-click=\"submitScrap(0)\">No Scrap</button>\n <button class=\"prompt-yes\" ng-click=\"scrapPrompt.enterMode = true\">Yes</button>\n </div>\n <div ng-if=\"scrapPrompt.enterMode\" class=\"prompt-entry\">\n <input type=\"number\" min=\"0\" ng-model=\"scrapPrompt.scrapCount\" placeholder=\"Enter scrap quantity\" />\n <button ng-click=\"submitScrap(scrapPrompt.scrapCount)\">Submit</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 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 };\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 };\n\n renderDashboard();\n\n if (elements.startButton) {\n elements.startButton.addEventListener(\"click\", function() {\n scope.send({ action: \"start\" });\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(scope.renderDashboard);\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 if (msg.topic === 'machineStatus') {\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 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 };\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.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 };\n });\n})(scope);\n\n\n</script>\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 180,
"y": 60,
"wires": [
[
"a7d58e15929b3d8c"
]
]
},
{
"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, #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(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, #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: 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</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\">Alerts</h1>\n </section>\n\n <section class=\"card quick-alerts\">\n <button class=\"quick-alert-btn\" data-alert=\"Machine stopped\">Machine stopped</button>\n <button class=\"quick-alert-btn\" data-alert=\"Fault in machine\">Fault in machine</button>\n <button class=\"quick-alert-btn\" data-alert=\"No material\">No material</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>Machine stopped</option>\n <option>Fault in machine</option>\n <option>No material</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 var quickButtons = document.querySelectorAll('[data-alert]');\n quickButtons.forEach(function(btn) {\n btn.addEventListener('click', function() {\n var type = btn.getAttribute('data-alert');\n scope.send({ action: 'alert', type: type });\n });\n });\n\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 scope.send({ action: 'alert', type: type, description: description });\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>\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 180,
"y": 140,
"wires": [
[
"a7d58e15929b3d8c"
]
]
},
{
"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/* 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 --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}\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/* CHART GRID: preserve 2x2 symmetry without wrapping glitches */\n.chart-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));\n gap: var(--section-gap);\n}\n\n.chart-card {\n display: flex;\n flex-direction: column;\n gap: clamp(0.85rem, 1vw, 1rem);\n padding: var(--card-pad);\n}\n\n.chart-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.chart-placeholder {\n flex: 1;\n border-radius: var(--radius);\n background: #121c28;\n border: 1px dashed rgba(58, 96, 136, 0.35);\n min-height: 13.75rem;\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 active\" 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\">Graphs</h1>\n </section>\n\n <section class=\"chart-grid\">\n <article class=\"card chart-card\">\n <h2 class=\"chart-title\">OEE Last 24h</h2>\n <div class=\"chart-placeholder\" id=\"chart-oee\"></div>\n </article>\n <article class=\"card chart-card\">\n <h2 class=\"chart-title\">Availability Last 7 days</h2>\n <div class=\"chart-placeholder\" id=\"chart-availability\"></div>\n </article>\n <article class=\"card chart-card\">\n <h2 class=\"chart-title\">Performance Last 7 days</h2>\n <div class=\"chart-placeholder\" id=\"chart-performance\"></div>\n </article>\n <article class=\"card chart-card\">\n <h2 class=\"chart-title\">Quality Last 7 days</h2>\n <div class=\"chart-placeholder\" id=\"chart-quality\"></div>\n </article>\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": 190,
"y": 180,
"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 centralizes Overall Equipment Effectiveness metrics, real-time production details, and critical status indicators. Each tab follows a unified layout so operators can scan performance, alerts, and configuration without relearning navigation.</p>\n </section>\n\n <section class=\"card help-card\">\n <h2 class=\"help-title\">How to Start / Stop Production</h2>\n <p class=\"help-body\">Navigate to the Work Orders tab, select the required job, and use the primary controls to begin or end production. Always log the reason for stoppages using the Alerts tab, and confirm machine readiness before resuming. Follow your facilitys standard operating procedure for approvals and sign-off.</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": 180,
"y": 220,
"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-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: info + Add Mold button -->\n <div class=\"preset-empty\" ng-if=\"!showAddMoldForm\">\n <p>Select a manufacturer and mold from the dropdowns above.</p>\n <p>If you can't find the mold you're looking for, add a new one:</p>\n <button class=\"preset-select-btn\" type=\"button\" ng-click=\"openAddMold()\">Add Mold</button>\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 <button class=\"integrations-button\" type=\"button\" disabled>Connect to ERP</button>\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 scope.$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 // Handle different message types\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 // Initialize by loading manufacturers\n console.log('Initializing Settings page...');\n scope.loadManufacturers();\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",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 190,
"y": 260,
"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}\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;\n min-width: clamp(8.75rem, 12vw, 10rem);\n}\n\n.action-btn:hover {\n transform: translateY(-0.05rem);\n filter: brightness(1.08);\n}\n\n.action-btn:active {\n transform: none;\n filter: brightness(0.96);\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(3),\n.workorders-table thead th:nth-child(4),\n.workorders-table thead th:nth-child(5),\n.workorders-table thead th:nth-child(8),\n.workorders-table tbody td:nth-child(3),\n.workorders-table tbody td:nth-child(4),\n.workorders-table tbody td:nth-child(5),\n.workorders-table tbody td:nth-child(8) {\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.workorders-table tbody tr {\ncursor: pointer;\ntransition: background 0.18s ease, border-color 0.18s ease;\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.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: flex-end;\n font-size: clamp(0.75rem, 0.9vw, 0.85rem);\n color: var(--muted);\n}\n\n\n@media (max-width: 68.75rem) {\n .page-actions {\n width: 100%;\n justify-content: flex-start;\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</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 <button class=\"action-btn\" data-action=\"upload-excel\">Upload Excel</button>\n <button class=\"action-btn\" data-action=\"start-work-order\">Start</button>\n <button class=\"action-btn\" data-action=\"complete-work-order\">Done</button>\n <button class=\"action-btn\" data-action=\"refresh-work-orders\">Refresh</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\">\n <thead>\n <tr>\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\"><span id=\"workorders-count\">0 items</span></div>\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 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 };\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 renderWorkOrders() {\n var orders = Array.isArray(window.workOrders) ? window.workOrders : [];\n orders = orders.filter(function(order) {\n return (order.status || '').toUpperCase() !== 'DONE';\n });\n var selectedId = scope.selectedOrder ? scope.selectedOrder.id : null;\n \n if (elements.tbody) {\n elements.tbody.textContent = '';\n scope.selectedRow = null;\n \n orders.forEach(function(order) {\n var tr = document.createElement('tr');\n tr.dataset.orderId = order.id || '';\n \n function appendCell(content, alignRight) {\n var td = document.createElement('td');\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 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 tr.addEventListener('click', function() {\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 if (selectedId && selectedId === (order.id || '')) {\n tr.classList.add('row-selected');\n scope.selectedRow = tr;\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\nif (elements.buttons) {\nelements.buttons.forEach(function(btn) {\nbtn.addEventListener('click', function() {\nconst action = btn.getAttribute(\"data-action\");\nswitch (action) {\ncase \"upload-excel\": handleUploadExcel(); break;\ncase \"refresh-work-orders\": handleRefresh(); break;\ncase \"start-work-order\": handleStart(); break;\ncase \"complete-work-order\": handleComplete(); break;\ndefault: scope.send({ action }); break;\n}\n});\n});\n}\n function handleUploadExcel() {\n const input = document.getElementById(\"wo-file\");\n input.value = \"\"; // reset\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 // Send Base64 file to Node-RED\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// 2⃣ Refresh work orders\nfunction handleRefresh() {\n scope.send({ action: \"refresh-work-orders\" });\n}\n\n// 3⃣ Start a work order\nfunction handleStart() {\nif (!scope.selectedOrder) return alert(\"Please select a work order first.\");\n scope.send({ action: \"start-work-order\", payload: scope.selectedOrder });\n handleRefresh(); // fetch latest statuses immediately\n}\n\n// 4⃣ Complete a work order\nfunction handleComplete() {\nif (!scope.selectedOrder) return alert(\"No active work order selected.\");\n scope.send({ action: \"complete-work-order\", payload: scope.selectedOrder });\n handleRefresh(); // keep table in sync after completion\n}\n\n// 5⃣ Listen to messages from Node-RED\nscope.$watch(\"msg\", function(msg) {\n if (!msg) return;\n if (msg.topic === \"workOrdersList\") {\n // Update frontend with DB data\n window.workOrders = msg.payload || [];\n scope.renderWorkOrders();\n }\n if (msg.topic === \"uploadStatus\") {\n alert(msg.payload.message || \"Upload complete\");\n handleRefresh();\n }\n});\n\nfunction handleUploadExcel() {\nconst input = document.getElementById(\"wo-file\");\ninput.value = \"\"; // reset\ninput.onchange = function (event) {\nconst file = event.target.files[0];\nif (!file) return;\nconst reader = new FileReader();\nreader.onload = function (e) {\nconst base64 = e.target.result.split(\",\")[1];\n// Send Base64 file to Node-RED\nscope.send({\naction: \"upload-excel\",\nfilename: file.name,\npayload: base64\n});\n};\nreader.readAsDataURL(file);\n};\ninput.click();\n}\n\n// 2⃣ Refresh work orders\nfunction handleRefresh() {\nscope.send({ action: \"refresh-work-orders\" });\n}\n\n// 3⃣ Start a work order\nfunction handleStart() {\nif (!scope.selectedOrder) return alert(\"Please select a work order first.\");\nscope.send({ action: \"start-work-order\", payload: scope.selectedOrder });\n}\n\n// 4⃣ Complete a work order\nfunction handleComplete() {\nif (!scope.selectedOrder) return alert(\"No active work order selected.\");\nscope.send({ action: \"complete-work-order\", payload: scope.selectedOrder });\n}\n\n// 5⃣ Listen to messages from Node-RED\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\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(scope.renderWorkOrders);\n</script>\n",
"storeOutMessages": true,
"fwdInMessages": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 180,
"y": 100,
"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": 420,
"y": 160,
"wires": [
[
"cc81a9dbfd443d62"
]
]
},
{
"id": "cc81a9dbfd443d62",
"type": "ui_ui_control",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "",
"events": "all",
"x": 580,
"y": 160,
"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": 540,
"y": 120,
"wires": [
[]
]
},
{
"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": 170,
"y": 380,
"wires": [
[
"0f5ee343ed17976c"
]
]
},
{
"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": 490,
"y": 400,
"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": 150,
"y": 420,
"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": 330,
"y": 420,
"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": 150,
"y": 460,
"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": 320,
"y": 460,
"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": 910,
"y": 60,
"wires": [
[]
]
},
{
"id": "a1b2c3d4e5f6a7b8",
"type": "function",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "Mold Presets Handler",
"func": "const topic = msg.topic || '';\nconst payload = msg.payload || {};\n\n// Log every incoming request\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": 840,
"y": 120,
"wires": [
[
"c9d8e7f6a5b4c3d2"
]
]
},
{
"id": "c9d8e7f6a5b4c3d2",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"mydb": "00d8ad2b0277f906",
"name": "Mold Presets DB",
"x": 1050,
"y": 120,
"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": 1270,
"y": 120,
"wires": [
[
"0b5740c4a2b298b7"
]
]
},
{
"id": "0a5caf3e23c68e6e",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link out 1",
"mode": "link",
"links": [
"7311641fd09b4d3a"
],
"x": 305,
"y": 260,
"wires": []
},
{
"id": "7311641fd09b4d3a",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "link in 1",
"links": [
"0a5caf3e23c68e6e"
],
"x": 755,
"y": 60,
"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 // start/complete unchanged...\n case \"start-work-order\": {\n msg._mode = \"start\";\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 msg.startOrder = order;\n\n msg.topic = `\n UPDATE work_orders\n SET\n status = CASE\n WHEN work_order_id = '${order.id}' THEN 'RUNNING'\n ELSE 'PENDING'\n END,\n updated_at = CASE\n WHEN work_order_id = '${order.id}' THEN NOW()\n ELSE updated_at\n END\n WHERE status <> 'DONE';\n `;\n\n global.set(\"activeWorkOrder\", order);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n return [null, null, msg, null];\n }\n case \"complete-work-order\": {\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 msg.completeOrder = order;\n msg.topic = `\n UPDATE work_orders\n SET status = 'DONE', updated_at = NOW()\n WHERE work_order_id = '${order.id}';\n `;\n global.set(\"activeWorkOrder\", null);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n return [null, null, null, msg];\n }\n case \"scrap-entry\": {\n const { id, scrap } = msg.payload || {};\n if (!id) {\n node.error(\"No work order id supplied for scrap entry\", msg);\n return [null, null, null, null];\n }\n\n msg._mode = \"scrap-complete\";\n msg.scrapEntry = { id, scrap: Number(scrap) || 0 };\n\n msg.topic = `\n UPDATE work_orders\n SET\n scrap_parts = scrap_parts + ${Number(scrap) || 0},\n good_parts = GREATEST(good_parts - ${Number(scrap) || 0}, 0),\n status = 'DONE',\n updated_at = NOW()\n WHERE work_order_id = '${id}';\n `;\n\n global.set(\"scrapPromptIssuedFor\", null);\n global.set(\"activeWorkOrder\", null);\n global.set(\"cycleCount\", 0);\n\n return [null, null, msg, null]; // route through the MySQL node (same output as start/complete)\n }\n}\n",
"outputs": 4,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 970,
"y": 400,
"wires": [
[
"15a6b7b6d8f39fe4"
],
[
"f6ad294bc02618c9"
],
[
"f6ad294bc02618c9"
],
[
"f6ad294bc02618c9"
]
]
},
{
"id": "010de5af3ced0ae3",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link out 2",
"mode": "link",
"links": [
"65ddb4cca6787bde"
],
"x": 305,
"y": 100,
"wires": []
},
{
"id": "65ddb4cca6787bde",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link in 2",
"links": [
"010de5af3ced0ae3"
],
"x": 845,
"y": 400,
"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": 1240,
"y": 360,
"wires": [
[
"f6ad294bc02618c9"
]
]
},
{
"id": "f6ad294bc02618c9",
"type": "mysql",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"mydb": "00d8ad2b0277f906",
"name": "mariaDB",
"x": 1220,
"y": 400,
"wires": [
[
"578c92e75bf0f266"
]
]
},
{
"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\nif (mode === \"upload\") {\n msg.topic = \"uploadStatus\";\n msg.payload = { message: \"✅ Work orders uploaded successfully.\" };\n return [msg, null];\n}\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];\n}\n\nif (mode === \"start\") {\n const order = started || {};\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 }\n };\n return [null, homeMsg];\n}\n\nif (mode === \"complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg];\n}\n\nif (mode === \"cycle\") {\n const cycle = msg.cycle || {};\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 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 }\n };\n\n return [workOrderMsg, homeMsg];\n}\nif (mode === \"production-state\") {\n const homeMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: msg.machineOnline ?? true,\n productionStarted: !!msg.productionStarted\n }\n };\n return [null, homeMsg];\n}\nif (mode === \"scrap-prompt\") {\n const prompt = msg.scrapPrompt || {};\n const homeMsg = { topic: \"scrapPrompt\", payload: prompt };\n const tabMsg = { ui_control: { tab: \"Home\" } };\n\n // output1: nothing, output2: Home template, output3: Tab navigation\n return [null, homeMsg, tabMsg];\n}\n\nif (mode === \"scrap-complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg];\n}\nreturn [null, null];",
"outputs": 3,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1570,
"y": 400,
"wires": [
[
"0779932734d8201c"
],
[
"64661fe6aa2cb83d"
],
[
"fd32602c52d896e9"
]
]
},
{
"id": "0779932734d8201c",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 3",
"mode": "link",
"links": [
"6f9de736a538d0d1"
],
"x": 1665,
"y": 360,
"wires": []
},
{
"id": "6f9de736a538d0d1",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 3",
"links": [
"0779932734d8201c"
],
"x": 75,
"y": 100,
"wires": [
[
"f1a2b3c4d5e6f7a8"
]
]
},
{
"id": "3772c25d07b07407",
"type": "book",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "",
"raw": false,
"x": 1350,
"y": 320,
"wires": [
[
"c2b272494952cd98"
]
]
},
{
"id": "c2b272494952cd98",
"type": "sheet",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "",
"sheetName": "Sheet1",
"x": 1470,
"y": 320,
"wires": [
[
"87d85c86e4773aa5"
]
]
},
{
"id": "87d85c86e4773aa5",
"type": "sheet-to-json",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "",
"raw": "false",
"range": "",
"header": "default",
"blankrows": false,
"x": 1610,
"y": 320,
"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": 1220,
"y": 320,
"wires": [
[
"3772c25d07b07407"
]
]
},
{
"id": "64661fe6aa2cb83d",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 4",
"mode": "link",
"links": [
"16af50d6fce977a8"
],
"x": 1665,
"y": 400,
"wires": []
},
{
"id": "16af50d6fce977a8",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 4",
"links": [
"64661fe6aa2cb83d"
],
"x": 75,
"y": 60,
"wires": [
[
"1821c4842945ecd8"
]
]
},
{
"id": "578c92e75bf0f266",
"type": "function",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "Refresh Trigger",
"func": "if (msg._mode === \"start\" || msg._mode === \"complete\") {\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, null];\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 === \"scrap-complete\") {\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, null];\n}\nreturn [null, msg];",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1400,
"y": 400,
"wires": [
[
"f6ad294bc02618c9"
],
[
"f2bab26e27e2023d"
]
]
},
{
"id": "0d023d87a13bf56f",
"type": "function",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "Machine cycles",
"func": "const current = Number(msg.payload) || 0;\n\nlet zeroStreak = flow.get(\"zeroStreak\") || 0;\nzeroStreak = current === 0 ? zeroStreak + 1 : 0;\nflow.set(\"zeroStreak\", zeroStreak);\n\nconst prev = flow.get(\"lastMachineState\") ?? 0;\nflow.set(\"lastMachineState\", current);\n\nglobal.set(\"machineOnline\", true); // force ONLINE for now\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\nconst activeOrder = global.get(\"activeWorkOrder\");\nconst cavities = Number(global.get(\"moldActive\") || 0);\nif (!activeOrder || !activeOrder.id || cavities <= 0) {\n // We still want to pass along any state change even if there's no active WO.\n return [null, stateMsg];\n}\n\n// only count rising edges (0 -> 1) for production totals\nif (prev === 1 || current !== 1) {\n return [null, stateMsg];\n}\n\nlet cycles = Number(global.get(\"cycleCount\") || 0) + 1;\nglobal.set(\"cycleCount\", cycles);\n\nconst produced = cycles * cavities;\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\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]; // bypass the DB update on this cycle\n}\n\nconst dbMsg = {\n _mode: \"cycle\",\n cycle: {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n good: produced,\n scrap: Number(activeOrder.scrap) || 0,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: progress,\n lastUpdateIso: activeOrder.lastUpdateIso,\n machineOnline: true,\n productionStarted: productionRunning\n },\n topic: `\n UPDATE work_orders\n SET\n good_parts = ${produced},\n progress_percent = ${progress},\n updated_at = NOW()\n WHERE work_order_id = '${activeOrder.id}';\n `\n};\n\nreturn [dbMsg, stateMsg];",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 640,
"y": 400,
"wires": [
[
"dbc7a5ee041845ed"
],
[
"e15d6c1f78b644a2"
]
]
},
{
"id": "dbc7a5ee041845ed",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "link out 5",
"mode": "link",
"links": [
"76ce53cf1ae40e9c"
],
"x": 755,
"y": 380,
"wires": []
},
{
"id": "76ce53cf1ae40e9c",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link in 5",
"links": [
"dbc7a5ee041845ed"
],
"x": 1115,
"y": 400,
"wires": [
[
"f6ad294bc02618c9"
]
]
},
{
"id": "e15d6c1f78b644a2",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "16bb591480852f51",
"name": "link out 6",
"mode": "link",
"links": [
"0d6ec01f421acdef"
],
"x": 755,
"y": 420,
"wires": []
},
{
"id": "0d6ec01f421acdef",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link in 6",
"links": [
"e15d6c1f78b644a2"
],
"x": 1295,
"y": 440,
"wires": [
[
"578c92e75bf0f266"
]
]
},
{
"id": "fd32602c52d896e9",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "b7ab5e0cc02b9508",
"name": "link out 7",
"mode": "link",
"links": [
"2f04a72fdeb67f3f"
],
"x": 1665,
"y": 440,
"wires": []
},
{
"id": "2f04a72fdeb67f3f",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 7",
"links": [
"fd32602c52d896e9"
],
"x": 335,
"y": 200,
"wires": [
[
"a7d58e15929b3d8c"
]
]
},
{
"id": "0b5740c4a2b298b7",
"type": "link out",
"z": "cac3a4383120cb57",
"g": "28c173789034639c",
"name": "link out 8",
"mode": "link",
"links": [
"8f890f97aa9257c7"
],
"x": 1395,
"y": 120,
"wires": []
},
{
"id": "8f890f97aa9257c7",
"type": "link in",
"z": "cac3a4383120cb57",
"g": "ec32d0a62eacfb22",
"name": "link in 8",
"links": [
"0b5740c4a2b298b7"
],
"x": 75,
"y": 260,
"wires": [
[
"f5a6b7c8d9e0f1a2"
]
]
}
]