costmaster-pro / index.html
Kaliman-1981's picture
Manual changes saved
b0bca2b verified
Raw
History Blame Contribute Delete
67 kB
<!DOCTYPE html>
<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>