Table of contents

Flow 2 — Tool Life

UI-Template Node
UI-Template Node
<style> :root { --bg: #f7f8fa; --card-bg: #ffffff; --border: #e2e4e8; --text: #222; --muted: #6b7280; --good: #16a34a; --warn: #d97706; --critical: #dc2626; --header: #1f2430; --accent: #f5a623; } * { box-sizing: border-box; } .container { padding: 16px; font-family: 'Segoe UI', Arial, sans-serif; background: var(--bg); color: var(--text); } .mono { font-family: 'Roboto Mono', 'Consolas', monospace; } .app-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; } .app-header h2 { margin: 0; font-size: 22px; } .tabs { display: flex; background: var(--header); border-radius: 10px; padding: 4px; gap: 4px; } .tab { border: none; background: transparent; color: #b7bdc9; padding: 8px 18px; font-size: 13px; font-weight: 600; border-radius: 7px; cursor: pointer; transition: background 0.2s ease, color 0.2s ease; border-bottom: 2px solid transparent; } .tab:hover { color: #fff; } .tab.active { background: rgba(245,166,35,0.15); color: #fff; border-bottom: 2px solid var(--accent); } .section-title { font-size: 16px; font-weight: 600; margin: 24px 0 10px; display: flex; align-items: center; justify-content: space-between; } .chart-subtitle { font-size: 11px; color: var(--muted); font-weight: 400; } .overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } .stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .stat-card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } .stat-card .value { font-size: 26px; font-weight: 700; margin-top: 4px; } .stat-card.good .value { color: var(--good); } .stat-card.warn .value { color: var(--warn); } .stat-card.critical .value { color: var(--critical); } .charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; } .chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); margin-bottom: 14px; } .chart-card-title { font-weight: 700; font-size: 13px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; } .donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } .donut-center-value { font-size: 22px; font-weight: 700; fill: var(--text); font-family: 'Roboto Mono', monospace; } .donut-center-label { font-size: 10px; fill: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } .donut-legend { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } .legend-item { display: flex; align-items: center; gap: 6px; } .legend-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .gauge-wrap { display: flex; flex-direction: column; align-items: center; gap: 0; } .gauge-zone { fill: none; stroke-width: 18; } .gauge-zone.critical { stroke: var(--critical); } .gauge-zone.warn { stroke: var(--warn); } .gauge-zone.good { stroke: var(--good); } .gauge-needle { stroke: var(--text); stroke-width: 3; stroke-linecap: round; transition: transform 0.4s ease; } .gauge-hub { fill: var(--text); } .gauge-value { font-size: 20px; font-weight: 700; font-family: 'Roboto Mono', monospace; margin-top: -10px; } .gauge-caption { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } .bar-chart { display: flex; flex-direction: column; gap: 10px; } .bar-row { display: grid; grid-template-columns: 140px 1fr 44px; align-items: center; gap: 8px; font-size: 12px; } .bar-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); } .bar-track { height: 10px; background: #eef0f3; border-radius: 6px; overflow: hidden; } .bar-fill { height: 100%; border-radius: 6px; transition: width 0.4s ease; background: var(--header); } .bar-fill.good { background: var(--good); } .bar-fill.warn { background: var(--warn); } .bar-fill.critical { background: var(--critical); } .bar-value { text-align: right; color: var(--muted); } .tool-table { width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 10px; overflow: hidden; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .tool-table th, .tool-table td { border-bottom: 1px solid var(--border); padding: 10px 8px; text-align: center; font-size: 13px; } .tool-table th { background: var(--header); color: white; font-weight: 600; } .tool-table tr:nth-child(even) { background: #fafbfc; } .tool-table tr:hover { background: #f0f3ff; } .life-cell { display: flex; flex-direction: column; gap: 4px; align-items: center; } .progress-track { width: 200px; height: 8px; background: #e5e7eb; border-radius: 6px; overflow: hidden; } .progress-fill { height: 200%; border-radius: 6px; transition: width 0.3s ease; } .progress-fill.good { background: var(--good); } .progress-fill.warn { background: var(--warn); } .progress-fill.critical { background: var(--critical); } .life-value { font-weight: 600; font-size: 12px; } .life-value.good { color: var(--good); } .life-value.warn { color: var(--warn); } .life-value.critical { color: var(--critical); } .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; } .badge.good { background: #dcfce7; color: var(--good); } .badge.warn { background: #fef3c7; color: var(--warn); } .badge.critical { background: #fee2e2; color: var(--critical); } .log-list { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; max-height: 260px; overflow-y: auto; } .log-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; } .log-item:last-child { border-bottom: none; } .log-main { display: flex; flex-direction: column; gap: 2px; } .log-tool { font-weight: 600; } .log-detail { color: var(--muted); font-size: 12px; } .log-time { color: var(--muted); font-size: 12px; white-space: nowrap; } .log-arrow.decrease { color: var(--critical); } .log-arrow.reset { color: var(--good); } .empty-state { padding: 24px; text-align: center; color: var(--muted); background: var(--card-bg); border-radius: 10px; border: 1px dashed var(--border); } .filters-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 14px; } .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } .field input, .field select { border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; font-size: 13px; color: var(--text); min-width: 130px; } .btn { border: none; border-radius: 7px; padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: filter 0.15s ease; } .btn:hover { filter: brightness(0.95); } .btn.primary { background: var(--header); color: white; } .btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); } .toast-stack { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 60; } .toast { background: var(--header); color: white; padding: 10px 16px; border-radius: 8px; font-size: 13px; border-left: 4px solid var(--accent); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .toast.warn { border-left-color: var(--warn); } .toast.critical { border-left-color: var(--critical); } @media (max-width: 700px) { .charts-grid { grid-template-columns: 1fr; } .bar-row { grid-template-columns: 90px 1fr 40px; } } .pagination-bar { display: flex; align-items: center; justify-content: space-between; padding: 10px 4px; font-size: 13px; color: var(--muted); } .pagination-controls { display: flex; align-items: center; gap: 4px; } .page-btn { border: 1px solid var(--border); background: var(--card-bg); color: var(--text); border-radius: 6px; padding: 5px 10px; font-size: 13px; cursor: pointer; font-weight: 600; transition: background 0.15s; } .page-btn:hover:not(:disabled) { background: #f0f3ff; } .page-btn:disabled { opacity: 0.4; cursor: default; } .page-btn.active { background: var(--header); color: white; border-color: var(--header); } </style> <template> <div class="container"> <div class="app-header"> <h2>Tool Life Dashboard</h2> <nav class="tabs"> <button :class="['tab', { active: currentTab === 'dashboard' }]" @click="currentTab = 'dashboard'">Dashboard</button> <button :class="['tab', { active: currentTab === '' }]" @click="loadActual()">Actual</button> </nav> </div> <div v-if="toolLifes.length === 0" class="empty-state"> No tool data available </div> <div v-else> <section v-show="currentTab === 'dashboard'"> <div class="section-title">Overview</div> <div class="overview-grid"> <div class="stat-card"> <div class="label">Total Tools</div> <div class="value">{{ totalTools }}</div> </div> <div class="stat-card good"> <div class="label">Good</div> <div class="value">{{ goodCount }}</div> </div> <div class="stat-card warn"> <div class="label">Warning</div> <div class="value">{{ warnCount }}</div> </div> <div class="stat-card critical"> <div class="label">Critical</div> <div class="value">{{ criticalCount }}</div> </div> </div> <div class="section-title">Charts</div> <div class="charts-grid"> <div class="chart-card"> <div class="chart-card-title">Tools by Machine</div> <div class="bar-chart"> <div class="bar-row" v-for="(m, i) in manufacturerChartData" :key="i"> <span class="bar-label">{{ m.label }}</span> <div class="bar-track"> <div class="bar-fill" :style="{ width: m.percent + '%', background: m.color }"></div> </div> <span class="bar-value mono">{{ m.value }}</span> </div> </div> </div> <div class="chart-card"> <div class="chart-card-title">Status Breakdown</div> <div class="donut-wrap"> <svg viewBox="0 0 140 140" width="140" height="140"> <circle cx="70" cy="70" r="54" fill="none" stroke="#e5e7eb" stroke-width="18" /> <g transform="rotate(-90 70 70)"> <circle v-for="(s, i) in statusPieSlices" :key="i" cx="70" cy="70" r="54" fill="none" :stroke="s.color" stroke-width="18" stroke-linecap="butt" :stroke-dasharray="s.dasharray" :stroke-dashoffset="s.dashoffset" /> </g> <text x="70" y="66" text-anchor="middle" class="donut-center-value">{{ totalTools }}</text> <text x="70" y="82" text-anchor="middle" class="donut-center-label">tools</text> </svg> <div class="donut-legend"> <div class="legend-item" v-for="(s, i) in statusPieSlices" :key="'l' + i"> <span class="legend-dot" :style="{ background: s.color }"></span> {{ s.label }} — {{ s.value }} ({{ s.percent.toFixed(0) }}%) </div> </div> </div> </div> </div> <div class="section-title"> <span>Tool Life Tracker</span> <span class="mono" style="font-size:12px;color:var(--muted)">{{ filteredReportTools.length }} of {{ totalTools }} tools</span> </div> <div class="filters-bar"> <div class="field"> <label>Search</label> <input type="text" v-model="filters.search" placeholder="Asset, name, serial..."> </div> <div class="field"> <label>Machine</label> <select v-model="filters.manufacturer"> <option value="">All</option> <option v-for="m in manufacturerOptions" :key="m" :value="m">{{ m }}</option> </select> </div> <div class="field"> <label>Status</label> <select v-model="filters.status"> <option value="">All</option> <option value="good">Good</option> <option value="warn">Warning</option> <option value="critical">Critical</option> </select> </div> <button class="btn ghost" @click="resetFilters">Clear filters</button> <div style="margin-left:auto; display:flex; gap:8px;"> <button class="btn ghost" @click="downloadCSV">Download CSV</button> <button class="btn primary" @click="downloadPDF">Print</button> </div> </div> <div v-if="filteredReportTools.length === 0" class="empty-state">No tools match these filters</div> <template v-else> <table class="tool-table"> <thead> <tr> <th>#</th> <th>Machine</th> <th>Tool No.</th> <th>Tool Name</th> <th>Type</th> <th>Material</th> <th>Wear %</th> <th>Status</th> <th>Start Time</th> <th>Last Updated</th> </tr> </thead> <tbody> <tr v-for="(tool, index) in pagedTools" :key="index"> <td class="mono" style="color:var(--muted)">{{ (currentPage - 1) * pageSize + index + 1 }}</td> <td>{{ tool.machine }}</td> <td>{{ tool.toolNumber }}</td> <td>{{ tool.name }}</td> <td>{{ tool.type }}</td> <td>{{ tool.material }}</td> <td> <div class="life-cell"> <span class="life-value" :class="statusClass(tool)">{{ tool.percentage }}%</span> <div class="progress-track"> <div class="progress-fill" :class="statusClass(tool)" :style="{ width: tool.percentage + '%' }"></div> </div> </div> </td> <td><span class="badge" :class="statusClass(tool)">{{ tool.status }}</span></td> <td style="font-size:0.82em; white-space:nowrap; color:var(--muted);"> {{ tool.startTime ? new Date(tool.startTime).toLocaleString() : '—' }} </td> <td style="font-size:0.82em; white-space:nowrap; color:var(--muted);"> {{ tool.lastUpdatedTime ? new Date(tool.lastUpdatedTime).toLocaleString() : '—' }} </td> </tr> </tbody> </table> <div class="pagination-bar"> <span>Showing {{ (currentPage - 1) * pageSize + 1 }}–{{ Math.min(currentPage * pageSize, filteredReportTools.length) }} of {{ filteredReportTools.length }} tools</span> <div class="pagination-controls"> <button class="page-btn" :disabled="currentPage === 1" @click="currentPage--">&laquo;</button> <button v-for="p in pageNumbers" :key="p" class="page-btn" :class="{ active: p === currentPage }" @click="currentPage = p">{{ p }}</button> <button class="page-btn" :disabled="currentPage === totalPages" @click="currentPage++">&raquo;</button> </div> </div> </template> </section> </div> <div class="toast-stack"> <div class="toast" v-for="t in toasts" :key="t.id" :class="t.type">{{ t.message }}</div> </div> </div> </template> <script> export default { data() { return { currentTab: 'dashboard', toolLifes: [], changeLog: [], filters: { search: '', manufacturer: '', status: '' }, toasts: [], currentPage: 1, pageSize: 5, categoryPalette: ['#1f2430', '#3b82f6', '#8b5cf6', '#06b6d4', '#84cc16', '#ec4899', '#f97316', '#f5a623'] } }, watch: { msg: function (newMsg) { if (!newMsg?.payload) return; if (Array.isArray(newMsg.payload)) { this.updateToolLifes(newMsg.payload); } }, filters: { deep: true, handler() { this.currentPage = 1; } } }, computed: { pagedTools() { const start = (this.currentPage - 1) * this.pageSize; return this.filteredReportTools.slice(start, start + this.pageSize); }, totalPages() { return Math.max(1, Math.ceil(this.filteredReportTools.length / this.pageSize)); }, pageNumbers() { const pages = []; for (let i = 1; i <= this.totalPages; i++) pages.push(i); return pages; }, totalTools() { return this.toolLifes.length; }, goodCount() { return this.toolLifes.filter(t => this.statusClass(t) === 'good').length; }, warnCount() { return this.toolLifes.filter(t => this.statusClass(t) === 'warn').length; }, criticalCount() { return this.toolLifes.filter(t => this.statusClass(t) === 'critical').length; }, averageLife() { if (this.toolLifes.length === 0) return 0; const sum = this.toolLifes.reduce((acc, t) => acc + this.remainingPercent(t), 0); return (sum / this.toolLifes.length).toFixed(1); }, manufacturerOptions() { return [...new Set(this.toolLifes.map(t => t.machine).filter(Boolean))]; }, filteredReportTools() { return this.toolLifes.filter(t => { if (this.filters.search) { const s = this.filters.search.toLowerCase(); const hay = `${t.machine} ${t.toolNumber} ${t.name} ${t.type} ${t.material}`.toLowerCase(); if (!hay.includes(s)) return false; } if (this.filters.manufacturer && t.machine !== this.filters.manufacturer) return false; if (this.filters.status && this.statusClass(t) !== this.filters.status) return false; return true; }); }, statusPieSlices() { const total = this.totalTools; if (!total) return []; const r = 54; const circumference = 2 * Math.PI * r; const raw = [ { label: 'Good', value: this.goodCount, color: '#16a34a' }, { label: 'Warning', value: this.warnCount, color: '#d97706' }, { label: 'Critical', value: this.criticalCount, color: '#dc2626' } ].filter(d => d.value > 0); let cumulativeLength = 0; return raw.map(d => { const percent = d.value / total; const segmentLength = percent * circumference; const result = { ...d, percent: percent * 100, dasharray: `${segmentLength} ${circumference}`, dashoffset: -cumulativeLength }; cumulativeLength += segmentLength; return result; }); }, manufacturerChartData() { const counts = {}; this.toolLifes.forEach(t => { const key = t.machine || 'Unknown'; counts[key] = (counts[key] || 0) + 1; }); const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]); const max = entries.length ? entries[0][1] : 1; return entries.map(([label, value], i) => ({ label, value, percent: (value / max) * 100, color: this.categoryPalette[i % this.categoryPalette.length] })); }, lifeRemainingChartData() { return [...this.toolLifes] .map(t => ({ label: `${t.name} #${t.toolNumber} (${t.machine})`, percent: this.remainingPercent(t), status: this.statusClass(t) })) .sort((a, b) => a.percent - b.percent) .slice(0, 8); }, needleAngle() { const v = Math.max(0, Math.min(100, Number(this.averageLife) || 0)); return (v / 100) * 180 - 90; } }, methods: { formatTime(ts) { if (!ts) return ""; return new Date(ts).toLocaleString(); }, remainingPercent(tool) { return Math.max(0, Math.min(100, 100 - (Number(tool.percentage) || 0))); }, statusClass(tool) { const s = tool.status; if (s === 'Replace') return 'critical'; if (s === 'Critical') return 'critical'; if (s === 'Warning') return 'warn'; return 'good'; }, statusLabel(tool) { return tool.status || 'OK'; }, gaugePoint(value) { const angleDeg = 180 - (value / 100) * 180; const rad = angleDeg * Math.PI / 180; return { x: 100 + 80 * Math.cos(rad), y: 100 - 80 * Math.sin(rad) }; }, zoneArc(v1, v2) { const p1 = this.gaugePoint(v1); const p2 = this.gaugePoint(v2); return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} A 80 80 0 0 1 ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`; }, updateToolLifes(newTools) { const latestMap = new Map(); newTools.forEach(t => { const key = `${t.machine}__${t.toolNumber}__${t.type}__${t.material}__${t.startTime ?? ''}`; const existing = latestMap.get(key); if (!existing || new Date(t.time) >= new Date(existing.time)) { latestMap.set(key, t); } }); const deduped = Array.from(latestMap.values()).filter(t => t.startTime && t.startTime !== ''); if (this.toolLifes.length > 0) { deduped.forEach(newTool => { const key = `${newTool.machine}__${newTool.toolNumber}__${newTool.type}__${newTool.material}__${newTool.startTime ?? ''}`; const oldTool = this.toolLifes.find( t => `${t.machine}__${t.toolNumber}__${t.type}__${t.material}__${t.startTime ?? ''}` === key ); if (oldTool && oldTool.percentage !== newTool.percentage) { this.changeLog.unshift({ id: `${key}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, assetId: `${newTool.machine} #${newTool.toolNumber}`, toolName: newTool.name, oldLife: this.remainingPercent(oldTool), newLife: this.remainingPercent(newTool), timestamp: newTool.time || Date.now(), type: newTool.percentage < oldTool.percentage ? 'reset' : 'decrease' }); } }); if (this.changeLog.length> 50) { this.changeLog = this.changeLog.slice(0, 50); } } this.toolLifes = deduped; }, resetFilters() { this.filters = { search: '', manufacturer: '', status: '' }; }, escapeHtml(str) { return String(str ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }, downloadCSV() { const rows = this.filteredReportTools; if (rows.length === 0) { this.addToast('No data to export', 'warn'); return; } const headers = ['Machine', 'Tool No.', 'Tool Name', 'Type', 'Material',/* 'Expected (mins)', 'Used (mins)', 'Remaining (mins)',*/ 'Wear %', 'Status', 'Start Time', 'Last Updated' /*, 'Timestamp' */ ]; const csvRows = [headers.join(',')]; rows.forEach(t => { const line = [ t.machine, t.toolNumber, t.name, t.type, t.material,/* t.expectedLife, t.usedMins, t.remainingMins,*/ t.percentage, t.status, /*, this.formatTime(t.time)*/ t.startTime ? new Date(t.startTime).toLocaleString() : '', t.lastUpdatedTime ? new Date(t.lastUpdatedTime).toLocaleString() : '' ].map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(','); csvRows.push(line); }); const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `tool-life-report-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); this.addToast(`Exported ${rows.length} tool(s) to CSV`, 'good'); }, downloadPDF() { const rows = this.filteredReportTools; if (rows.length === 0) { this.addToast('No data to export', 'warn'); return; } const win = window.open('', '_blank', 'width=900,height=700'); if (!win) { this.addToast('Please allow pop-ups to export PDF', 'critical'); return; } const tableRows = rows.map(t => ` <tr> <td>${this.escapeHtml(t.machine)}</td> <td>${this.escapeHtml(t.toolNumber)}</td> <td>${this.escapeHtml(t.name)}</td> <td>${this.escapeHtml(t.type)}</td> <td>${this.escapeHtml(t.material)}</td> <td class="${this.statusClass(t)}">${t.percentage}%</td> <td>${this.escapeHtml(t.status)}</td> <td>${t.startTime ? new Date(t.startTime).toLocaleString() : '—'}</td> <td>${t.lastUpdatedTime ? new Date(t.lastUpdatedTime).toLocaleString() : '—'}</td> </tr>`).join(''); win.document.write(` <html> <head> <title>Tool Life Report</title> <style> body { font-family: Arial, sans-serif; padding: 24px; color: #1f2430; } h1 { font-size: 18px; margin: 0 0 4px; } .meta { color: #6b7280; font-size: 12px; margin-bottom: 16px; } table { width: 100%; border-collapse: collapse; font-size: 12px; } th, td { border: 1px solid #e2e4e8; padding: 6px 8px; text-align: center; } th { background: #1f2430; color: white; } tr:nth-child(even) { background: #fafbfc; } .good { color: #16a34a; font-weight: 600; } .warn { color: #d97706; font-weight: 600; } .critical { color: #dc2626; font-weight: 600; } </style> </head> <body> <h1>Tool Life Report</h1> <div class="meta">Generated ${new Date().toLocaleString()} &middot; ${rows.length} tool(s)</div> <table> <thead> <tr><th>Machine</th><th>Tool No.</th><th>Tool Name</th><th>Type</th><th>Material</th><th>Wear %</th><th>Status</th><th>Start Time</th><th>Last Updated</th></tr> </thead> <tbody>${tableRows}</tbody> </table> </body> </html> `); win.document.close(); win.focus(); setTimeout(() => { win.print(); }, 250); }, addToast(message, type = 'good') { const id = Date.now() + Math.random(); this.toasts.push({ id, message, type }); setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, 4000); }, loadActual() { this.toolMode = true this.send({ payload: { tab: 'Actual' } }) } } } </script>