Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>ISO Scope Tracker – V2 Dashboard</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script> | |
| <style> | |
| @media print { | |
| body { | |
| background: #ffffff ; | |
| color: #000000 ; | |
| } | |
| header, | |
| #file-input, | |
| #btn-clear, | |
| #btn-report, | |
| #btn-print::after { | |
| box-shadow: none ; | |
| } | |
| #file-input, | |
| #btn-clear, | |
| #btn-report, | |
| #btn-print, | |
| #status-msg, | |
| #error-msg { | |
| display: none ; | |
| } | |
| .bg-gray-900, | |
| .bg-gray-950, | |
| .bg-gray-800, | |
| .bg-gray-700 { | |
| background-color: #ffffff ; | |
| } | |
| .border-gray-800, | |
| .border-gray-900 { | |
| border-color: #cccccc ; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 text-gray-100"> | |
| <header class="py-8 border-b border-gray-800 mb-6"> | |
| <div class="max-w-7xl mx-auto px-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4"> | |
| <div> | |
| <h1 class="text-3xl md:text-4xl font-bold text-white">ISO Scope Tracker – Overview</h1> | |
| <p class="text-gray-300 mt-1 text-sm md:text-base"> | |
| PipeScope Pro · ISO & Spool performance across units, fabricators, and shipment status | |
| </p> | |
| </div> | |
| <div class="flex gap-3 items-center flex-wrap"> | |
| <label class="inline-flex items-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-xl cursor-pointer text-white text-sm shadow"> | |
| Choose CSV | |
| <input id="file-input" type="file" accept=".csv" class="hidden" /> | |
| </label> | |
| <button id="btn-clear" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-xl text-sm text-gray-100 shadow"> | |
| Clear / Reset | |
| </button> | |
| <button id="btn-report" class="px-4 py-2 bg-sky-600 hover:bg-sky-500 rounded-xl text-sm text-white shadow flex items-center gap-2"> | |
| <span>Download Report</span> | |
| </button> | |
| <button id="btn-print" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-sm text-white shadow flex items-center gap-2"> | |
| <span>PDF Report</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="max-w-7xl mx-auto px-6 mt-2 text-sm text-gray-300"> | |
| <span id="file-name" class="font-medium">No file selected</span> | |
| <span id="status-msg" class="ml-4 text-green-400 hidden"></span> | |
| <span id="error-msg" class="ml-4 text-red-400 hidden"></span> | |
| </div> | |
| </header> | |
| <main class="max-w-7xl mx-auto px-6 pb-16"> | |
| <!-- Filters --> | |
| <section id="filter-section" class="hidden mb-6 bg-gray-900/70 rounded-2xl border border-gray-800 p-4"> | |
| <div class="flex flex-wrap gap-3 items-center"> | |
| <div class="flex flex-col text-xs text-gray-400"> | |
| <label for="filter-unit" class="mb-1">Unit</label> | |
| <select id="filter-unit" class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm min-w-[120px]"></select> | |
| </div> | |
| <div class="flex flex-col text-xs text-gray-400"> | |
| <label for="filter-priority" class="mb-1">Priority</label> | |
| <select id="filter-priority" class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm min-w-[120px]"></select> | |
| </div> | |
| <div class="flex flex-col text-xs text-gray-400"> | |
| <label for="filter-fabricator" class="mb-1">Fabricator</label> | |
| <select id="filter-fabricator" class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm min-w-[140px]"></select> | |
| </div> | |
| <div class="flex flex-col text-xs text-gray-400"> | |
| <label for="filter-status" class="mb-1">Status</label> | |
| <select id="filter-status" class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm min-w-[140px]"></select> | |
| </div> | |
| <div class="flex flex-col text-xs text-gray-400 flex-1 min-w-[160px]"> | |
| <label for="filter-spool" class="mb-1">Spool Number Contains</label> | |
| <input id="filter-spool" type="text" placeholder="Search spool..." | |
| class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1 text-sm w-full" /> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- KPI row --> | |
| <section id="kpi-section" class="hidden mb-8"> | |
| <div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-7 gap-4"> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Total ISOs</p> | |
| <p id="kpi-total-isos" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Total Spools</p> | |
| <p id="kpi-total-spools" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Unique Lines</p> | |
| <p id="kpi-unique-lines" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Delivered (Received)</p> | |
| <p id="kpi-delivered" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Remaining to Ship</p> | |
| <p id="kpi-rem-ship" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Remaining to Install</p> | |
| <p id="kpi-rem-install" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 text-center border border-gray-800 shadow"> | |
| <p class="text-[11px] text-gray-400 uppercase tracking-wide">Insulated</p> | |
| <p id="kpi-insulated" class="text-2xl md:text-3xl font-bold mt-1">0</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Fabricator cards --> | |
| <section id="fabricator-section" class="hidden mb-8"> | |
| <h2 class="text-xl font-semibold mb-3">Spools by Fabricator – Shipment Status</h2> | |
| <div id="fabricator-cards" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div> | |
| </section> | |
| <!-- Unit cards --> | |
| <section id="unit-section" class="hidden mb-10"> | |
| <h2 class="text-xl font-semibold mb-3">Spools by Unit – Shipment Status</h2> | |
| <div id="unit-cards" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div> | |
| </section> | |
| <!-- Status by Unit table --> | |
| <section id="tables-section" class="hidden mb-10"> | |
| <div class="bg-gray-900 rounded-xl border border-gray-800 p-4 shadow"> | |
| <h3 class="text-lg font-semibold mb-3">Status by Unit</h3> | |
| <div id="table-unit-wrapper" class="overflow-x-auto text-sm"></div> | |
| </div> | |
| </section> | |
| <!-- Heatmap --> | |
| <section id="heatmap-section" class="hidden mb-10 bg-gray-900 rounded-xl border border-gray-800 p-4 shadow"> | |
| <h3 class="text-lg font-semibold mb-3">Remaining to Ship – Fabricator × Unit</h3> | |
| <div id="heatmap-wrapper" class="overflow-x-auto text-sm"></div> | |
| </section> | |
| <!-- Charts --> | |
| <section id="charts-section" class="hidden grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10"> | |
| <div class="bg-gray-900 rounded-xl p-4 border border-gray-800 shadow"> | |
| <h3 class="text-lg font-semibold mb-2">ISOs by Unit</h3> | |
| <canvas id="chart-unit"></canvas> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 border border-gray-800 shadow"> | |
| <h3 class="text-lg font-semibold mb-2">ISOs by Zone</h3> | |
| <canvas id="chart-zone"></canvas> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 border border-gray-800 shadow"> | |
| <h3 class="text-lg font-semibold mb-2">ISOs by NDE Class</h3> | |
| <canvas id="chart-nde"></canvas> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 border border-gray-800 shadow"> | |
| <h3 class="text-lg font-semibold mb-2">Spools by Unit</h3> | |
| <canvas id="chart-spool-unit"></canvas> | |
| </div> | |
| <div class="bg-gray-900 rounded-xl p-4 border border-gray-800 shadow"> | |
| <h3 class="text-lg font-semibold mb-2">Installed vs Remaining Spools by Unit</h3> | |
| <canvas id="chart-spool-unit-install"></canvas> | |
| </div> | |
| </section> | |
| <!-- Completion summary --> | |
| <section id="completion-section" class="hidden bg-gray-900 rounded-xl border border-gray-800 p-4 shadow mb-10"> | |
| <h3 class="text-lg font-semibold mb-3">Completion Summary (Filtered ISOs)</h3> | |
| <div id="completion-table-wrapper" class="text-sm"></div> | |
| </section> | |
| <!-- Status by Fabricator table --> | |
| <section id="fabricator-status-section" class="hidden bg-gray-900 rounded-xl border border-gray-800 p-4 shadow mb-16"> | |
| <h3 class="text-lg font-semibold mb-3">Status by Fabricator</h3> | |
| <div id="table-fab-wrapper" class="overflow-x-auto text-sm"></div> | |
| </section> | |
| </main> | |
| <script> | |
| let isoRows = []; | |
| let allRows = []; | |
| let filteredIso = []; | |
| let filteredAll = []; | |
| const charts = {}; | |
| const fileInput = document.getElementById("file-input"); | |
| const clearButton = document.getElementById("btn-clear"); | |
| const reportButton = document.getElementById("btn-report"); | |
| const printButton = document.getElementById("btn-print"); | |
| fileInput.addEventListener("change", handleFile); | |
| clearButton.addEventListener("click", resetDashboard); | |
| reportButton.addEventListener("click", downloadReport); | |
| printButton.addEventListener("click", () => window.print()); | |
| const filters = { | |
| unit: "All", | |
| priority: "All", | |
| fabricator: "All", | |
| status: "All", | |
| spool: "" | |
| }; | |
| function handleFile(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| document.getElementById("file-name").textContent = file.name; | |
| Papa.parse(file, { | |
| header: true, | |
| skipEmptyLines: true, | |
| complete: (results) => processCsv(results.data || []), | |
| error: (err) => showError("Error parsing CSV: " + err) | |
| }); | |
| } | |
| function resetDashboard() { | |
| isoRows = []; | |
| allRows = []; | |
| filteredIso = []; | |
| filteredAll = []; | |
| fileInput.value = ""; | |
| document.getElementById("file-name").textContent = "No file selected"; | |
| document.getElementById("status-msg").classList.add("hidden"); | |
| document.getElementById("error-msg").classList.add("hidden"); | |
| const sections = [ | |
| "filter-section","kpi-section","fabricator-section","unit-section", | |
| "tables-section","heatmap-section","charts-section","completion-section", | |
| "fabricator-status-section" | |
| ]; | |
| sections.forEach(id => document.getElementById(id).classList.add("hidden")); | |
| document.getElementById("fabricator-cards").innerHTML = ""; | |
| document.getElementById("unit-cards").innerHTML = ""; | |
| document.getElementById("table-unit-wrapper").innerHTML = ""; | |
| document.getElementById("table-fab-wrapper").innerHTML = ""; | |
| document.getElementById("heatmap-wrapper").innerHTML = ""; | |
| document.getElementById("completion-table-wrapper").innerHTML = ""; | |
| ["kpi-total-isos","kpi-total-spools","kpi-unique-lines", | |
| "kpi-delivered","kpi-rem-ship","kpi-rem-install","kpi-insulated"] | |
| .forEach(id => document.getElementById(id).textContent = "0"); | |
| for (const key in charts) { | |
| if (charts[key]) { charts[key].destroy(); charts[key] = null; } | |
| } | |
| } | |
| function showError(msg) { | |
| const err = document.getElementById("error-msg"); | |
| const ok = document.getElementById("status-msg"); | |
| err.textContent = msg; | |
| err.classList.remove("hidden"); | |
| ok.classList.add("hidden"); | |
| } | |
| function showStatus(msg) { | |
| const err = document.getElementById("error-msg"); | |
| const ok = document.getElementById("status-msg"); | |
| ok.textContent = msg; | |
| ok.classList.remove("hidden"); | |
| err.classList.add("hidden"); | |
| } | |
| function normalizeFlag(v) { | |
| if (v === undefined || v === null) return "No"; | |
| const t = v.toString().trim().toLowerCase(); | |
| if (!t || t === "0" || t === "no" || t === "n" || t === "na" || t === "n/a") return "No"; | |
| if (t === "yes" || t === "y" || t === "1") return "Yes"; | |
| return "No"; | |
| } | |
| function normalizeInstalled(v) { | |
| if (v === undefined || v === null) return "No"; | |
| const t = v.toString().trim().toLowerCase(); | |
| if (!t || t === "na" || t === "n/a") return "No"; | |
| return "Yes"; | |
| } | |
| function processCsv(rows) { | |
| allRows = rows.slice(); | |
| const cleaned = []; | |
| rows.forEach(row => { | |
| const iso = (row["Iso Number"] || "").toString().trim(); | |
| row.Painted = normalizeFlag(row["Painted"]); | |
| row.Insulated = normalizeFlag(row["Insulated"]); | |
| row.Installed = normalizeInstalled(row["Installed"]); | |
| if (!iso) return; | |
| cleaned.push(row); | |
| }); | |
| if (!cleaned.length) { | |
| showError("No valid rows with 'Iso Number' found."); | |
| return; | |
| } | |
| isoRows = cleaned; | |
| initFilters(isoRows); | |
| applyFilters(); | |
| showStatus("Loaded " + isoRows.length + " ISO rows from " + allRows.length + " total rows."); | |
| [ | |
| "filter-section","kpi-section","fabricator-section","unit-section", | |
| "tables-section","heatmap-section","charts-section","completion-section", | |
| "fabricator-status-section" | |
| ].forEach(id => document.getElementById(id).classList.remove("hidden")); | |
| } | |
| // Filters | |
| function initFilters(rows) { | |
| const unitSet = new Set(); | |
| const prioritySet = new Set(); | |
| const fabSet = new Set(); | |
| rows.forEach(r => { | |
| const u = (r["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| const p = (r["Priority"] || "Unknown").toString().trim() || "Unknown"; | |
| const f = (r["Fabrication"] || "Unknown").toString().trim() || "Unknown"; | |
| unitSet.add(u); | |
| prioritySet.add(p); | |
| fabSet.add(f); | |
| }); | |
| const unitSel = document.getElementById("filter-unit"); | |
| const priSel = document.getElementById("filter-priority"); | |
| const fabSel = document.getElementById("filter-fabricator"); | |
| const statusSel = document.getElementById("filter-status"); | |
| const spoolInput = document.getElementById("filter-spool"); | |
| function fillSelect(sel, values) { | |
| sel.innerHTML = ""; | |
| const optAll = document.createElement("option"); | |
| optAll.value = "All"; | |
| optAll.textContent = "All"; | |
| sel.appendChild(optAll); | |
| [...values].sort().forEach(v => { | |
| const opt = document.createElement("option"); | |
| opt.value = v; | |
| opt.textContent = v; | |
| sel.appendChild(opt); | |
| }); | |
| } | |
| fillSelect(unitSel, unitSet); | |
| fillSelect(priSel, prioritySet); | |
| fillSelect(fabSel, fabSet); | |
| statusSel.innerHTML = ""; | |
| ["All","No Ship","Shipped","Received","Installed"].forEach(s => { | |
| const opt = document.createElement("option"); | |
| opt.value = s; | |
| opt.textContent = s; | |
| statusSel.appendChild(opt); | |
| }); | |
| unitSel.onchange = () => { filters.unit = unitSel.value; applyFilters(); }; | |
| priSel.onchange = () => { filters.priority = priSel.value; applyFilters(); }; | |
| fabSel.onchange = () => { filters.fabricator = fabSel.value; applyFilters(); }; | |
| statusSel.onchange = () => { filters.status = statusSel.value; applyFilters(); }; | |
| spoolInput.oninput = () => { filters.spool = spoolInput.value.trim(); applyFilters(); }; | |
| } | |
| function getStatus(row) { | |
| if (row.Installed === "Yes") return "Installed"; | |
| const received = (row["Received"] || "").toString().trim(); | |
| const shipped = (row["Shipped"] || "").toString().trim(); | |
| if (received) return "Received"; | |
| if (shipped) return "Shipped"; | |
| return "No Ship"; | |
| } | |
| function passesFilters(row) { | |
| const unit = (row["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| const pri = (row["Priority"] || "Unknown").toString().trim() || "Unknown"; | |
| const fab = (row["Fabrication"] || "Unknown").toString().trim() || "Unknown"; | |
| const status = getStatus(row); | |
| const spool = (row["Spool Number"] || "").toString().trim(); | |
| if (filters.unit !== "All" && unit !== filters.unit) return false; | |
| if (filters.priority !== "All" && pri !== filters.priority) return false; | |
| if (filters.fabricator !== "All" && fab !== filters.fabricator) return false; | |
| if (filters.status !== "All" && status !== filters.status) return false; | |
| if (filters.spool && !spool.includes(filters.spool)) return false; | |
| return true; | |
| } | |
| function applyFilters() { | |
| filteredIso = isoRows.filter(passesFilters); | |
| filteredAll = allRows.filter(passesFilters); | |
| if (!filteredIso.length) { | |
| showError("No ISO rows match the current filters."); | |
| } else { | |
| showStatus("Filtered to " + filteredIso.length + " ISO rows."); | |
| } | |
| updateKpis(filteredIso, filteredAll); | |
| updateFabricatorCards(filteredIso); | |
| updateUnitCards(filteredIso); | |
| updateStatusTables(filteredIso); | |
| updateHeatmap(filteredIso); | |
| updateCharts(filteredIso); | |
| updateCompletionTable(filteredIso); | |
| } | |
| // KPIs | |
| function updateKpis(rowsIso, rowsAll) { | |
| const isoSet = new Set(); | |
| const spoolSet = new Set(); | |
| const lineSet = new Set(); | |
| let delivered = 0; | |
| let installed = 0; | |
| let insulated = 0; | |
| rowsIso.forEach(r => { | |
| const iso = (r["Iso Number"] || "").toString().trim(); | |
| if (iso) isoSet.add(iso); | |
| const spool = (r["Spool Number"] || "").toString().trim(); | |
| if (spool) spoolSet.add(spool); | |
| const line = (r["Line Number"] || "").toString().trim(); | |
| if (line) lineSet.add(line); | |
| const received = (r["Received"] || "").toString().trim(); | |
| if (received) delivered++; | |
| if (getStatus(r) === "Installed") installed++; | |
| if (r.Insulated === "Yes") insulated++; | |
| }); | |
| rowsAll.forEach(r => { | |
| const line = (r["Line Number"] || "").toString().trim(); | |
| if (line) lineSet.add(line); | |
| }); | |
| const totalSpools = spoolSet.size; | |
| const remShip = totalSpools - delivered; | |
| const remInstall = totalSpools - installed; | |
| document.getElementById("kpi-total-isos").textContent = isoSet.size; | |
| document.getElementById("kpi-total-spools").textContent = totalSpools; | |
| document.getElementById("kpi-unique-lines").textContent = lineSet.size; | |
| document.getElementById("kpi-delivered").textContent = delivered; | |
| document.getElementById("kpi-rem-ship").textContent = remShip < 0 ? 0 : remShip; | |
| document.getElementById("kpi-rem-install").textContent = remInstall < 0 ? 0 : remInstall; | |
| document.getElementById("kpi-insulated").textContent = insulated; | |
| } | |
| // Fabricator cards | |
| function updateFabricatorCards(rows) { | |
| const container = document.getElementById("fabricator-cards"); | |
| container.innerHTML = ""; | |
| const map = {}; | |
| const overall = { spools:0, forecast:0, shipped:0, received:0 }; | |
| rows.forEach(r => { | |
| let fab = (r["Fabrication"] || "Unknown").toString().trim() || "Unknown"; | |
| fab = fab.replace(/\u00A0/g," ").replace(/\s+/g," ").toLowerCase(); | |
| fab = fab.replace(/\b\w/g, c => c.toUpperCase()); | |
| if (!map[fab]) map[fab] = { spools:0, forecast:0, shipped:0, received:0 }; | |
| map[fab].spools++; | |
| overall.spools++; | |
| if ((r["Forcast Ship"] || "").toString().trim()) { map[fab].forecast++; overall.forecast++; } | |
| if ((r["Shipped"] || "").toString().trim()) { map[fab].shipped++; overall.shipped++; } | |
| if ((r["Received"] || "").toString().trim()) { map[fab].received++; overall.received++; } | |
| }); | |
| const overallPct = overall.spools ? ((overall.received/overall.spools)*100).toFixed(1) : "0.0"; | |
| let html = ` | |
| <div class="bg-gray-950 rounded-xl p-5 shadow border border-gray-800 col-span-1 md:col-span-2 xl:col-span-3"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h3 class="text-lg font-semibold">All Fabricators</h3> | |
| <span class="text-xs text-gray-400">Overview</span> | |
| </div> | |
| <p class="text-gray-300">Total Spools: <span class="font-bold">${overall.spools}</span></p> | |
| <p class="text-gray-300">Forecast Ship: <span>${overall.forecast}</span></p> | |
| <p class="text-gray-300">Shipped: <span>${overall.shipped}</span></p> | |
| <p class="text-gray-300">Received: <span>${overall.received}</span></p> | |
| <p class="mt-3">Progress: | |
| <span class="px-3 py-1 rounded-full bg-emerald-600 text-white text-xs">${overallPct}%</span> | |
| </p> | |
| </div> | |
| `; | |
| Object.entries(map).forEach(([fab,data]) => { | |
| const pct = data.spools ? ((data.received/data.spools)*100).toFixed(1) : "0.0"; | |
| html += ` | |
| <div class="bg-gray-900 rounded-xl p-5 shadow border border-gray-800"> | |
| <h3 class="text-lg font-semibold mb-2">${fab}</h3> | |
| <p class="text-gray-300">Total Spools: <span class="font-bold">${data.spools}</span></p> | |
| <p class="text-gray-300">Forecast Ship: <span>${data.forecast}</span></p> | |
| <p class="text-gray-300">Shipped: <span>${data.shipped}</span></p> | |
| <p class="text-gray-300">Received: <span>${data.received}</span></p> | |
| <p class="mt-3">Progress: | |
| <span class="px-3 py-1 rounded-full bg-emerald-600 text-white text-xs">${pct}%</span> | |
| </p> | |
| </div> | |
| `; | |
| }); | |
| container.innerHTML = html; | |
| } | |
| function updateUnitCards(rows) { | |
| const container = document.getElementById("unit-cards"); | |
| container.innerHTML = ""; | |
| const map = {}; | |
| const overall = { spools:0, forecast:0, shipped:0, received:0 }; | |
| rows.forEach(r => { | |
| let unit = (r["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| if (!map[unit]) map[unit] = { spools:0, forecast:0, shipped:0, received:0 }; | |
| map[unit].spools++; | |
| overall.spools++; | |
| if ((r["Forcast Ship"] || "").toString().trim()) { map[unit].forecast++; overall.forecast++; } | |
| if ((r["Shipped"] || "").toString().trim()) { map[unit].shipped++; overall.shipped++; } | |
| if ((r["Received"] || "").toString().trim()) { map[unit].received++; overall.received++; } | |
| }); | |
| const overallPct = overall.spools ? ((overall.received/overall.spools)*100).toFixed(1) : "0.0"; | |
| let html = ` | |
| <div class="bg-gray-950 rounded-xl p-5 shadow border border-gray-800 col-span-1 md:col-span-2 xl:col-span-3"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h3 class="text-lg font-semibold">All Units</h3> | |
| <span class="text-xs text-gray-400">Overview</span> | |
| </div> | |
| <p class="text-gray-300">Total Spools: <span class="font-bold">${overall.spools}</span></p> | |
| <p class="text-gray-300">Forecast Ship: <span>${overall.forecast}</span></p> | |
| <p class="text-gray-300">Shipped: <span>${overall.shipped}</span></p> | |
| <p class="text-gray-300">Received: <span>${overall.received}</span></p> | |
| <p class="mt-3">Progress: | |
| <span class="px-3 py-1 rounded-full bg-emerald-600 text-white text-xs">${overallPct}%</span> | |
| </p> | |
| </div> | |
| `; | |
| const units = Object.entries(map).sort((a,b) => a[0].localeCompare(b[0])); | |
| units.forEach(([unit,data]) => { | |
| const pct = data.spools ? ((data.received/data.spools)*100).toFixed(1) : "0.0"; | |
| html += ` | |
| <div class="bg-gray-900 rounded-xl p-5 shadow border border-gray-800"> | |
| <h3 class="text-lg font-semibold mb-2">${unit}</h3> | |
| <p class="text-gray-300">Total Spools: <span class="font-bold">${data.spools}</span></p> | |
| <p class="text-gray-300">Forecast Ship: <span>${data.forecast}</span></p> | |
| <p class="text-gray-300">Shipped: <span>${data.shipped}</span></p> | |
| <p class="text-gray-300">Received: <span>${data.received}</span></p> | |
| <p class="mt-3">Progress: | |
| <span class="px-3 py-1 rounded-full bg-emerald-600 text-white text-xs">${pct}%</span> | |
| </p> | |
| </div> | |
| `; | |
| }); | |
| container.innerHTML = html; | |
| } | |
| // Status tables | |
| function updateStatusTables(rows) { | |
| buildStatusByUnit(rows); | |
| buildStatusByFabricator(rows); | |
| } | |
| function buildStatusByUnit(rows) { | |
| const wrapper = document.getElementById("table-unit-wrapper"); | |
| wrapper.innerHTML = ""; | |
| const map = {}; | |
| let total = { spools:0, delivered:0, remShip:0, installed:0, remInstall:0 }; | |
| rows.forEach(r => { | |
| const unit = (r["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| if (!map[unit]) map[unit] = { spools:0, delivered:0, installed:0 }; | |
| map[unit].spools++; | |
| total.spools++; | |
| const received = (r["Received"] || "").toString().trim(); | |
| const status = getStatus(r); | |
| if (received) { map[unit].delivered++; total.delivered++; } | |
| if (status === "Installed") { map[unit].installed++; total.installed++; } | |
| }); | |
| const units = Object.entries(map).sort((a,b) => a[0].localeCompare(b[0])); | |
| let html = `<table class="min-w-full border-collapse"> | |
| <thead class="text-xs text-gray-400 border-b border-gray-800"> | |
| <tr> | |
| <th class="py-2 pr-3 text-left">Unit</th> | |
| <th class="py-2 pr-3 text-right">Total Spools</th> | |
| <th class="py-2 pr-3 text-right">Delivered</th> | |
| <th class="py-2 pr-3 text-right">% Delivered</th> | |
| <th class="py-2 pr-3 text-right">Rem to Ship</th> | |
| <th class="py-2 pr-3 text-right">Installed</th> | |
| <th class="py-2 pr-3 text-right">% Installed</th> | |
| <th class="py-2 pr-0 text-right">Rem to Install</th> | |
| </tr> | |
| </thead> | |
| <tbody class="text-xs md:text-sm">`; | |
| units.forEach(([unit,data]) => { | |
| const remShip = data.spools - data.delivered; | |
| const remInstall = data.spools - data.installed; | |
| const pctDelivered = data.spools ? (data.delivered/data.spools*100).toFixed(1) : "0.0"; | |
| const pctInstalled = data.spools ? (data.installed/data.spools*100).toFixed(1) : "0.0"; | |
| html += ` | |
| <tr class="border-b border-gray-900"> | |
| <td class="py-1.5 pr-3">${unit}</td> | |
| <td class="py-1.5 pr-3 text-right">${data.spools}</td> | |
| <td class="py-1.5 pr-3 text-right">${data.delivered}</td> | |
| <td class="py-1.5 pr-3 text-right"> | |
| <div class="flex items-center gap-2 justify-end"> | |
| <span>${pctDelivered}%</span> | |
| <div class="w-20 bg-gray-800 rounded-full h-2 overflow-hidden"> | |
| <div class="h-2 bg-lime-400" style="width:${pctDelivered}%;"></div> | |
| </div> | |
| </div> | |
| </td> | |
| <td class="py-1.5 pr-3 text-right">${remShip}</td> | |
| <td class="py-1.5 pr-3 text-right">${data.installed}</td> | |
| <td class="py-1.5 pr-3 text-right"> | |
| <div class="flex items-center gap-2 justify-end"> | |
| <span>${pctInstalled}%</span> | |
| <div class="w-20 bg-gray-800 rounded-full h-2 overflow-hidden"> | |
| <div class="h-2 bg-sky-400" style="width:${pctInstalled}%;"></div> | |
| </div> | |
| </div> | |
| </td> | |
| <td class="py-1.5 pr-0 text-right">${remInstall}</td> | |
| </tr>`; | |
| }); | |
| total.remShip = total.spools - total.delivered; | |
| total.remInstall = total.spools - total.installed; | |
| const tPctDel = total.spools ? (total.delivered/total.spools*100).toFixed(1) : "0.0"; | |
| const tPctInst = total.spools ? (total.installed/total.spools*100).toFixed(1) : "0.0"; | |
| html += ` | |
| </tbody> | |
| <tfoot class="text-xs md:text-sm font-semibold border-t border-gray-800"> | |
| <tr> | |
| <td class="py-2 pr-3">Total</td> | |
| <td class="py-2 pr-3 text-right">${total.spools}</td> | |
| <td class="py-2 pr-3 text-right">${total.delivered}</td> | |
| <td class="py-2 pr-3 text-right">${tPctDel}%</td> | |
| <td class="py-2 pr-3 text-right">${total.remShip}</td> | |
| <td class="py-2 pr-3 text-right">${total.installed}</td> | |
| <td class="py-2 pr-3 text-right">${tPctInst}%</td> | |
| <td class="py-2 pr-0 text-right">${total.remInstall}</td> | |
| </tr> | |
| </tfoot> | |
| </table>`; | |
| wrapper.innerHTML = html; | |
| } | |
| function buildStatusByFabricator(rows) { | |
| const wrapper = document.getElementById("table-fab-wrapper"); | |
| wrapper.innerHTML = ""; | |
| const map = {}; | |
| let total = { spools:0, delivered:0, remShip:0 }; | |
| rows.forEach(r => { | |
| let fab = (r["Fabrication"] || "Unknown").toString().trim() || "Unknown"; | |
| fab = fab.replace(/\u00A0/g," ").replace(/\s+/g," ").toLowerCase(); | |
| fab = fab.replace(/\b\w/g, c => c.toUpperCase()); | |
| if (!map[fab]) map[fab] = { spools:0, delivered:0, remShip:0, dates:[] }; | |
| map[fab].spools++; | |
| total.spools++; | |
| const received = (r["Received"] || "").toString().trim(); | |
| if (received) { map[fab].delivered++; total.delivered++; } | |
| const fDate = (r["Forcast Ship"] || "").toString().trim(); | |
| if (fDate) map[fab].dates.push(fDate); | |
| }); | |
| Object.values(map).forEach(m => { | |
| m.remShip = m.spools - m.delivered; | |
| }); | |
| total.remShip = total.spools - total.delivered; | |
| let html = `<table class="min-w-full border-collapse"> | |
| <thead class="text-xs text-gray-400 border-b border-gray-800"> | |
| <tr> | |
| <th class="py-2 pr-3 text-left">Fabricator</th> | |
| <th class="py-2 pr-3 text-right">Total</th> | |
| <th class="py-2 pr-3 text-right">Delivered</th> | |
| <th class="py-2 pr-3 text-right">Rem to Ship</th> | |
| <th class="py-2 pr-0 text-right">Final Ship</th> | |
| </tr> | |
| </thead> | |
| <tbody class="text-xs md:text-sm">`; | |
| const fabs = Object.entries(map).sort((a,b) => a[0].localeCompare(b[0])); | |
| fabs.forEach(([fab,data]) => { | |
| const finalShip = data.dates.length ? data.dates.sort().slice(-1)[0] : ""; | |
| html += ` | |
| <tr class="border-b border-gray-900"> | |
| <td class="py-1.5 pr-3">${fab}</td> | |
| <td class="py-1.5 pr-3 text-right">${data.spools}</td> | |
| <td class="py-1.5 pr-3 text-right">${data.delivered}</td> | |
| <td class="py-1.5 pr-3 text-right">${data.remShip}</td> | |
| <td class="py-1.5 pr-0 text-right">${finalShip}</td> | |
| </tr>`; | |
| }); | |
| html += `</tbody> | |
| <tfoot class="text-xs md:text-sm font-semibold border-t border-gray-800"> | |
| <tr> | |
| <td class="py-2 pr-3">Total</td> | |
| <td class="py-2 pr-3 text-right">${total.spools}</td> | |
| <td class="py-2 pr-3 text-right">${total.delivered}</td> | |
| <td class="py-2 pr-3 text-right">${total.remShip}</td> | |
| <td class="py-2 pr-0 text-right"></td> | |
| </tr> | |
| </tfoot> | |
| </table>`; | |
| wrapper.innerHTML = html; | |
| } | |
| // Heatmap | |
| function updateHeatmap(rows) { | |
| const wrapper = document.getElementById("heatmap-wrapper"); | |
| wrapper.innerHTML = ""; | |
| const unitsSet = new Set(); | |
| const fabsSet = new Set(); | |
| const cell = {}; | |
| const totalRow = {}; | |
| const totalCol = {}; | |
| rows.forEach(r => { | |
| let fab = (r["Fabrication"] || "Unknown").toString().trim() || "Unknown"; | |
| fab = fab.replace(/\u00A0/g," ").replace(/\s+/g," ").toLowerCase(); | |
| fab = fab.replace(/\b\w/g, c => c.toUpperCase()); | |
| let unit = (r["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| const received = (r["Received"] || "").toString().trim(); | |
| if (received) return; | |
| unitsSet.add(unit); | |
| fabsSet.add(fab); | |
| const key = fab + "|" + unit; | |
| cell[key] = (cell[key] || 0) + 1; | |
| }); | |
| const units = [...unitsSet].sort(); | |
| const fabs = [...fabsSet].sort(); | |
| fabs.forEach(fab => { | |
| let rowTotal = 0; | |
| units.forEach(unit => { | |
| const key = fab + "|" + unit; | |
| const v = cell[key] || 0; | |
| rowTotal += v; | |
| totalCol[unit] = (totalCol[unit] || 0) + v; | |
| }); | |
| totalRow[fab] = rowTotal; | |
| }); | |
| const grandTotal = Object.values(totalRow).reduce((a,b) => a+b,0); | |
| const maxVal = Math.max(1, ...Object.values(cell)); | |
| function bgFor(v) { | |
| if (v === 0) return "bg-gray-900"; | |
| const intensity = v / maxVal; | |
| if (intensity < 0.25) return "bg-emerald-900"; | |
| if (intensity < 0.5) return "bg-emerald-800"; | |
| if (intensity < 0.75) return "bg-emerald-700"; | |
| return "bg-emerald-600"; | |
| } | |
| let html = `<table class="border-collapse min-w-full"> | |
| <thead class="text-xs text-gray-400"> | |
| <tr> | |
| <th class="py-2 px-3 text-left border-b border-gray-800">Fabricator</th>`; | |
| units.forEach(u => { | |
| html += `<th class="py-2 px-3 text-center border-b border-gray-800">${u}</th>`; | |
| }); | |
| html += `<th class="py-2 px-3 text-center border-b border-gray-800">Total</th></tr></thead><tbody class="text-xs md:text-sm">`; | |
| fabs.forEach(fab => { | |
| html += `<tr class="border-b border-gray-900"> | |
| <td class="py-1.5 px-3">${fab}</td>`; | |
| units.forEach(unit => { | |
| const key = fab + "|" + unit; | |
| const v = cell[key] || 0; | |
| html += `<td class="py-1.5 px-1 text-center"> | |
| <div class="inline-flex items-center justify-center rounded-md ${bgFor(v)} px-2 py-0.5 min-w-[36px]"> | |
| <span class="text-xs">${v}</span> | |
| </div> | |
| </td>`; | |
| }); | |
| html += `<td class="py-1.5 px-3 text-center font-semibold">${totalRow[fab] || 0}</td></tr>`; | |
| }); | |
| html += `</tbody> | |
| <tfoot class="text-xs md:text-sm font-semibold border-t border-gray-800"> | |
| <tr> | |
| <td class="py-2 px-3">Total</td>`; | |
| units.forEach(unit => { | |
| const v = totalCol[unit] || 0; | |
| html += `<td class="py-2 px-1 text-center">${v}</td>`; | |
| }); | |
| html += `<td class="py-2 px-3 text-center">${grandTotal}</td></tr></tfoot></table>`; | |
| wrapper.innerHTML = html; | |
| } | |
| // Chart helpers | |
| function groupIsoByField(rows, field) { | |
| const map = new Map(); | |
| rows.forEach(r => { | |
| const iso = (r["Iso Number"] || "").toString().trim(); | |
| if (!iso) return; | |
| let key = (r[field] || "Unknown").toString().trim() || "Unknown"; | |
| if (!map.has(key)) map.set(key, new Set()); | |
| map.get(key).add(iso); | |
| }); | |
| return [...map.entries()].map(([label,set]) => ({label, count:set.size})); | |
| } | |
| function groupSpoolByField(rows, field) { | |
| const map = new Map(); | |
| rows.forEach(r => { | |
| const spool = (r["Spool Number"] || "").toString().trim(); | |
| if (!spool) return; | |
| let key = (r[field] || "Unknown").toString().trim() || "Unknown"; | |
| if (!map.has(key)) map.set(key, new Set()); | |
| map.get(key).add(spool); | |
| }); | |
| return [...map.entries()].map(([label,set]) => ({label, count:set.size})); | |
| } | |
| function groupSpoolsInstalledRemainingByUnit(rows) { | |
| const map = new Map(); | |
| rows.forEach(r => { | |
| const spool = (r["Spool Number"] || "").toString().trim(); | |
| if (!spool) return; | |
| const unit = (r["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| if (!map.has(unit)) { | |
| map.set(unit, { all: new Set(), installed: new Set() }); | |
| } | |
| const entry = map.get(unit); | |
| entry.all.add(spool); | |
| if (getStatus(r) === "Installed") { | |
| entry.installed.add(spool); | |
| } | |
| }); | |
| return [...map.entries()].map(([unit, entry]) => { | |
| const installed = entry.installed.size; | |
| const total = entry.all.size; | |
| const remaining = total - installed; | |
| return { label: unit, installed, remaining }; | |
| }); | |
| } | |
| function updateCharts(rows) { | |
| let unit = groupIsoByField(rows, "Unit"); | |
| let zone = groupIsoByField(rows, "Zone"); | |
| let nde = groupIsoByField(rows, "NDE Class"); | |
| let spoolUnit = groupSpoolByField(rows, "Unit"); | |
| let spoolInstallUnit = groupSpoolsInstalledRemainingByUnit(rows); | |
| // sort ascending by count | |
| unit.sort((a,b) => a.count - b.count); | |
| zone.sort((a,b) => a.count - b.count); | |
| nde.sort((a,b) => a.count - b.count); | |
| spoolUnit.sort((a,b) => a.count - b.count); | |
| spoolInstallUnit.sort((a,b) => (a.installed + a.remaining) - (b.installed + b.remaining)); | |
| renderBarChart("chart-unit", unit); | |
| renderBarChart("chart-zone", zone); | |
| renderBarChart("chart-nde", nde); | |
| renderBarChart("chart-spool-unit", spoolUnit); | |
| renderBarChartDual("chart-spool-unit-install", spoolInstallUnit); | |
| } | |
| function renderBarChart(id, data) { | |
| const canvas = document.getElementById(id); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (charts[id]) charts[id].destroy(); | |
| charts[id] = new Chart(ctx, { | |
| type: "bar", | |
| data: { | |
| labels: data.map(d => d.label), | |
| datasets: [{ | |
| data: data.map(d => d.count), | |
| backgroundColor: "rgba(56, 189, 248, 0.8)" | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { ticks: { color: "#e5e7eb" }, grid: { display:false } }, | |
| y: { beginAtZero:true, ticks:{ color:"#9ca3af", precision:0 }, grid:{ color:"#111827" } } | |
| } | |
| } | |
| }); | |
| } | |
| function renderBarChartDual(id, data) { | |
| const canvas = document.getElementById(id); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (charts[id]) charts[id].destroy(); | |
| charts[id] = new Chart(ctx, { | |
| type: "bar", | |
| data: { | |
| labels: data.map(d => d.label), | |
| datasets: [ | |
| { | |
| label: "Installed", | |
| data: data.map(d => d.installed), | |
| backgroundColor: "rgba(16, 185, 129, 0.9)" | |
| }, | |
| { | |
| label: "Remaining", | |
| data: data.map(d => d.remaining), | |
| backgroundColor: "rgba(239, 68, 68, 0.8)" | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| plugins: { | |
| legend: { display: true, labels: { color: "#e5e7eb" } } | |
| }, | |
| scales: { | |
| x: { ticks: { color: "#e5e7eb" }, grid: { display:false } }, | |
| y: { beginAtZero:true, ticks:{ color:"#9ca3af", precision:0 }, grid:{ color:"#111827" } } | |
| } | |
| } | |
| }); | |
| } | |
| // Report download (CSV-style summary) | |
| function downloadReport() { | |
| if (!filteredIso || !filteredIso.length) { | |
| showError("No data available to download. Load a CSV first."); | |
| return; | |
| } | |
| const rowsIso = filteredIso; | |
| const rowsAll = filteredAll || []; | |
| const isoSet = new Set(); | |
| const spoolSet = new Set(); | |
| const lineSet = new Set(); | |
| let delivered = 0; | |
| let installed = 0; | |
| let insulated = 0; | |
| rowsIso.forEach(r => { | |
| const iso = (r["Iso Number"] || "").toString().trim(); | |
| if (iso) isoSet.add(iso); | |
| const spool = (r["Spool Number"] || "").toString().trim(); | |
| if (spool) spoolSet.add(spool); | |
| const line = (r["Line Number"] || "").toString().trim(); | |
| if (line) lineSet.add(line); | |
| const received = (r["Received"] || "").toString().trim(); | |
| if (received) delivered++; | |
| if (getStatus(r) === "Installed") installed++; | |
| if (r.Insulated === "Yes") insulated++; | |
| }); | |
| rowsAll.forEach(r => { | |
| const line = (r["Line Number"] || "").toString().trim(); | |
| if (line) lineSet.add(line); | |
| }); | |
| const totalSpools = spoolSet.size; | |
| const remShip = totalSpools - delivered; | |
| const remInstall = totalSpools - installed; | |
| const unitMap = {}; | |
| rowsIso.forEach(r => { | |
| const unit = (r["Unit"] || "Unknown").toString().trim() || "Unknown"; | |
| if (!unitMap[unit]) unitMap[unit] = { spools:0, delivered:0, installed:0 }; | |
| unitMap[unit].spools++; | |
| const received = (r["Received"] || "").toString().trim(); | |
| const status = getStatus(r); | |
| if (received) unitMap[unit].delivered++; | |
| if (status === "Installed") unitMap[unit].installed++; | |
| }); | |
| const fabMap = {}; | |
| rowsIso.forEach(r => { | |
| let fab = (r["Fabrication"] || "Unknown").toString().trim() || "Unknown"; | |
| fab = fab.replace(/\u00A0/g," ").replace(/\s+/g," ").toLowerCase(); | |
| fab = fab.replace(/\b\w/g, c => c.toUpperCase()); | |
| if (!fabMap[fab]) fabMap[fab] = { spools:0, delivered:0, remShip:0 }; | |
| fabMap[fab].spools++; | |
| const received = (r["Received"] || "").toString().trim(); | |
| if (received) fabMap[fab].delivered++; | |
| }); | |
| Object.values(fabMap).forEach(m => { m.remShip = m.spools - m.delivered; }); | |
| const lines = []; | |
| lines.push("ISO Scope Tracker Report"); | |
| lines.push("Generated," + new Date().toISOString()); | |
| lines.push(""); | |
| lines.push("High-Level KPIs"); | |
| lines.push("Metric,Value"); | |
| lines.push("Total ISOs," + isoSet.size); | |
| lines.push("Total Spools," + totalSpools); | |
| lines.push("Unique Lines," + lineSet.size); | |
| lines.push("Delivered (Received)," + delivered); | |
| lines.push("Remaining to Ship," + (remShip < 0 ? 0 : remShip)); | |
| lines.push("Remaining to Install," + (remInstall < 0 ? 0 : remInstall)); | |
| lines.push("Insulated," + insulated); | |
| lines.push(""); | |
| lines.push("Status by Unit"); | |
| lines.push("Unit,Total Spools,Delivered,Installed"); | |
| Object.keys(unitMap).sort().forEach(u => { | |
| const d = unitMap[u]; | |
| lines.push([u,d.spools,d.delivered,d.installed].join(",")); | |
| }); | |
| lines.push(""); | |
| lines.push("Status by Fabricator"); | |
| lines.push("Fabricator,Total Spools,Delivered,Rem to Ship"); | |
| Object.keys(fabMap).sort().forEach(f => { | |
| const d = fabMap[f]; | |
| lines.push([f,d.spools,d.delivered,d.remShip].join(",")); | |
| }); | |
| const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "ISO_Scope_Report.csv"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Completion Summary | |
| function updateCompletionTable(rows) { | |
| const isoInstalled = new Set(); | |
| const isoPainted = new Set(); | |
| const isoInsulated = new Set(); | |
| const isoAll = new Set(); | |
| function isYes(value) { | |
| if (!value) return false; | |
| const v = value.toString().trim().toLowerCase(); | |
| return v === "yes"; | |
| } | |
| rows.forEach(r => { | |
| const iso = (r["Iso Number"] || "").toString().trim(); | |
| if (!iso) return; | |
| isoAll.add(iso); | |
| if (getStatus(r) === "Installed") isoInstalled.add(iso); | |
| if (isYes(r.Painted)) isoPainted.add(iso); | |
| if (isYes(r.Insulated)) isoInsulated.add(iso); | |
| }); | |
| const installed = isoInstalled.size; | |
| const painted = isoPainted.size; | |
| const insulated = isoInsulated.size; | |
| const total = isoAll.size || 1; | |
| document.getElementById("completion-table-wrapper").innerHTML = ` | |
| <table class="min-w-full border-collapse"> | |
| <thead class="text-xs text-gray-400 border-b border-gray-800"> | |
| <tr> | |
| <th class="py-2 pr-3 text-left">Metric</th> | |
| <th class="py-2 pr-3 text-right">Count</th> | |
| <th class="py-2 pr-0 text-right">% of Filtered ISOs</th> | |
| </tr> | |
| </thead> | |
| <tbody class="text-xs md:text-sm"> | |
| <tr class="border-b border-gray-900"> | |
| <td class="py-1.5 pr-3">Installed</td> | |
| <td class="py-1.5 pr-3 text-right">${installed}</td> | |
| <td class="py-1.5 pr-0 text-right">${(installed/total*100).toFixed(1)}%</td> | |
| </tr> | |
| <tr class="border-b border-gray-900"> | |
| <td class="py-1.5 pr-3">Painted</td> | |
| <td class="py-1.5 pr-3 text-right">${painted}</td> | |
| <td class="py-1.5 pr-0 text-right">${(painted/total*100).toFixed(1)}%</td> | |
| </tr> | |
| <tr> | |
| <td class="py-1.5 pr-3">Insulated</td> | |
| <td class="py-1.5 pr-3 text-right">${insulated}</td> | |
| <td class="py-1.5 pr-0 text-right">${(insulated/total*100).toFixed(1)}%</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| `; | |
| } | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-qwensite.hf.space/logo.svg" alt="qwensite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-qwensite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >QwenSite</a> - 🧬 <a href="https://enzostvs-qwensite.hf.space?remix=Kaliman-1981/tempspooltracker" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> | |