Documentation
Function Codes
Flow 2 — Tool Life
Flow 2 — Tool Life
<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--">«</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++">»</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 => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[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()} · ${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>