Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>Cost Master – S‑Curve</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 3v18h18' stroke='%239ab0d8' fill='none'/%3E%3Cpath d='M6 15l4-5 3 3 5-7' stroke='%239ab0d8' fill='none'/%3E%3C/svg%3E"/> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script> | |
| <style> | |
| :root{--bg:#0b1222;--panel:#0e1a32;--text:#e7eefc;--muted:#a3b5d9;--border:#233255;--teal:#22e1e8;--blue:#7ad7ff;--green:#86efac;--amber:#ffc455;--purple:#a87fff;--pink:#f08ac3;--orange:#ffa055;} | |
| *{box-sizing:border-box} | |
| body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0} | |
| .wrap{max-width:1280px;margin:0 auto} | |
| header{position:sticky;top:0;z-index:10;background:rgba(11,18,34,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border)} | |
| .brand{display:flex;align-items:flex-end;gap:.55rem;padding:10px 0} | |
| .brand .logo{width:32px;height:32px;border-radius:8px;background:#0f2042;border:1px solid #284475;display:flex;align-items:center;justify-content:center} | |
| .brand .logo svg{width:20px;height:20px;stroke:#a9bfe6;fill:none;stroke-width:2} | |
| .brand .title{font-size:30px;line-height:1;font-weight:700;background:linear-gradient(180deg,#e6ecff,#a0b5df);-webkit-background-clip:text;color:transparent} | |
| .brand .subtitle{font-size:12px;color:#a6b9de;margin-top:2px} | |
| .btn{padding:.45rem .7rem;border-radius:10px;border:1px solid var(--border);background:#0e1e3b;color:#e7eefc;font-weight:600;font-size:.9rem} | |
| .btn.warning{background:#f5a524;color:#142036;border-color:#f0aa3a} | |
| .btn.danger{background:#943535;border-color:#b24b4b} | |
| .filename{font-size:.85rem;color:#c5d3f1;opacity:.85} | |
| #file{position:absolute;left:-9999px;width:1px;height:1px;opacity:0} | |
| .card{background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.015));border:1px solid var(--border);border-radius:14px;padding:1rem; margin-bottom: 1rem} | |
| .section{display:flex;align-items:center;justify-content:space-between;margin:6px 0 8px} | |
| .section h2{font-size:1rem;opacity:.95} | |
| .hint{font-size:.8rem;color:var(--muted)} | |
| /* KPI */ | |
| .kpi .label{color:var(--muted);font-size:.82rem;margin-bottom:.35rem;display:flex;gap:.45rem;align-items:center} | |
| .kpi .value{font-weight:700;font-size:1.1rem;line-height:1.25;color:#dbe6ff;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;letter-spacing:.2px} | |
| .kpi .value.small{font-size:1.02rem} | |
| .kpi .note{margin-top:.25rem;font-size:.86rem;line-height:1.2;color:var(--muted);font-weight:500;letter-spacing:.15px} | |
| .kpi-grid{display:grid;grid-template-columns:repeat(4, minmax(220px,1fr));gap:12px} | |
| @media (max-width: 1100px){ .kpi-grid{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));} } | |
| /* Pills */ | |
| .grid-fit{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px} | |
| .badge{display:flex;align-items:center;gap:10px;background:#0f1e39;border:1px solid var(--border);padding:.45rem .7rem;border-radius:999px;min-width:220px} | |
| .badge .dot{width:8px;height:8px;border-radius:999px;background:var(--teal)} | |
| .badge .label{color:var(--muted);font-size:.9rem} | |
| .badge .val{font-variant-numeric:tabular-nums;font-weight:700;white-space:nowrap} | |
| /* Range slider */ | |
| .track{height:8px;background:#0f1e39;border:1px solid var(--border);border-radius:999px;position:relative} | |
| .fill{position:absolute;height:8px;background:linear-gradient(90deg,#22e1e8,#7ad7ff);border-radius:999px} | |
| .range{pointer-events:none;position:absolute;height:0;width:100%;-webkit-appearance:none} | |
| .range::-webkit-slider-thumb{pointer-events:auto;width:14px;height:14px;border-radius:50%;background:var(--amber);-webkit-appearance:none;border:2px solid #13233f} | |
| .chartbox{height:320px} | |
| .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} | |
| .compare-pill{display:inline-flex;align-items:center;gap:6px;padding:.2rem .45rem;border-radius:999px;border:1px solid var(--border);background:#0f1e39;font-size:.8rem} | |
| .compare-dot{width:9px;height:9px;border-radius:999px;display:inline-block} | |
| .compare-pill .val{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;opacity:.9} | |
| .compare-legend-bottom{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:6px} | |
| /* ---- S‑Curve Loader ---- */ | |
| .cm-loader{position:fixed;inset:0;background:rgba(9,14,28,.78);backdrop-filter:blur(6px); | |
| display:flex;align-items:center;justify-content:center;z-index:70} | |
| .cm-loader-card{width:min(780px,92vw);border:1px solid var(--border);border-radius:18px; | |
| background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.02)); | |
| box-shadow:0 10px 40px rgba(0,0,0,.35); padding:18px 20px} | |
| .cm-hero{width:100%;height:120px;display:block} | |
| .cm-title{font-weight:700;letter-spacing:.2px} | |
| .cm-sub{color:var(--muted);margin-top:2px} | |
| .cm-loader-body{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center} | |
| .cm-progress{grid-column:1/2;height:10px;border-radius:999px;background:#0f1e39;border:1px solid var(--border);overflow:hidden;margin-top:6px} | |
| .cm-bar{height:100%;width:0;background:linear-gradient(90deg,#22e1e8,#7ad7ff);transition:width .35s ease} | |
| .cm-pct{grid-column:2/3;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} | |
| /* Curve draw + moving dot along path */ | |
| @keyframes cmDraw { from { stroke-dashoffset: 800; } to { stroke-dashoffset: 0; } } | |
| @keyframes cmDash { from { stroke-dasharray: 1 10; } to { stroke-dasharray: 12 10; } } | |
| @keyframes cmGlow { 0%,100% { filter: drop-shadow(0 0 0px #00e4ff);} 50% { filter: drop-shadow(0 0 6px #00e4ff);} } | |
| /* --- loading toast --- */ | |
| .cm-toast{position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:80} | |
| .cm-toast-card{display:flex;align-items:center;gap:8px;background:rgba(16,29,56,.92); | |
| border:1px solid var(--border);padding:8px 12px;border-radius:10px; | |
| box-shadow:0 6px 18px rgba(0,0,0,.35);color:#d9e7ff;font-weight:600} | |
| .cm-dot{width:10px;height:10px;border-radius:50%;background:conic-gradient(#22e1e8,#7ad7ff); | |
| animation: cmPulse 1s ease-in-out infinite alternate} | |
| @keyframes cmPulse{from{filter:drop-shadow(0 0 0px #00e4ff)}to{filter:drop-shadow(0 0 6px #00e4ff)}} | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| </head> | |
| <body> | |
| <header class="px-6"> | |
| <div class="wrap flex items-center justify-between gap-3"> | |
| <div class="brand"> | |
| <div class="logo"><svg viewBox="0 0 24 24"><path d='M3 3v18h18'/><path d='M6 15l4-5 3 3 5-7'/></svg></div> | |
| <div><div class="title">Cost Master</div><div class="subtitle">Advanced S‑Curve Analysis & Project Management</div></div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <label id="fileLabel" for="file" class="btn warning cursor-pointer" role="button" tabindex="0">+ Choose File</label> | |
| <span id="filename" class="filename">No file selected</span> | |
| <input id="file" type="file" accept=".xlsx,.xls,.xlsm,.csv,.XLSX,.XLS,.XLSM,.CSV"/> | |
| <button id="exportCsv" class="btn">Export Daily CSV</button> | |
| <button id="exportPdf" class="btn ml-2">Export Report PDF</button> | |
| <button id="resetAll" class="btn danger">Reset</button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Tiny "please wait" toast --> | |
| <div id="cmToast" class="cm-toast" style="display:none;"> | |
| <div class="cm-toast-card"> | |
| <svg id="uploadSpinner" width="16" height="16" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#7ad7ff" style="display:none;"> | |
| <g fill="none" fill-rule="evenodd"> | |
| <g transform="translate(1 1)" stroke-width="2"> | |
| <circle stroke-opacity=".5" cx="18" cy="18" r="18"/> | |
| <path d="M36 18c0-9.94-8.06-18-18-18"> | |
| <animateTransform attributeName="transform" type="rotate" from="0 18 18" to="360 18 18" dur="1s" repeatCount="indefinite"/> | |
| </path> | |
| </g> | |
| </g> | |
| </svg> | |
| <span class="cm-dot"></span> | |
| <span id="cmToastMsg">Loading file… please wait</span> | |
| </div> | |
| </div> | |
| <!-- S‑Curve Loader Overlay --> | |
| <div id="cmLoader" class="cm-loader" style="display:none;"> | |
| <div class="cm-loader-card"> | |
| <svg class="cm-hero" viewBox="0 0 600 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> | |
| <!-- faint grid --> | |
| <g opacity=".12"> | |
| <path d="M0 140 H600" stroke="#9fb4df"/> | |
| <path d="M0 100 H600" stroke="#9fb4df"/> | |
| <path d="M0 60 H600" stroke="#9fb4df"/> | |
| <path d="M0 20 H600" stroke="#9fb4df"/> | |
| </g> | |
| <!-- S-curve path (animated stroke-dashoffset draw) --> | |
| <path id="cmCurve" d="M10,140 C120,120 180,40 260,60 C330,77 360,95 410,80 C480,60 500,20 590,20" | |
| fill="none" stroke="#7ad7ff" stroke-width="4" stroke-linecap="round" /> | |
| <!-- running dot --> | |
| <circle id="cmDot" r="5" fill="#22e1e8"/> | |
| </svg> | |
| <div class="cm-loader-body"> | |
| <div class="cm-title">Loading data</div> | |
| <div class="cm-sub" id="cmStep">Reading file…</div> | |
| <div class="cm-progress"> | |
| <div id="cmBar" class="cm-bar"></div> | |
| </div> | |
| <div class="cm-pct" id="cmPct">0%</div> | |
| </div> | |
| </div> | |
| </div> | |
| <main class="wrap p-6 space-y-6"> | |
| <!-- OVERVIEW --> | |
| <section> | |
| <div class="section"><h2>Overview</h2><div class="hint">Rolling burn + quick forecast</div></div> | |
| <div class="kpi-grid"> | |
| <div class="card"><div class="kpi"><div class="label">Total Cost</div><div id="k_total" class="value">—</div></div></div> | |
| <div class="card"><div class="kpi"><div class="label">Date Range</div><div id="k_window" class="value">—</div></div></div> | |
| <div class="card"><div class="kpi"><div class="label">Avg Daily Burn</div><div id="k_avg" class="value">—</div></div></div> | |
| <div class="card"><div class="kpi"><div class="label">Forecast to Target</div> | |
| <input id="targetDate" type="date" class="btn w-full my-1"/> | |
| <div id="k_forecast" class="value small">—</div> | |
| <div id="k_forecast_formula" class="note">—</div> | |
| </div></div> | |
| </div> | |
| </section> | |
| <!-- FILTERS --> | |
| <section> | |
| <div class="section"><h2>Filters</h2><div class="hint">Company search, MAFE/AFE, WBS & date slider</div></div> | |
| <div class="card"> | |
| <div class="grid lg:grid-cols-3 gap-5"> | |
| <!-- Company --> | |
| <div class="lg:col-span-2"> | |
| <div class="hint mb-1">Company</div> | |
| <div class="card" style="padding:.6rem"> | |
| <div class="flex gap-2 mb-2"> | |
| <input id="search" class="btn w-full" placeholder="Search companies…"/> | |
| <button id="selectAll" class="btn">Select all</button> | |
| <button id="clearAll" class="btn">Clear</button> | |
| </div> | |
| <div id="chips" class="flex flex-wrap gap-2 mb-2"></div> | |
| <div id="list" class="border border-[var(--border)] rounded-lg p-2 max-h-56 overflow-auto bg-[#0c1730]"></div> | |
| </div> | |
| </div> | |
| <!-- Date range --> | |
| <div> | |
| <div class="hint mb-1">Date Range</div> | |
| <div class="card"> | |
| <div class="flex justify-between text-xs text-[var(--muted)] mono mb-1"> | |
| <span id="rangeMinBadge">—</span><span id="rangeMaxBadge">—</span> | |
| </div> | |
| <div class="relative"> | |
| <div class="track"><div class="fill" id="rangeFill"></div></div> | |
| <input type="range" min="0" max="100" value="0" id="rangeMin" class="range"> | |
| <input type="range" min="0" max="100" value="100" id="rangeMax" class="range"> | |
| </div> | |
| <div class="mt-2 flex flex-wrap gap-2 text-sm"> | |
| <button class="btn quick" data-last="5">Last 5 Days</button> | |
| <button class="btn quick" data-last="7">Last 7 Days</button> | |
| <button class="btn quick" data-last="10">Last 10 Days</button> | |
| <button class="btn quick" data-last="14">Last 14 Days</button> | |
| <button class="btn quick" id="allData">All Data</button> | |
| </div> | |
| <div class="hint mt-4 mb-1">MAFE vs AFE</div> | |
| <select id="mafeFilter" class="btn w-full"> | |
| <option value="ALL">All</option> | |
| <option value="AFE">AFE</option> | |
| <option value="MAFE">MAFE</option> | |
| </select> | |
| <div class="hint mt-4 mb-1">Module</div> | |
| <select id="moduleFilter" class="btn w-full"> | |
| <option value="ALL">All</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- BREAKDOWN --> | |
| <section> | |
| <div class="section"><h2>Breakdown</h2><div class="hint">Hours are DFL only; costs and KPIs are totals</div></div> | |
| <div class="grid-fit"> | |
| <div class="card"> | |
| <h3 class="font-semibold mb-2">Hours</h3> | |
| <div class="grid-fit"> | |
| <div class="badge"><span class="dot"></span><span class="label">ST Hours</span><span id="h_st" class="val mono ml-auto">—</span></div> | |
| <div class="badge"><span class="dot" style="background:var(--blue)"></span><span class="label">OT Hours</span><span id="h_ot" class="val mono ml-auto">—</span></div> | |
| <div class="badge"><span class="dot" style="background:var(--green)"></span><span class="label">DT Hours</span><span id="h_dt" class="val mono ml-auto">—</span></div> | |
| <div class="badge"><span class="dot" style="background:var(--pink)"></span><span class="label">Total Hours</span><span id="h_total" class="val mono ml-auto">—</span></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3 class="font-semibold mb-2">Cost</h3> | |
| <div class="grid-fit"> | |
| <div class="badge"><span class="dot"></span><span class="label">ST TOT</span><span id="c_st" class="val mono ml-auto">—</span></div> | |
| <div class="badge"><span class="dot" style="background:var(--blue)"></span><span class="label">OT TOT</span><span id="c_ot" class="val mono ml-auto">—</span></div> | |
| <div class="badge"><span class="dot" style="background:var(--green)"></span><span class="label">DT TOT</span><span id="c_dt" class="val mono ml-auto">—</span></div> | |
| <div class="badge"><span class="dot" style="background:#f6a3a3"></span><span class="label">TOTALS</span><span id="c_tot" class="val mono ml-auto">—</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- TRENDS --> | |
| <section> | |
| <div class="section"><h2>Trends</h2><div class="hint">Daily & Cumulative; Top spenders; Pies below</div></div> | |
| <!-- Full-width Daily --> | |
| <div class="card mb-4"> | |
| <h3 class="font-semibold mb-2">Daily Cost (bars) + Cumulative (line)</h3> | |
| <div class="chartbox" style="height:440px"><canvas id="chartDaily"></canvas></div> | |
| </div> | |
| <!-- Full-width Cumulative --> | |
| <div class="card mb-6"> | |
| <h3 class="font-semibold mb-2">Cumulative Cost</h3> | |
| <div class="chartbox" style="height:440px"><canvas id="chartCum"></canvas></div> | |
| </div> | |
| <!-- Contractor Compare --> | |
| <div class="card mb-6"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h3 class="font-semibold">Contractor Compare</h3> | |
| <span class="hint">Uses current filters and date window. If none selected, shows top 5.</span> | |
| </div> | |
| <div class="chartbox" style="height:420px"><canvas id="chartCompare"></canvas></div> | |
| <div id="compareLegend" class="compare-legend-bottom"></div> | |
| </div> | |
| <!-- Top 10 --> | |
| <div class="grid md:grid-cols-2 gap-4"> | |
| <div class="card"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h3 class="font-semibold">Top 10 Companies (Chart)</h3> | |
| <span class="hint">Reflects current filters</span> | |
| </div> | |
| <div class="chartbox" style="height:360px"><canvas id="chartTop"></canvas></div> | |
| </div> | |
| <div class="card"><h3 class="font-semibold mb-2">Top 10 Companies (List)</h3><ol id="topList" class="space-y-2 text-sm"></ol></div> | |
| </div> | |
| <!-- MAFE vs AFE Detailed Tables --> | |
| <section class="mt-8"> | |
| <div class="section"><h2>MAFE vs AFE — Last 10 Days</h2><div class="hint">Cost and hours breakdown</div></div> | |
| <div class="grid md:grid-cols-2 gap-4"> | |
| <div class="card"> | |
| <h3 class="font-semibold mb-2">Cost Comparison</h3> | |
| <div class="overflow-auto max-h-[500px]"> | |
| <table id="afeMafeCost" class="w-full border-collapse"> | |
| <thead> | |
| <tr class="border-b border-[var(--border)]"> | |
| <th class="text-left p-2">Date</th> | |
| <th class="text-right p-2">MAFE</th> | |
| <th class="text-right p-2">Capital</th> | |
| <th class="text-right p-2">Total</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-[var(--border)]"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3 class="font-semibold mb-2">Hours Comparison</h3> | |
| <div class="overflow-auto max-h-[500px]"> | |
| <table id="afeMafeHours" class="w-full border-collapse"> | |
| <thead> | |
| <tr class="border-b border-[var(--border)]"> | |
| <th class="text-left p-2">Date</th> | |
| <th class="text-right p-2">MAFE</th> | |
| <th class="text-right p-2">AFE</th> | |
| <th class="text-right p-2">Total</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-[var(--border)]"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Pies underneath with spacing --> | |
| <div class="grid md:grid-cols-2 gap-4 mt-8"> | |
| <div class="card"> | |
| <h3 class="font-semibold mb-2">MAFE vs AFE</h3> | |
| <div class="chartbox" style="height:320px"><canvas id="chartMafe"></canvas></div> | |
| </div> | |
| <div class="card"> | |
| <h3 class="font-semibold mb-2">Pie by WBS Element (Top 10)</h3> | |
| <div class="chartbox" style="height:320px"><canvas id="chartWbs"></canvas></div> | |
| </div> | |
| </div> | |
| <!-- Cost by Module with additional spacing --> | |
| <section class="mt-8"> | |
| <div class="card"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h3 class="font-semibold">Cost by Module</h3> | |
| <span class="hint">Uses current filters and date window.</span> | |
| </div> | |
| <div class="grid md:grid-cols-2 gap-4 items-start"> | |
| <div class="chartbox" style="height:260px"><canvas id="chartModule"></canvas></div> | |
| <div> | |
| <div class="flex items-center justify-between mb-2"> | |
| <div class="hint">Module breakdown</div> | |
| <div class="flex gap-2"> | |
| <button id="moduleShowDollar" class="btn">Show $</button> | |
| <button id="moduleShowPercent" class="btn">Show %</button> | |
| </div> | |
| </div> | |
| <div class="overflow-auto max-h-[260px]"> | |
| <table id="moduleTable" class="w-full border-collapse"> | |
| <thead> | |
| <tr class="border-b border-[var(--border)]"> | |
| <th class="text-left p-2">Module</th> | |
| <th class="text-right p-2">Cost</th> | |
| <th class="text-right p-2">% of Total</th> | |
| <th class="text-right p-2">Rank</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-[var(--border)]"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </section> | |
| <!-- ===== Monte Carlo — Cost Projection ===== --> | |
| <section id="monte-carlo-section" class="mt-6"> | |
| <div class="section"><h2>Monte Carlo — Cost Projection</h2><div class="hint">Fan bands P10 / P50 / P90</div></div> | |
| <div class="card"> | |
| <div class="flex flex-wrap items-center gap-2"> | |
| <span class="hint uppercase text-xs mr-1">Rate Window</span> | |
| <button class="btn" id="mc_win_30" data-window="30">30d</button> | |
| <button class="btn" id="mc_win_45" data-window="45">45d</button> | |
| <button class="btn" id="mc_win_60" data-window="60">60d</button> | |
| <div class="mx-2"></div> | |
| <label class="hint">Current Burn Rate ($/day)</label> | |
| <input id="mc_manualRate" type="text" inputmode="decimal" class="btn mono" placeholder="auto" style="width:140px" /> | |
| <div class="mx-2"></div> | |
| <label class="hint">±%</label> | |
| <input id="mc_uncertainty" type="range" min="0" max="50" value="20" style="accent-color:#22e1e8;width:130px"> | |
| <span id="mc_uncertaintyLabel" class="mono hint" style="width:36px;text-align:center">20%</span> | |
| <div class="mx-2"></div> | |
| <label class="hint">Horizon</label> | |
| <input id="mc_horizon" type="number" min="1" max="365" value="60" class="btn mono" style="width:80px"> | |
| <label class="hint ml-2">Iters</label> | |
| <input id="mc_iters" type="number" min="100" max="20000" step="100" value="5000" class="btn mono" style="width:90px"> | |
| <div class="ml-auto flex gap-2"> | |
| <button id="mc_runBtn" class="btn">Run</button> | |
| <button id="mc_exportBtn" class="btn">Export CSV</button> | |
| </div> | |
| </div> | |
| <div class="hint mt-2"> | |
| 💡 Tip: Rate window averages recent daily burn. Or type a Current Burn Rate. Adjust ±%, horizon, and iters, then click <b>Run</b>. Export CSV to download results. | |
| </div> | |
| <div class="grid lg:grid-cols-12 gap-3 mt-3"> | |
| <div class="card lg:col-span-8" style="height:460px"> | |
| <canvas id="mc_fanChart" style="width:100%;height:100%"></canvas> | |
| </div> | |
| <div class="lg:col-span-4 grid gap-3"> | |
| <div class="card"> | |
| <div class="kpi-title hint">Current Total</div> | |
| <div id="mc_kpiCurrent" class="value mono">$—</div> | |
| </div> | |
| <div class="card"> | |
| <div class="kpi-title hint">Projected Total (P10)</div> | |
| <div id="mc_kpiP10" class="value mono" style="color:#22e1e8">$—</div> | |
| </div> | |
| <div class="card"> | |
| <div class="kpi-title hint">Projected Total (P50)</div> | |
| <div id="mc_kpiP50" class="value mono" style="color:#ffc455">$—</div> | |
| </div> | |
| <div class="card"> | |
| <div class="kpi-title hint">Projected Total (P90)</div> | |
| <div id="mc_kpiP90" class="value mono" style="color:#f08ac3">$—</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- COMPANY COST & HOURS (DFL Labor only) --> | |
| <section> | |
| <div class="section"><h2>Company Cost & Hours (DFL Labor)</h2> | |
| <div class="hint">Filters applied above still apply. Rows include only Staff = DFL and Module = Labor.</div> | |
| </div> | |
| <div class="card overflow-auto"> | |
| <table id="companyCostHoursTable" class="w-full border-collapse table-auto"> | |
| <thead> | |
| <tr class="border-b border-[var(--border)]"> | |
| <th class="text-left p-2">Company</th> | |
| <th class="text-right p-2">Total Hours</th> | |
| <th class="text-right p-2">Total Cost</th> | |
| <th class="text-right p-2">$ per Hour</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-[var(--border)]"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| const $=(id)=>document.getElementById(id); | |
| const fmt=(n)=> n?.toLocaleString(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}) ?? '—'; | |
| const fmt0=(n)=> isNaN(n)?'—':n.toLocaleString(undefined,{maximumFractionDigits:0}); | |
| const parseDate=(v)=>{ | |
| if(v==null||v==='')return null; | |
| if(typeof v==='number'){const s=String(v);if(s.length===8)return new Date(+s.slice(0,4),+s.slice(4,6)-1,+s.slice(6,8));return new Date((v-25569)*86400*1000);} | |
| if(typeof v==='string'){const s=v.trim();if(/^\\d{8}$/.test(s))return new Date(+s.slice(0,4),+s.slice(4,6)-1,+s.slice(6,8));const d=new Date(s);return isNaN(d)?null:d;} | |
| const d=new Date(v);return isNaN(d)?null:d; | |
| }; | |
| const parseNum=(v)=>{if(v==null||v==='')return NaN;if(typeof v==='number')return v; let s=v.toString().trim(); const neg=/^\\(.*\\)$/.test(s); s=s.replace(/^\\((.*)\\)$/,'$1').replace(/[$,]/g,''); const n=parseFloat(s); return isNaN(n)?NaN:(neg?-n:n);}; | |
| const norm=(s)=> (s??'').toString().trim().toLowerCase(); | |
| const isDFL = v => String(v||'').toUpperCase().includes('DFL'); | |
| let RAW=[], FILTERED=[], DAILY=[], CUM=[], TOP=[], charts={}; | |
| let ALL_DATES=[]; let COMPANIES=[], SELECTED=new Set(); let MAFE='ALL'; let MAFE_EXACT='ALL'; let MODULE='ALL'; | |
| // Define a harmonious color palette that fits the current theme | |
| const chartColors = [ | |
| '#22e1e8', // teal from gradient | |
| '#7ad7ff', // blue from gradient | |
| '#a87fff', // purple accent | |
| '#f5a524', // amber/yellow | |
| '#86efac', // green accent | |
| '#f08ac3', // pink accent | |
| '#ffa055', // orange accent | |
| '#35b5ff', // lighter blue | |
| '#1dc6c2', // slightly deeper teal | |
| '#c28bff' // lighter purple | |
| ]; | |
| function mapRow(r){ | |
| const k=Object.fromEntries(Object.keys(r).map(x=>[norm(x),r[x]])); | |
| return { | |
| module:(k['module']||k['module name']||k['mod']||k['mdl']||'').toString().trim(), | |
| ts:parseDate(k['ts date']||k['ts_date']||k['tsdate']||k['date']||k['ts']), | |
| total:parseNum(k['grand total']||k['grandtotal']||k['total']||k['amount']||k['totals']), | |
| company:(k['company']||k['vendor']||k['contractor']||'').toString().trim(), | |
| staff:(k['staff']||k['labor type']||k['category']||k['staff type']||'').toString().trim(), | |
| st_hours:parseNum(k['st hours']||k['sthours']||k['s thours']), | |
| ot_hours:parseNum(k['ot hours']||k['othours']), | |
| dt_hours:parseNum(k['dt hours']||k['dthours']), | |
| st_tot:parseNum(k['st tot']||k['st total']||k['st cost']), | |
| ot_tot:parseNum(k['ot tot']||k['ot total']||k['ot cost']), | |
| dt_tot:parseNum(k['dt tot']||k['dt total']||k['dt cost']), | |
| mafe_raw:(k['mafe vs afe']||k['mafe_vs_afe']||k['mafe/afe']||'').toString().trim(), | |
| mafe:(()=>{ | |
| const raw=(k['mafe vs afe']||k['mafe_vs_afe']||k['mafe/afe']||'').toString().trim(); | |
| const u=raw.toUpperCase(); | |
| if(u.includes('MAFE')) return 'MAFE'; | |
| if(u.includes('AFE')) return 'AFE'; | |
| return u; | |
| })(), | |
| wbs:(k['wbs element']||k['wbs']||'').toString().trim() | |
| }; | |
| } | |
| const uniqueSorted = arr => Array.from(new Set(arr.filter(Boolean))).sort((a,b)=>a>b?1:-1); | |
| // Company picker | |
| function buildPicker(){ | |
| COMPANIES = uniqueSorted(RAW.map(r=>r.company)); | |
| const list=$('list'), chips=$('chips'); | |
| function renderList(){ | |
| list.innerHTML=''; | |
| const q=($('search').value||'').toLowerCase(); | |
| COMPANIES.filter(c=>!q||c.toLowerCase().includes(q)).forEach(c=>{ | |
| const row=document.createElement('label'); row.className='flex items-center gap-2 py-1'; | |
| row.innerHTML=`<input type="checkbox" ${SELECTED.has(c)?'checked':''} /><span>${c||'—'}</span>`; | |
| const cb=row.querySelector('input'); | |
| cb.addEventListener('change',(e)=>{ | |
| if(e.target.checked) SELECTED.add(c); else SELECTED.delete(c); | |
| renderChips(); | |
| applyFilters(); // Update filters immediately when selection changes | |
| }); | |
| list.appendChild(row); | |
| }); | |
| } | |
| function renderChips(){ | |
| chips.innerHTML=''; | |
| if(!SELECTED.size){ chips.innerHTML='<span class="hint">No companies selected (all included)</span>'; return; } | |
| SELECTED.forEach(c=>{ | |
| const s=document.createElement('span'); s.className='px-2 py-1 rounded-full border border-[var(--border)] bg-[#0b1830] text-sm'; s.textContent=c; | |
| const x=document.createElement('span'); x.textContent=' ×'; x.style.cursor='pointer'; x.style.opacity='.7'; | |
| x.onclick=()=>{ SELECTED.delete(c); renderList(); renderChips(); applyFilters(); }; | |
| s.appendChild(x); chips.appendChild(s); | |
| }); | |
| } | |
| $('selectAll').onclick=()=>{ | |
| COMPANIES.forEach(c=>SELECTED.add(c)); | |
| renderList(); | |
| renderChips(); | |
| applyFilters(); | |
| }; | |
| $('clearAll').onclick=()=>{ | |
| SELECTED.clear(); | |
| renderList(); | |
| renderChips(); | |
| applyFilters(); | |
| }; | |
| $('search').oninput=renderList; | |
| renderList(); renderChips(); | |
| } | |
| // Range slider | |
| function seedRange(){ | |
| ALL_DATES = uniqueSorted(RAW.map(r=>r.ts&&r.ts.toISOString().slice(0,10))); | |
| const max=Math.max(ALL_DATES.length-1,0); | |
| $('rangeMin').min=0; $('rangeMax').min=0; | |
| $('rangeMin').max=max; $('rangeMax').max=max; | |
| $('rangeMin').value=0; $('rangeMax').value=max; | |
| updateRangeUI(); | |
| } | |
| function updateRangeUI(){ | |
| const lo=Math.min(+$('rangeMin').value,+$('rangeMax').value), hi=Math.max(+$('rangeMin').value,+$('rangeMax').value); | |
| $('rangeMin').value=lo; $('rangeMax').value=hi; | |
| $('rangeMinBadge').textContent=ALL_DATES[lo]||'—'; | |
| $('rangeMaxBadge').textContent=ALL_DATES[hi]||'—'; | |
| const a=(ALL_DATES.length<=1)?0:lo/(ALL_DATES.length-1)*100; | |
| const b=(ALL_DATES.length<=1)?100:hi/(ALL_DATES.length-1)*100; | |
| const f=$('rangeFill'); f.style.left=a+'%'; f.style.right=(100-b)+'%'; | |
| applyFilters(); | |
| } | |
| function setLast(n){ if(!ALL_DATES.length) return; $('rangeMax').value=ALL_DATES.length-1; $('rangeMin').value=Math.max(0,ALL_DATES.length-n); updateRangeUI(); } | |
| function setAll(){ if(!ALL_DATES.length) return; $('rangeMin').value=0; $('rangeMax').value=ALL_DATES.length-1; updateRangeUI(); } | |
| // Aggregation | |
| function applyFilters(){ | |
| if(!RAW.length) return; | |
| const lo=+$('rangeMin').value, hi=+$('rangeMax').value; | |
| const loDate=ALL_DATES[Math.min(lo,hi)]||null, hiDate=ALL_DATES[Math.max(lo,hi)]||null; | |
| const sel=Array.from(SELECTED.values()); | |
| FILTERED = RAW.filter(r=>{ | |
| const d=r.ts&&r.ts.toISOString().slice(0,10); | |
| if(loDate && d<loDate) return false; | |
| if(hiDate && d>hiDate) return false; | |
| // Company filter - check if selected companies exist and match | |
| if(sel.length > 0) { | |
| const companyMatch = sel.some(selectedCompany => { | |
| return (r.company || '').trim().toLowerCase() === selectedCompany.trim().toLowerCase(); | |
| }); | |
| if(!companyMatch) return false; | |
| } | |
| if(MAFE_EXACT!=='ALL' && (r.mafe_raw||'')!==MAFE_EXACT) return false; | |
| if(MAFE!=='ALL' && (r.mafe||'').toUpperCase()!==MAFE) return false; | |
| if(MODULE!=='ALL' && String(r.module||'').toUpperCase()!==String(MODULE).toUpperCase()) return false; | |
| return true; | |
| }); | |
| aggregateAndRender(); | |
| } | |
| function aggregateAndRender(){ | |
| const by=new Map(); | |
| FILTERED.forEach(r=>{const d=r.ts&&r.ts.toISOString().slice(0,10); if(!d)return; by.set(d,(by.get(d)||0)+(r.total||0));}); | |
| DAILY=Array.from(by.entries()).sort((a,b)=>a[0].localeCompare(b[0])).map(([d,v])=>({d,v})); | |
| let run=0; CUM=DAILY.map(x=>{run+=x.v; return {d:x.d,v:run};}); | |
| const comp = new Map(); | |
| FILTERED.forEach(r=>{ const c = r.company || '—'; comp.set(c, (comp.get(c)||0) + (r.total||0)); }); | |
| TOP = Array.from(comp.entries()).sort((a,b)=>b[1]-a[1]).slice(0,10); | |
| const tot=CUM.length?CUM[CUM.length-1].v:0, avg=DAILY.length?tot/DAILY.length:0; | |
| $('k_total').textContent=fmt(tot); $('k_avg').textContent=fmt(avg); | |
| $('k_window').textContent=DAILY.length ? (DAILY[0].d+' → '+DAILY[DAILY.length-1].d) : '—'; | |
| const sum=(arr,k)=>arr.reduce((a,b)=>a+(+b[k]||0),0); | |
| const DFILTERED = FILTERED.filter(x => isDFL(x.staff)); | |
| const hst=sum(DFILTERED,'st_hours'), hot=sum(DFILTERED,'ot_hours'), hdt=sum(DFILTERED,'dt_hours'); | |
| $('h_st').textContent=fmt0(hst); $('h_ot').textContent=fmt0(hot); $('h_dt').textContent=fmt0(hdt); $('h_total').textContent=fmt0(hst+hot+hdt); | |
| const cst=sum(FILTERED,'st_tot'), cot=sum(FILTERED,'ot_tot'), cdt=sum(FILTERED,'dt_tot'); | |
| $('c_st').textContent=fmt0(cst); $('c_ot').textContent=fmt0(cot); $('c_dt').textContent=fmt0(cdt); | |
| $('c_tot').textContent=fmt0(cst+cot+cdt); | |
| drawCharts(); drawCompare(); drawTopList(TOP); | |
| drawMafePie(); drawWbsPie(); drawModulePie(); | |
| updateMafeAfeTables(); // Add this to ensure tables update | |
| calcForecast(); | |
| renderCompanyCostHours(); | |
| } | |
| function updateMafeAfeTables() { | |
| if (!FILTERED.length) return; | |
| // Get last 10 unique dates from filtered data | |
| const dates = Array.from(new Set(FILTERED.map(r => r.ts && r.ts.toISOString().slice(0,10)))) | |
| .filter(Boolean) | |
| .sort((a,b) => new Date(a) - new Date(b)) | |
| .slice(-10); | |
| // Group by date and MAFE/AFE | |
| const costByDate = {}; | |
| const hoursByDate = {}; | |
| // Initialize all dates with zero values | |
| dates.forEach(d => { | |
| costByDate[d] = { MAFE: 0, AFE: 0 }; | |
| hoursByDate[d] = { | |
| MAFE: { st: 0, ot: 0, dt: 0, total: 0 }, | |
| AFE: { st: 0, ot: 0, dt: 0, total: 0 } | |
| }; | |
| }); | |
| // Process filtered data | |
| FILTERED.forEach(r => { | |
| const d = r.ts && r.ts.toISOString().slice(0,10); | |
| if (!d || !dates.includes(d)) return; | |
| const isMAFE = (r.mafe || '').toUpperCase().includes('MAFE'); | |
| const key = isMAFE ? 'MAFE' : 'AFE'; | |
| // Sum costs | |
| costByDate[d][key] += r.total || 0; | |
| // Sum hours (only DFL) | |
| if (isDFL(r.staff)) { | |
| hoursByDate[d][key].st += r.st_hours || 0; | |
| hoursByDate[d][key].ot += r.ot_hours || 0; | |
| hoursByDate[d][key].dt += r.dt_hours || 0; | |
| hoursByDate[d][key].total += (r.st_hours || 0) + (r.ot_hours || 0) + (r.dt_hours || 0); | |
| } | |
| }); | |
| // Render cost table | |
| const costTable = document.getElementById('afeMafeCost').querySelector('tbody'); | |
| costTable.innerHTML = dates.map(d => { | |
| const mafe = costByDate[d].MAFE; | |
| const afe = costByDate[d].AFE; | |
| const total = mafe + afe; | |
| return ` | |
| <tr class="hover:bg-[var(--panel)]"> | |
| <td class="p-2">${d}</td> | |
| <td class="p-2 text-right mono">${fmt(mafe)}</td> | |
| <td class="p-2 text-right mono">${fmt(afe)} </td> | |
| <td class="p-2 text-right mono">${fmt(total)}</td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| // Render hours table | |
| const hoursTable = document.getElementById('afeMafeHours').querySelector('tbody'); | |
| hoursTable.innerHTML = dates.map(d => { | |
| const mafe = hoursByDate[d].MAFE.total; | |
| const afe = hoursByDate[d].AFE.total; | |
| return ` | |
| <tr class="hover:bg-[var(--panel)]"> | |
| <td class="p-2">${d}</td> | |
| <td class="p-2 text-right mono">${fmt0(mafe)}</td> | |
| <td class="p-2 text-right mono">${fmt0(afe)}</td> | |
| <td class="p-2 text-right mono">${fmt0(mafe + afe)}</td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| } | |
| function renderCompanyCostHours(){ | |
| const table = document.getElementById('companyCostHoursTable'); | |
| if(!table){ return; } | |
| const tbody = table.querySelector('tbody'); | |
| if(!Array.isArray(FILTERED) || !FILTERED.length){ | |
| tbody.innerHTML = `<tr><td colspan="4" class="p-3 hint">Upload data and/or widen your date range/filters to see company breakdown.</td></tr>`; | |
| return; | |
| } | |
| const rows = FILTERED.filter(r => { | |
| const staff = String(r.staff||'').toUpperCase(); | |
| const module = String(r.module||'').toUpperCase(); | |
| return staff.includes('DFL') && module === 'LABOR'; | |
| }); | |
| const by = new Map(); | |
| rows.forEach(r=>{ | |
| const c = (r.company||'—').trim() || '—'; | |
| const hrs = (r.st_hours||0)+(r.ot_hours||0)+(r.dt_hours||0); | |
| const usd = (r.st_tot||0)+(r.ot_tot||0)+(r.dt_tot||0); | |
| const acc = by.get(c) || {hours:0, cost:0}; | |
| acc.hours += (isFinite(hrs)?hrs:0); | |
| acc.cost += (isFinite(usd)?usd:0); | |
| by.set(c, acc); | |
| }); | |
| const items = Array.from(by.entries()).sort((a,b)=>b[1].cost - a[1].cost); | |
| const _fmt = (typeof fmt === 'function') ? fmt : (n)=> (isFinite(n)? n.toLocaleString(undefined,{style:'currency',currency:'USD'}):'—'); | |
| const _fmt0 = (typeof fmt0 === 'function') ? fmt0 : (n)=> (isFinite(n)? Math.round(n).toLocaleString():'—'); | |
| tbody.innerHTML = items.map(([name, v])=>{ | |
| const rate = v.hours ? (v.cost / v.hours) : 0; | |
| return `<tr class="hover:bg-[var(--panel)]"> | |
| <td class="p-2">${name}</td> | |
| <td class="p-2 text-right mono">${_fmt0(v.hours)}</td> | |
| <td class="p-2 text-right mono">${_fmt(v.cost)}</td> | |
| <td class="p-2 text-right mono">${_fmt(rate)}</td> | |
| </tr>`; | |
| }).join(''); | |
| if(items.length===0){ | |
| tbody.innerHTML = `<tr><td colspan="4" class="p-3 hint">No rows matched Staff = DFL and Module = Labor.</td></tr>`; | |
| } | |
| } | |
| function drawTopList(arr){ | |
| const ol = $('topList'); ol.innerHTML=''; | |
| if(!arr.length){ol.innerHTML='<li class="hint">No data</li>';return;} | |
| const max=Math.max(...arr.map(t=>t[1])); | |
| arr.forEach(([name,val],i)=>{ | |
| const li=document.createElement('li'); | |
| li.innerHTML = `<div class="flex items-center gap-2"> | |
| <span class="mono text-xs" style="width:1.5rem;opacity:.7">${(i+1).toString().padStart(2,'0')}</span> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex justify-between gap-2"> | |
| <span class="truncate">${name||'—'}</span> | |
| <span class="mono">${fmt(val)}</span> | |
| </div> | |
| <div style="height:6px;background:#0f1e39;border:1px solid var(--border);border-radius:999px;margin-top:4px;position:relative"> | |
| <div style="position:absolute;left:0;top:0;height:6px;border-radius:999px;background:linear-gradient(90deg,var(--teal),#7ad7ff);width:${max?(val/max*100).toFixed(1):0}%"></div> | |
| </div> | |
| </div> | |
| </div>`; | |
| ol.appendChild(li); | |
| }); | |
| } | |
| function drawCompare(){ | |
| const ctx = document.getElementById('chartCompare'); if(!ctx) return; | |
| if (charts.compare){ charts.compare.destroy(); charts.compare=null; } | |
| const legend = document.getElementById('compareLegend'); if(legend) legend.innerHTML=''; | |
| if (!FILTERED.length){ charts.compare = new Chart(ctx,{type:'line',data:{labels:[],datasets:[]},options:{responsive:true,maintainAspectRatio:false}}); return; } | |
| const modeBtn = document.getElementById('compareMode'); | |
| const mode = modeBtn?.dataset.mode || 'absolute'; // 'absolute' or 'indexed' | |
| // Build date index for current window | |
| const dates = Array.from(new Set(FILTERED.map(r=>r.ts && r.ts.toISOString().slice(0,10)))).filter(Boolean).sort(); | |
| const dateIdx = new Map(dates.map((d,i)=>[d,i])); | |
| // Choose companies: selected chips, or top 5 by filtered spend | |
| let chosen = Array.from(SELECTED.values()); | |
| if(!chosen.length){ | |
| const comp = new Map(); | |
| FILTERED.forEach(r=>{ const c=r.company||'—'; comp.set(c,(comp.get(c)||0)+(r.total||0)); }); | |
| chosen = Array.from(comp.entries()).sort((a,b)=>b[1]-a[1]).slice(0,5).map(x=>x[0]); | |
| } | |
| // Build per-day and cumulative per company | |
| const daily = {}; const series = {}; | |
| chosen.forEach(c=>{ daily[c]=new Array(dates.length).fill(0); series[c]=new Array(dates.length).fill(0); }); | |
| FILTERED.forEach(r=>{ | |
| const c=r.company||'—'; if(!daily.hasOwnProperty(c)) return; | |
| const d=r.ts && r.ts.toISOString().slice(0,10); const i=dateIdx.get(d); if(i==null) return; | |
| daily[c][i] += (r.total||0); | |
| }); | |
| Object.keys(series).forEach(c=>{ | |
| let run=0; series[c] = daily[c].map(v=>{ run+=v; return run; }); // cumulative | |
| }); | |
| // Optional indexing | |
| if(mode==='indexed'){ | |
| Object.keys(series).forEach(c=>{ | |
| const arr = series[c]; | |
| const base = arr.find(v=>v>0) || 0; | |
| if(base>0){ series[c] = arr.map(v=> (v/base)*100 ); } | |
| }); | |
| } | |
| // datasets and legend | |
| const datasets = Object.keys(series).map((c,i)=>({ | |
| label:c, | |
| data:series[c], | |
| borderColor:chartColors[i%chartColors.length], | |
| tension:.25, | |
| pointRadius:0 | |
| })); | |
| // Custom legend pills with latest values | |
| if(legend){ | |
| Object.keys(series).forEach((c,i)=>{ | |
| const latest = series[c].length ? series[c][series[c].length-1] : 0; | |
| const pill = document.createElement('div'); pill.className='compare-pill'; | |
| pill.innerHTML = `<span class="compare-dot" style="background:${chartColors[i%chartColors.length]}"></span><span>${c}</span><span class="val">${mode==='indexed'? latest.toFixed(0): (latest||0).toLocaleString()}</span>`; | |
| pill.addEventListener('click',()=>{ // toggle dataset visibility | |
| const ds = charts.compare.data.datasets[i]; ds.hidden = !ds.hidden; charts.compare.update(); | |
| pill.style.opacity = ds.hidden? .45 : 1; | |
| }); | |
| legend.appendChild(pill); | |
| }); | |
| } | |
| charts.compare = new Chart(ctx,{ | |
| type:'line', | |
| data:{ labels:dates, datasets }, | |
| options:{ | |
| responsive:true, | |
| maintainAspectRatio:false, | |
| plugins:{ legend:{display:false} }, | |
| interaction:{mode:'nearest',intersect:false}, | |
| scales:{ | |
| y:{ | |
| grid: { color: 'rgba(255,255,255,.05)' }, | |
| ticks: { | |
| callback:(v)=> mode==='indexed'? (v|0) : (typeof v==='number'? v.toLocaleString():v) | |
| } | |
| }, | |
| x: { | |
| grid: { color: 'rgba(255,255,255,.05)' } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| function drawCharts(){ | |
| const labels=DAILY.map(x=>x.d); | |
| ['daily','cum','top'].forEach(k=>{ if(charts[k]){charts[k].destroy(); charts[k]=null;} }); | |
| // Combined chart: Daily bars (left y) + Cumulative line (right y) | |
| charts.daily = new Chart(document.getElementById('chartDaily'), { | |
| data: { | |
| labels, | |
| datasets: [ | |
| { type:'bar', label:'Daily', data:DAILY.map(x=>x.v), | |
| backgroundColor:'rgba(34,225,232,0.25)', borderColor:'#22e1e8', borderWidth:1, yAxisID:'y' }, | |
| { type:'line', label:'Cumulative', data:CUM.map(x=>x.v), | |
| borderColor:'#7ad7ff', backgroundColor:'rgba(122,215,255,0.12)', | |
| borderWidth:2, tension:0.25, pointRadius:0, yAxisID:'y1' } | |
| ] | |
| }, | |
| options:{ | |
| responsive:true, maintainAspectRatio:false, | |
| interaction:{mode:'index',intersect:false}, | |
| plugins:{legend:{display:true}}, | |
| scales:{ | |
| x:{ grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'} }, | |
| y:{ position:'left', grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'}, | |
| ticks:{ callback:(v)=> v.toLocaleString() } }, | |
| y1:{ position:'right', grid:{ drawOnChartArea:false }, border:{color:'rgba(255,255,255,.1)'}, | |
| ticks:{ callback:(v)=> v.toLocaleString() } } | |
| } | |
| } | |
| }); | |
| // Keep standalone cumulative chart below | |
| const optsLine={ | |
| responsive:true, maintainAspectRatio:false, animation:false, | |
| plugins:{legend:{display:false}}, | |
| scales:{ | |
| x:{ grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'} }, | |
| y:{ grid:{color:'rgba(255,255,255,.05)'}, border:{color:'rgba(255,255,255,.1)'}, | |
| ticks:{ callback:(v)=> v.toLocaleString() } } | |
| } | |
| }; | |
| charts.cum=new Chart(document.getElementById('chartCum'),{ | |
| type:'line', | |
| data:{labels, datasets:[{label:'Cumulative', data:CUM.map(x=>x.v), | |
| borderColor:'#7ad7ff', backgroundColor:'rgba(122,215,255,0.1)', | |
| borderWidth:2, tension:0.25, pointRadius:0}]}, | |
| options:optsLine | |
| }); | |
| charts.top=new Chart(document.getElementById('chartTop'),{ | |
| type:'bar', | |
| data:{labels:TOP.map(t=>t[0]), datasets:[{data:TOP.map(t=>t[1]), | |
| backgroundColor: TOP.map((_,i)=>['#22e1e8','#7ad7ff','#a87fff','#f5a524','#86efac','#f08ac3','#ffa055','#35b5ff','#1dc6c2','#c28bff'][i%10]), | |
| borderColor:'#111', borderWidth:1}]}, | |
| options:optsLine | |
| }); | |
| } | |
| function pieOptions(){return { | |
| responsive:true, | |
| maintainAspectRatio:false, | |
| plugins:{ | |
| legend:{ | |
| display:true, | |
| position:'right', | |
| labels:{ | |
| usePointStyle: true, | |
| color: '#cfe0ff', | |
| font: { size: 12 }, | |
| padding: 20 | |
| } | |
| } | |
| } | |
| }} | |
| function drawMafePie(){ | |
| const ctx=document.getElementById('chartMafe'); if(!ctx) return; | |
| if (charts.mafe) charts.mafe.destroy(); | |
| if (!FILTERED.length){ | |
| charts.mafe = new Chart(ctx,{ | |
| type:'pie', | |
| data:{labels:[],datasets:[{data:[]}]}, | |
| options:pieOptions() | |
| }); | |
| return; | |
| } | |
| const totals=new Map(); | |
| FILTERED.forEach(r=>{const k=r.mafe_raw||'—'; totals.set(k,(totals.get(k)||0)+(r.total||0));}); | |
| const labels=Array.from(totals.keys()); | |
| const data=labels.map(k=>totals.get(k)); | |
| charts.mafe=new Chart(ctx,{ | |
| type:'pie', | |
| data:{ | |
| labels, | |
| datasets:[{ | |
| data, | |
| backgroundColor: chartColors, | |
| borderColor: '#0b1222', | |
| borderWidth: 2 | |
| }] | |
| }, | |
| options:pieOptions() | |
| }); | |
| } | |
| function drawWbsPie(){ | |
| const ctx=document.getElementById('chartWbs'); if(!ctx) return; | |
| if (charts.wbs) charts.wbs.destroy(); | |
| if (!FILTERED.length){ | |
| charts.wbs = new Chart(ctx,{ | |
| type:'pie', | |
| data:{labels:[],datasets:[{data:[]}]}, | |
| options:pieOptions() | |
| }); | |
| return; | |
| } | |
| const totals=new Map(); | |
| FILTERED.forEach(r=>{const k=r.wbs||'—'; totals.set(k,(totals.get(k)||0)+(r.total||0));}); | |
| const top=Array.from(totals.entries()).sort((a,b)=>b[1]-a[1]).slice(0,10); | |
| const labels=top.map(x=>x[0]); const data=top.map(x=>x[1]); | |
| charts.wbs=new Chart(ctx,{ | |
| type:'pie', | |
| data:{ | |
| labels, | |
| datasets:[{ | |
| data, | |
| backgroundColor: chartColors, | |
| borderColor: '#0b1222', | |
| borderWidth: 2 | |
| }] | |
| }, | |
| options:pieOptions() | |
| }); | |
| } | |
| function drawModulePie(){ | |
| const ctx=document.getElementById('chartModule'); if(!ctx) return; | |
| if (charts.module) charts.module.destroy(); | |
| const tbody = document.querySelector('#moduleTable tbody'); | |
| if (!FILTERED.length){ | |
| charts.module = new Chart(ctx,{ type:'doughnut', data:{labels:[],datasets:[{data:[]}]}, options:pieOptions() }); | |
| if(tbody) tbody.innerHTML = '<tr><td class="p-2 hint" colspan="4">No data</td></tr>'; | |
| return; | |
| } | |
| // Build totals per module | |
| const totals=new Map(); | |
| FILTERED.forEach(r=>{const k=(r.module||'—'); totals.set(k,(totals.get(k)||0)+(r.total||0));}); | |
| // Sort by value desc | |
| const entries = Array.from(totals.entries()).sort((a,b)=>b[1]-a[1]); | |
| const labels=entries.map(x=>x[0]); | |
| const data=entries.map(x=>x[1]); | |
| charts.module=new Chart(ctx,{ | |
| type:'doughnut', | |
| data:{ | |
| labels, | |
| datasets:[{ | |
| data, | |
| backgroundColor: chartColors, | |
| borderColor: '#0b1222', | |
| borderWidth: 2 | |
| }] | |
| }, | |
| options:(function(){ | |
| const base=pieOptions(); | |
| base.cutout='58%'; | |
| if(!base.plugins) base.plugins={}; | |
| base.plugins.legend={display:false}; | |
| base.plugins.tooltip={callbacks:{label:(c)=>{ | |
| const v=c.parsed; | |
| const sum = data.reduce((a,b)=>a+b,0); | |
| const pct = sum? (v/sum*100).toFixed(1) : '—'; | |
| return `${c.label}: ${fmt(v)} (${pct}%)`; | |
| }}}; | |
| base.onHover=(e,elements)=>{ | |
| const rows = document.querySelectorAll('#moduleTable tbody tr'); | |
| rows.forEach(r=>r.style.background=''); | |
| if(elements && elements.length){ | |
| const idx = elements[0].index; | |
| const row = rows[idx]; | |
| if(row){ row.style.background='rgba(255,255,255,0.06)'; } | |
| } | |
| }; | |
| return base; | |
| })() | |
| }); | |
| // Render right-side table | |
| if(tbody){ | |
| const totalSum = data.reduce((a,b)=>a+b,0); | |
| tbody.innerHTML = labels.map((name, i)=>{ | |
| const val = data[i]; | |
| const pct = totalSum ? (val/totalSum*100) : 0; | |
| return ` | |
| <tr data-index="${i}" class="hover:bg-[var(--panel)]"> | |
| <td class="p-2">${name||'—'}</td> | |
| <td class="p-2 text-right mono">${fmt(val)}</td> | |
| <td class="p-2 text-right mono">${pct.toFixed(1)}%</td> | |
| <td class="p-2 text-right mono">${(i+1)}</td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| // Interactivity: hover and click syncing | |
| tbody.querySelectorAll('tr').forEach(tr=>{ | |
| tr.addEventListener('mouseenter', (ev)=>{ | |
| const idx = +ev.currentTarget.dataset.index; | |
| if(Number.isFinite(idx)){ | |
| charts.module.setActiveElements([{datasetIndex:0, index:idx}]); | |
| charts.module.update(); | |
| } | |
| }); | |
| tr.addEventListener('mouseleave', ()=>{ | |
| charts.module.setActiveElements([]); | |
| charts.module.update(); | |
| }); | |
| tr.addEventListener('click', (ev)=>{ | |
| const idx = +ev.currentTarget.dataset.index; | |
| const ds = charts.module.data.datasets[0]; | |
| if(ds._isoIndex === idx){ | |
| delete ds._isoIndex; | |
| ds.backgroundColor = labels.map((_,i)=> chartColors[i%chartColors.length]); | |
| }else{ | |
| ds._isoIndex = idx; | |
| ds.backgroundColor = labels.map((_,i)=> i===idx ? chartColors[i%chartColors.length] : 'rgba(255,255,255,0.15)'); | |
| } | |
| charts.module.update(); | |
| }); | |
| }); | |
| // Toggle buttons for $ vs % | |
| const btn$ = document.getElementById('moduleShowDollar'); | |
| const btnP = document.getElementById('moduleShowPercent'); | |
| if(btn$ && btnP){ | |
| btn$.onclick = ()=>{ | |
| tbody.querySelectorAll('tr').forEach((tr,i)=>{ | |
| const val = data[i]; | |
| const pct = totalSum ? (val/totalSum*100) : 0; | |
| tr.children[1].textContent = fmt(val); | |
| tr.children[2].textContent = pct.toFixed(1) + '%'; | |
| }); | |
| }; | |
| btnP.onclick = ()=>{ | |
| tbody.querySelectorAll('tr').forEach((tr,i)=>{ | |
| const val = data[i]; | |
| const pct = totalSum ? (val/totalSum*100) : 0; | |
| tr.children[1].textContent = pct.toFixed(1) + '%'; | |
| tr.children[2].textContent = fmt(val); | |
| }); | |
| }; | |
| } | |
| } | |
| } | |
| function | |
| calcForecast(){ | |
| const t=$('targetDate').value?new Date($('targetDate').value):null; | |
| if(!t||!DAILY.length){$('k_forecast').textContent='—';$('k_forecast_formula').textContent='—';return;} | |
| const end=new Date(DAILY[DAILY.length-1].d); | |
| const rem=Math.max(Math.ceil((t-end)/86400000),0); | |
| const ac=CUM[CUM.length-1].v; | |
| const burn = DAILY.length ? ac/DAILY.length : 0; // average over selected window | |
| const eac=ac + rem*burn; | |
| $('k_forecast').textContent = fmt(eac); | |
| $('k_forecast_formula').textContent = `(${rem} days × ${fmt(burn)})`; | |
| } | |
| // === Loader control === | |
| (function(){ | |
| const $ = (id)=>document.getElementById(id); | |
| const loader = $('cmLoader'), bar = $('cmBar'), pct = $('cmPct'), step = $('cmStep'); | |
| // path animations setup | |
| const curve = document.getElementById('cmCurve'); | |
| const dot = document.getElementById('cmDot'); | |
| function animateCurve(){ | |
| try{ | |
| const len = curve.getTotalLength(); | |
| curve.style.strokeDasharray = len + ' ' + len; | |
| curve.style.strokeDashoffset = len; | |
| curve.style.animation = 'cmDraw 1.6s ease forwards, cmDash 1.2s ease-in-out infinite alternate'; | |
| // dot motion along path | |
| let t=0; const dur=1800; | |
| function tick(ts0){ | |
| const ts = performance.now(); | |
| const p = (ts % dur) / dur; | |
| const pt = curve.getPointAtLength(p*len); | |
| dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y); | |
| dot.style.animation = 'cmGlow 1.6s ease-in-out infinite'; | |
| requestAnimationFrame(tick); | |
| } requestAnimationFrame(tick); | |
| }catch(_){} | |
| } | |
| window.CM_LOADER = { | |
| show(msg='Loading…', v=0){ | |
| if(loader.style.display!=='flex'){ | |
| loader.style.display='flex'; | |
| animateCurve(); | |
| } | |
| step.textContent=msg; | |
| bar.style.width = Math.max(0,Math.min(100,v)) + '%'; | |
| pct.textContent = Math.round(Math.max(v,0)) + '%'; | |
| }, | |
| step(msg, v){ | |
| step.textContent=msg; | |
| if(typeof v==='number'){ | |
| bar.style.width = Math.max(0,Math.min(100,v)) + '%'; | |
| pct.textContent = Math.round(Math.max(v,0)) + '%'; | |
| } | |
| }, | |
| hide(){ | |
| loader.style.display='none'; | |
| bar.style.width='0%'; | |
| pct.textContent='0%'; | |
| step.textContent=''; | |
| } | |
| }; | |
| })(); | |
| // --- toast control --- | |
| (function(){ | |
| const t = document.getElementById('cmToast'); | |
| const msg = document.getElementById('cmToastMsg'); | |
| window.CM_TOAST = { | |
| show(m='Loading file… please wait'){ | |
| if(t){ | |
| msg.textContent=m; | |
| t.style.display='block'; | |
| } | |
| }, | |
| hide(){ | |
| if(t){ | |
| t.style.display='none'; | |
| } | |
| } | |
| }; | |
| })(); | |
| // Upload & controls | |
| document.getElementById('fileLabel').addEventListener('keydown',(e)=>{ | |
| if(e.key==='Enter' || e.key===' '){ | |
| e.preventDefault(); | |
| const inp=$('file'); | |
| if(inp){ | |
| try{ | |
| inp.click(); | |
| }catch(_){} | |
| } | |
| } | |
| }); | |
| document.getElementById('fileLabel').addEventListener('pointerdown',()=>{ $('file').value=''; }); | |
| // Extra safety: explicit click to open the chooser across browsers | |
| document.getElementById('fileLabel').addEventListener('click',(e)=>{ | |
| e.preventDefault(); | |
| const inp=$('file'); | |
| if(inp){ | |
| try{ | |
| inp.click(); | |
| }catch(_){} | |
| } | |
| }); | |
| $('file').addEventListener('change',(e)=>{ | |
| const f=e.target.files && e.target.files[0]; | |
| if(!f) return; | |
| const spinner = document.getElementById('uploadSpinner'); | |
| if(spinner) { | |
| spinner.style.display = 'block'; | |
| document.querySelector('.cm-dot').style.display = 'none'; | |
| } | |
| CM_TOAST.show('Uploading file...'); | |
| $('filename').textContent=f.name; | |
| const lower=f.name.toLowerCase(); | |
| const afterLoad=()=>{ | |
| FILTERED=RAW.slice(); | |
| seedRange(); | |
| buildPicker(); | |
| buildMafeDropdown(); | |
| buildModuleDropdown(); | |
| applyFilters(); | |
| }; | |
| if(lower.endsWith('.csv')){ | |
| try{ CM_LOADER.step('Parsing CSV…', 18); }catch(_){} | |
| Papa.parse(f,{header:true,skipEmptyLines:true,dynamicTyping:true,complete:r=>{ | |
| try{ CM_LOADER.step('Mapping rows…', 55); }catch(_){} | |
| RAW=r.data.map(mapRow).filter(x=>x.ts&&!isNaN(x.total)); | |
| try{ CM_LOADER.step('Aggregating…', 72); }catch(_){} | |
| if(!RAW.length){alert('No valid rows'); CM_TOAST.hide(); return;} | |
| afterLoad(); | |
| try{ CM_LOADER.step('Rendering…', 90); }catch(_){} | |
| CM_TOAST.hide(); | |
| const spinner = document.getElementById('uploadSpinner'); | |
| if(spinner) { | |
| spinner.style.display = 'none'; | |
| document.querySelector('.cm-dot').style.display = 'block'; | |
| } | |
| }}); | |
| }else{ | |
| const fr=new FileReader(); | |
| fr.onload=ev=>{ | |
| try{ CM_LOADER.step('Reading workbook…', 28); }catch(_){} | |
| const wb=XLSX.read(new Uint8Array(ev.target.result),{type:'array'}); | |
| const sh=wb.SheetNames[0]; | |
| const js=XLSX.utils.sheet_to_json(wb.Sheets[sh],{defval:'',raw:true}); | |
| try{ CM_LOADER.step('Mapping rows…', 56); }catch(_){} | |
| RAW=js.map(mapRow).filter(x=>x.ts&&!isNaN(x.total)); | |
| if(!RAW.length){alert('No valid rows');return;} | |
| afterLoad(); | |
| try{ CM_LOADER.step('Rendering…', 90); }catch(_){} | |
| }; | |
| fr.readAsArrayBuffer(f); | |
| } | |
| }); | |
| document.addEventListener('click',e=>{ | |
| if(e.target.matches('.quick[data-last]')){ e.preventDefault(); setLast(+e.target.dataset.last); } | |
| if(e.target.id==='allData'){ e.preventDefault(); setAll(); } | |
| }); | |
| $('rangeMin').addEventListener('input',updateRangeUI); | |
| $('rangeMax').addEventListener('input',updateRangeUI); | |
| $('targetDate').addEventListener('change', calcForecast); | |
| $('exportCsv').onclick=()=>{ | |
| if(!DAILY.length) return; | |
| const csv="Date,Actual,Cumulative\n"+DAILY.map((d,i)=>`${d.d},${d.v},${CUM[i].v}`).join('\n'); | |
| saveAs(new Blob([csv],{type:'text/csv'}),"CostMaster_Filtered.csv"); | |
| }; | |
| $('resetAll').onclick=()=>{ | |
| RAW=[];FILTERED=[];DAILY=[];CUM=[];TOP=[];ALL_DATES=[];SELECTED.clear(); MAFE='ALL'; | |
| Object.values(charts).forEach(c=>c?.destroy()); charts={}; | |
| ['k_total','k_window','k_avg','k_forecast','k_forecast_formula','h_st','h_ot','h_dt','c_st','c_ot','c_dt','c_tot'].forEach(id=>$(id).textContent='—'); | |
| $('list').innerHTML=''; $('chips').innerHTML=''; $('filename').textContent='No file selected'; | |
| $('topList').innerHTML=''; $('rangeMinBadge').textContent='—'; $('rangeMaxBadge').textContent='—'; | |
| $('file').value=''; $('mafeFilter').value='ALL'; MAFE_EXACT='ALL'; | |
| }; | |
| $('mafeFilter').addEventListener('change', (e)=>{ const v=e.target.value; MAFE_EXACT=v; MAFE='ALL'; applyFilters(); }); | |
| function buildMafeDropdown(){ | |
| const sel = document.getElementById('mafeFilter'); | |
| if(!sel) return; | |
| const uniques = Array.from(new Set(RAW.map(r => (r.mafe_raw||'').trim()).filter(Boolean))).sort((a,b)=>a.localeCompare(b)); | |
| const cur = sel.value; | |
| sel.innerHTML = ''; | |
| const optAll = document.createElement('option'); optAll.value='ALL'; optAll.textContent='All'; sel.appendChild(optAll); | |
| uniques.forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; sel.appendChild(o); }); | |
| sel.value = uniques.includes(cur) ? cur : 'ALL'; | |
| } | |
| function buildModuleDropdown(){ | |
| const sel = document.getElementById('moduleFilter'); | |
| if(!sel) return; | |
| const uniques = Array.from(new Set(RAW.map(r => (r.module||'').trim()).filter(Boolean))).sort((a,b)=>a.localeCompare(b)); | |
| const cur = sel.value; | |
| sel.innerHTML = ''; | |
| const optAll = document.createElement('option'); optAll.value='ALL'; optAll.textContent='All'; sel.appendChild(optAll); | |
| uniques.forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; sel.appendChild(o); }); | |
| sel.value = uniques.includes(cur) ? cur : 'ALL'; | |
| sel.onchange = (e)=>{ MODULE = e.target.value || 'ALL'; applyFilters(); }; | |
| } | |
| // Set up interactive features | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Initialize any elements that need setup after the DOM is loaded | |
| // The file input handling is already set up in the script above | |
| }); | |
| </script> | |
| <script> | |
| // ===== Safe-Mode PDF Export (no globals, white pages, aspect-fit) ===== | |
| (function(){ | |
| function addImageFit(pdf, dataURL, x, y, maxW, maxH){ | |
| const img = new Image(); | |
| return new Promise(res=>{ | |
| img.onload = () => { | |
| const iw = img.width, ih = img.height; | |
| const r = Math.min(maxW/iw, maxH/ih); | |
| const w = Math.max(10, Math.floor(iw*r)); | |
| const h = Math.max(10, Math.floor(ih*r)); | |
| pdf.addImage(dataURL, 'PNG', x, y, w, h, undefined, 'FAST'); | |
| res(); | |
| }; | |
| img.src = dataURL; | |
| }); | |
| } | |
| async function snap(selector, scale=2){ | |
| const el = document.querySelector(selector); | |
| if(!el) return null; | |
| const canvas = await html2canvas(el, {backgroundColor:'#ffffff', scale}); | |
| return canvas.toDataURL('image/png', 0.98); | |
| } | |
| async function exportReportPDF(){ | |
| try{ | |
| if(!(window.jspdf && window.jspdf.jsPDF) || !window.html2canvas){ | |
| alert('PDF libraries not loaded. Check the two <script> tags in <head>.'); | |
| return; | |
| } | |
| const { jsPDF } = window.jspdf; | |
| const pdf = new jsPDF({orientation:'landscape', unit:'pt', format:'a4'}); | |
| const M = 36, W = pdf.internal.pageSize.getWidth(), H = pdf.internal.pageSize.getHeight(); | |
| const BOXW = W - 2*M, BOXH = H - 2*M; | |
| // Title Page | |
| pdf.setFillColor(255,255,255); pdf.rect(0,0,W,H,'F'); pdf.setTextColor(0,0,0); | |
| pdf.setFont('helvetica','bold'); pdf.setFontSize(20); pdf.text('Cost Master — Project Report', M, 46); | |
| pdf.setFont('helvetica','normal'); pdf.setFontSize(12); | |
| const now = new Date(); | |
| const dr = (document.getElementById('dateRange')?.textContent || document.getElementById('k_window')?.textContent || '').trim(); | |
| const mafeRows = FILTERED.filter(r => (r.mafe||'').toUpperCase().includes('MAFE')).length; | |
| const afeRows = FILTERED.filter(r => (r.mafe||'').toUpperCase().includes('AFE')).length; | |
| const tot = (document.querySelector('#ov_cost b')?.textContent || document.getElementById('k_total')?.textContent || '').trim(); | |
| const burn= (document.querySelector('#ov_burn b')?.textContent || document.getElementById('k_avg')?.textContent || '').trim(); | |
| const hST = (document.getElementById('h_st')?.textContent || '').trim(); | |
| const hOT = (document.getElementById('h_ot')?.textContent || '').trim(); | |
| const hDT = (document.getElementById('h_dt')?.textContent || '').trim(); | |
| const hTOT= (document.getElementById('h_tot')?.textContent || document.getElementById('h_total')?.textContent || '').trim(); | |
| let y = 76; | |
| pdf.text(`Generated: ${now.toLocaleString()}`, M, y); y+=20; | |
| if(dr){ pdf.text(`Date Range: ${dr}`, M, y); y+=26; } | |
| if(tot){ pdf.text(`Total Cost: ${tot}`, M, y); y+=18; } | |
| if(burn){ pdf.text(`Avg Daily Burn: ${burn}`, M, y); y+=18; } | |
| pdf.text(`Hours (DFL): ST ${hST} | OT ${hOT} | DT ${hDT} | Total ${hTOT}`, M, y); | |
| // Helper to add chart by selector | |
| async function addChart(title, selector){ | |
| const dataURL = await snap(selector); | |
| if(!dataURL) return; | |
| pdf.addPage('a4','landscape'); | |
| pdf.setFillColor(255,255,255); pdf.rect(0,0,W,H,'F'); pdf.setTextColor(0,0,0); | |
| pdf.setFont('helvetica','bold'); pdf.setFontSize(16); pdf.text(title, M, 40); | |
| await addImageFit(pdf, dataURL, M, 56, BOXW, BOXH-40); | |
| } | |
| await addChart('Daily Cost (bars) + Cumulative (line)', '#chartDaily'); | |
| await addChart('Cumulative Cost', '#chartCum'); | |
| await addChart('Cost by Module', '#chartModule'); | |
| await addChart('Top Categories', '#chartTop'); | |
| await addChart('Contractor Compare', '#chartCompare'); | |
| await addChart('MAFE vs AFE - Cost Comparison', '#afeMafeCost'); | |
| await addChart('MAFE vs AFE - Hours Comparison', '#afeMafeHours'); | |
| // Page numbers | |
| const pc = pdf.getNumberOfPages(); | |
| pdf.setFont('helvetica','normal'); pdf.setFontSize(9); | |
| for(let i=1;i<=pc;i++){ pdf.setPage(i); pdf.text(String(i)+' / '+String(pc), W - M, H - 10, {align:'right'}); } | |
| pdf.save('CostMaster_Report.pdf'); | |
| }catch(err){ console.error(err); alert('PDF export failed: '+(err?.message||err)); } | |
| } | |
| document.addEventListener('DOMContentLoaded', ()=>{ | |
| const btn = document.getElementById('exportPdf'); | |
| if(btn){ btn.onclick = exportReportPDF; } | |
| }); | |
| })(); | |
| </script> | |
| <script> | |
| (function(){ | |
| const $ = id => document.getElementById(id); | |
| const pick = (...sels)=>sels.map(s=>document.querySelector(s)).find(Boolean); | |
| const toNum = v => Number(String(v||'').replace(/[^\d.-]/g,'')) || 0; | |
| const usd = n => n.toLocaleString(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}); | |
| const parseNum = v => { if(v==null) return 0; const s=(''+v).replace(/[^\d.\-]/g,''); const n=Number(s); return Number.isFinite(n)?n:0; }; | |
| const manualRateEl = $('mc_manualRate'); | |
| const uncEl = $('mc_uncertainty'), uncLbl=$('mc_uncertaintyLabel'); | |
| const HEl = $('mc_horizon'), NEl = $('mc_iters'); | |
| const runBtn = $('mc_runBtn'), exportBtn = $('mc_exportBtn'); | |
| const kCur=$('mc_kpiCurrent'), k10=$('mc_kpiP10'), k50=$('mc_kpiP50'), k90=$('mc_kpiP90'); | |
| const winBtns = ['mc_win_30','mc_win_45','mc_win_60'].map(id=>$(id)); | |
| let activeWindow = 30; | |
| winBtns.forEach(b=>b&&b.addEventListener('click',()=>{ | |
| activeWindow = +b.dataset.window; | |
| winBtns.forEach(x=>x.style.opacity = (x===b)? '1' : '.7'); | |
| })); | |
| if(winBtns[0]) winBtns[0].style.opacity='1'; | |
| uncEl.addEventListener('input', ()=> uncLbl.textContent = (uncEl.value|0)+'%'); | |
| function boxMuller(){ | |
| let u=0,v=0; while(u===0) u=Math.random(); while(v===0) v=Math.random(); | |
| return Math.sqrt(-2.0*Math.log(u))*Math.cos(2.0*Math.PI*v); | |
| } | |
| function buildDateLabels(H){ | |
| const out=[]; const base=new Date(); | |
| for(let i=1;i<=H;i++){ const d=new Date(base); d.setDate(d.getDate()+i); out.push(d.toLocaleDateString(undefined,{month:'short',day:'2-digit'})); } | |
| return out; | |
| } | |
| function getCurrentTotal(){ return toNum(pick('#k_total','#total-cost')?.textContent); } | |
| function getAvgBurn(){ return toNum(pick('#k_avg','#avg-daily-burn')?.textContent); } | |
| const ctx = document.getElementById('mc_fanChart').getContext('2d'); | |
| let chart = new Chart(ctx, { | |
| type:'line', | |
| data:{datasets:[]}, | |
| options:{ | |
| responsive:true, maintainAspectRatio:false, animation:false, | |
| interaction:{mode:'index', intersect:false}, | |
| plugins:{legend:{display:true, labels:{color:'#cfe0ff'}}}, | |
| scales:{ | |
| x:{ticks:{color:'#cfe0ff'}, grid:{color:'rgba(255,255,255,.06)'}}, | |
| y:{ticks:{color:'#cfe0ff'}, grid:{color:'rgba(255,255,255,.06)'}} | |
| } | |
| } | |
| }); | |
| function exportCSV(labels, base, p10, p50, p90){ | |
| const header='Date,Baseline,P10,P50,P90\n'; | |
| const rows=labels.map((lab,i)=>`${lab},${Math.round(base)},${Math.round(p10[i]||0)},${Math.round(p50[i]||0)},${Math.round(p90[i]||0)}`).join('\n'); | |
| const blob=new Blob([header+rows],{type:'text/csv'}); const url=URL.createObjectURL(blob); | |
| const a=document.createElement('a'); a.href=url; a.download='mc_cost_projection.csv'; a.click(); URL.revokeObjectURL(url); | |
| } | |
| function runMC(){ | |
| const baseRate = parseNum(manualRateEl.value) || getAvgBurn(); | |
| const U = (+uncEl.value||0)/100; | |
| const H = Math.max(1, +HEl.value||60); | |
| const N = Math.max(100, +NEl.value||5000); | |
| const currentTotal = getCurrentTotal(); | |
| if(kCur) kCur.textContent = usd(currentTotal||0); | |
| if(!(baseRate>0)){ | |
| chart.data.datasets=[]; chart.update(); | |
| [k10,k50,k90].forEach(k=>k.textContent='$—'); | |
| return; | |
| } | |
| const series=[]; | |
| for(let i=0;i<N;i++){ | |
| let cum=currentTotal; const path=new Array(H); | |
| for(let d=0;d<H;d++){ | |
| const z=boxMuller(); | |
| const today=Math.max(0, baseRate + z*(U*baseRate)); | |
| cum+=today; path[d]=cum; | |
| } | |
| series.push(path); | |
| } | |
| const p10=[],p50=[],p90=[]; | |
| for(let d=0;d<H;d++){ | |
| const col=series.map(r=>r[d]).sort((a,b)=>a-b); | |
| p10.push(col[Math.floor(0.10*(N-1))]); | |
| p50.push(col[Math.floor(0.50*(N-1))]); | |
| p90.push(col[Math.floor(0.90*(N-1))]); | |
| } | |
| if(k10) k10.textContent=usd(p10[H-1]); | |
| if(k50) k50.textContent=usd(p50[H-1]); | |
| if(k90) k90.textContent=usd(p90[H-1]); | |
| const labels=buildDateLabels(H); | |
| chart.data.datasets=[ | |
| {label:'P90',data:p90,borderColor:'#f08ac3',backgroundColor:'rgba(240,138,195,.15)',fill:1,tension:.25}, | |
| {label:'P50',data:p50,borderColor:'#ffc455',backgroundColor:'rgba(255,196,85,.15)',fill:false,tension:.25}, | |
| {label:'P10',data:p10,borderColor:'#22e1e8',backgroundColor:'rgba(34,225,232,.15)',fill:-1,tension:.25} | |
| ]; | |
| chart.data.labels=labels; chart.update(); | |
| exportBtn.onclick=()=>exportCSV(labels,currentTotal,p10,p50,p90); | |
| } | |
| // initial paint | |
| const ct0 = getCurrentTotal(); | |
| if (kCur && ct0>0) kCur.textContent = usd(ct0); | |
| if (!manualRateEl.value) { | |
| const b0 = getAvgBurn(); if (b0>0) manualRateEl.value = Math.round(b0); | |
| } | |
| runBtn.addEventListener('click', runMC); | |
| // keep synced if overview changes | |
| let raf=0; const sync=()=>{ if(raf) cancelAnimationFrame(raf); raf=requestAnimationFrame(()=>{ | |
| const ct=getCurrentTotal(); if(kCur && ct>0) kCur.textContent=usd(ct); | |
| if(!manualRateEl.value){ const b=getAvgBurn(); if(b>0) manualRateEl.value=Math.round(b); } | |
| });}; | |
| ['#k_total','#total-cost','#k_avg','#avg-daily-burn'].forEach(sel=>{ | |
| const n=document.querySelector(sel); if(n) new MutationObserver(sync).observe(n,{subtree:true,childList:true,characterData:true}); | |
| }); | |
| })(); | |
| </script> | |
| </body></html> |