Hugdrigo's picture
Upload 3 files
65a81b6 verified
Raw History Blame Contribute Delete
48 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#171a34">
<meta name="description" content="TalentFlow: an offline, interactive workforce capacity and staffing scenario planning studio with transparent demonstration assumptions.">
<title>TalentFlow | Workforce Intelligence Studio</title>
<style>
:root{--bg:#f4f5fa;--paper:#fff;--soft:#f8f8fc;--text:#262944;--muted:#82869c;--border:#e7e9f1;--indigo:#6155d8;--purple:#8d62e8;--teal:#159d88;--red:#d55f76;--gold:#d39647;--shadow:0 12px 32px #252d5010;--sidebar:#181c34;color-scheme:light}
body.dark{--bg:#111526;--paper:#1a2036;--soft:#242a43;--text:#f1f2ff;--muted:#a3aac7;--border:#353d58;--indigo:#b3a8ff;--purple:#b99df8;--teal:#4bd2b5;--red:#ff94a6;--gold:#ffcf82;--shadow:0 14px 36px #0002;color-scheme:dark}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid var(--purple);outline-offset:2px}a{color:var(--indigo)}
.app{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--sidebar);color:#bdc0d8;position:sticky;top:0;height:100vh;padding:29px 17px;display:flex;flex-direction:column;z-index:2}.brand{display:flex;gap:12px;align-items:center;color:#fff;padding:0 8px 33px}.logo{height:42px;width:42px;background:linear-gradient(125deg,#ad81ff,#5c55e3 65%,#4db4de);display:grid;place-items:center;font-size:23px;font-weight:900;border-radius:14px;box-shadow:0 8px 26px #7556dc50}.brand b{font-size:21px;letter-spacing:-.9px}.brand small{display:block;font-size:9px;letter-spacing:2px;color:#9ba1c1;font-weight:800}.nav-caption{margin:9px 13px 10px;color:#7e86a7;letter-spacing:1.7px;font-size:10px;font-weight:800}.nav{display:grid;gap:5px}.nav button{border:1px solid transparent;background:transparent;color:#bcc3dc;display:flex;gap:12px;align-items:center;text-align:left;padding:12px 13px;border-radius:11px;font-size:12px;font-weight:700}.nav button span{font-size:17px;opacity:.8;width:21px}.nav button:hover,.nav button.active{background:#343654;color:white}.nav button.active{box-shadow:inset 3px 0 0 #b49aff}.sidebar-note{margin-top:auto;border:1px solid #3b405d;background:#242842;border-radius:14px;padding:15px;font-size:11px;line-height:1.65;color:#a8b1ce}.sidebar-note b{display:block;color:#f2ecff;margin:9px 0 2px;font-size:12px}.online{display:inline-block;width:8px;height:8px;background:#44d8ae;border-radius:50%;margin-right:5px}
.main{min-width:0;padding:29px clamp(19px,3vw,49px) 48px;max-width:1720px;width:100%}.topbar{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:30px}.crumb{font-weight:700;font-size:12px;color:var(--muted)}.crumb strong{color:var(--text)}.actions,.flex{display:flex;align-items:center;flex-wrap:wrap;gap:9px}.pill{border:1px solid #e4defa;background:#f1edff;color:#7564c7;padding:7px 11px;border-radius:30px;font-size:10px;font-weight:800;letter-spacing:.5px}body.dark .pill{background:#323354;border-color:#534875;color:#c1b2fa}.btn,.iconbtn{border:1px solid var(--border);border-radius:10px;padding:10px 14px;background:var(--paper);color:var(--text);font-size:11px;font-weight:800;white-space:nowrap}.btn:hover,.iconbtn:hover{border-color:var(--indigo)}.btn.primary{background:#6256d9;border-color:#6256d9;color:white}.btn.primary:hover{background:#4d43bb}.btn.ghost{background:transparent}.btn.danger{color:var(--red)}.iconbtn{padding:9px 12px;font-size:14px}.view{display:none}.view.active{display:block}.hero{display:flex;justify-content:space-between;align-items:center;gap:14px;margin:0 0 25px}.eyebrow{text-transform:uppercase;font-size:10px;color:var(--indigo);letter-spacing:2.1px;font-weight:900;margin-bottom:8px}.hero h1{font-size:clamp(26px,3.1vw,39px);line-height:1.18;letter-spacing:-1.55px;margin:0 0 9px}.hero p{max-width:700px;color:var(--muted);font-size:13px;margin:0}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:20px}.metric{background:var(--paper);border:1px solid var(--border);border-radius:15px;padding:17px 19px;box-shadow:var(--shadow);min-width:0}.metric-top{display:flex;align-items:center;justify-content:space-between;gap:5px;color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}.metric-icon{height:30px;width:30px;border-radius:9px;background:#eeeaff;color:#7466d9;display:grid;place-items:center;font-size:16px}body.dark .metric-icon{background:#383351;color:#c2b5ff}.metric-num{font-size:clamp(20px,2.3vw,31px);font-weight:850;letter-spacing:-1px;margin-top:11px;font-variant-numeric:tabular-nums}.metric small{font-size:10px;color:var(--muted)}.columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(290px,1fr);gap:18px}.stack{display:grid;align-content:start;gap:17px;min-width:0}.card{background:var(--paper);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);min-width:0;overflow:hidden}.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:21px 22px 0}.card-head h2{font-size:15px;letter-spacing:-.35px;margin:0}.card-head p{color:var(--muted);font-size:11px;margin:5px 0 0}.card-body{padding:18px 22px 22px}.tag{font-size:9px;letter-spacing:.4px;font-weight:900;color:var(--indigo);background:var(--soft);padding:6px 9px;border-radius:7px;white-space:nowrap}.note{font-size:11px;color:var(--muted);background:var(--soft);padding:13px 16px;border-radius:12px;border:1px solid var(--border);line-height:1.6}.notice{border:1px solid #e1d8ff;background:linear-gradient(115deg,#f2eeff,#fcfbff);border-radius:14px;padding:18px}.notice strong{font-size:12px;color:#5851ad}.notice p{font-size:11px;color:#75708d;margin:5px 0 0}body.dark .notice{background:linear-gradient(115deg,#2a2646,#22253c);border-color:#49446b}body.dark .notice strong{color:#c5b6ff}body.dark .notice p{color:#a9a1c8}.legend{display:flex;align-items:center;flex-wrap:wrap;gap:16px;color:var(--muted);font-size:10px}.swatch{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;background:var(--indigo)}.swatch.teal{background:var(--teal)}.swatch.red{background:var(--red)}.chart-wrap{width:100%;min-height:260px}.chart-wrap svg{width:100%;height:280px;display:block}.chart-wrap svg text{font-family:inherit;fill:var(--muted);font-size:11px}.mini-line{display:flex;align-items:center;justify-content:space-between;padding:11px 0;border-bottom:1px solid var(--border);gap:12px}.mini-line:last-child{border:0}.mini-line b{font-size:12px}.mini-line span{color:var(--muted);font-size:11px}.progress{height:7px;background:var(--soft);border-radius:30px;overflow:hidden;margin-top:7px}.progress i{display:block;height:100%;border-radius:30px;background:linear-gradient(90deg,#9b7dfa,#514ed7)}.progress i.good{background:linear-gradient(90deg,#42c5ab,#129983)}.progress i.warn{background:linear-gradient(90deg,#faad6a,#e47b73)}.teamline{padding:12px 0;border-bottom:1px solid var(--border)}.teamline:last-child{border:0}.teamrow{display:flex;align-items:center;justify-content:space-between;gap:10px}.teamrow strong{font-size:12px}.teamrow small{color:var(--muted)}.table-scroll{width:100%;overflow:auto}.table{border-collapse:collapse;min-width:760px;width:100%;font-size:11px}.table th{text-align:left;background:var(--soft);font-weight:800;color:var(--muted);padding:13px 14px;white-space:nowrap;letter-spacing:.3px}.table td{padding:13px 14px;border-top:1px solid var(--border);vertical-align:middle}.table tbody tr:hover{background:var(--soft)}.table td b{display:block;font-size:12px}.table td small{color:var(--muted)}.right{text-align:right!important}.chip{border-radius:20px;padding:4px 8px;font-size:10px;font-weight:800;background:#e7f7f3;color:#178977;white-space:nowrap}.chip.warn{background:#fff1e7;color:#bf7548}.chip.bad{background:#ffe8ee;color:#c75676}body.dark .chip{background:#21463f;color:#74dcc5}body.dark .chip.warn{background:#4c3c31;color:#f8c182}body.dark .chip.bad{background:#513443;color:#ffa3bc}.table-btn{border:1px solid var(--border);border-radius:7px;background:var(--paper);color:var(--indigo);font-size:10px;font-weight:800;padding:5px 8px}.table-btn.danger{color:var(--red)}.search{border:1px solid var(--border);border-radius:9px;padding:9px 11px;background:var(--paper);color:var(--text);font-size:11px;max-width:190px;width:100%}.search::placeholder{color:var(--muted)}.scenario-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.scenario{background:var(--soft);border:1px solid var(--border);border-radius:12px;padding:14px;min-height:81px;text-align:left;color:var(--text)}.scenario strong{display:block;font-size:12px}.scenario small{font-size:10px;color:var(--muted)}.scenario.active{background:#f0ecff;border:2px solid #8b74ef;padding:13px}body.dark .scenario.active{background:#343052}.field{display:grid;gap:5px;font-size:11px;color:var(--muted);font-weight:700}.field input,.field select{background:var(--paper);border:1px solid var(--border);border-radius:9px;padding:10px;color:var(--text);min-width:0;width:100%}.fields{display:grid;grid-template-columns:1fr 1fr;gap:13px}.control{padding:11px 0}.control>div:first-child{display:flex;justify-content:space-between;gap:12px;color:var(--text);font-size:11px;font-weight:700}.control output{font-weight:900;color:var(--indigo)}input[type=range]{width:100%;accent-color:#7462df;margin:13px 0 1px}.bar-row{display:grid;grid-template-columns:120px minmax(0,1fr) 55px;align-items:center;gap:9px;margin:12px 0;font-size:11px}.bar-row .bar-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bar-track{height:10px;background:var(--soft);border-radius:20px;overflow:hidden}.bar-fill{height:100%;background:linear-gradient(90deg,#ab85f8,#6456df);border-radius:20px}.statlist{display:grid;gap:9px}.statbox{padding:11px 13px;background:var(--soft);border:1px solid var(--border);border-radius:10px}.statbox span{color:var(--muted);font-size:10px}.statbox b{display:block;font-size:19px}.method{display:grid;gap:14px}.method h3{margin:2px 0;font-size:14px}.method p,.method li{font-size:12px;color:var(--muted);line-height:1.8}.method code{font-size:11px;color:var(--indigo);background:var(--soft);padding:3px 5px;border-radius:5px}.empty{text-align:center;padding:29px;color:var(--muted);font-size:12px}.footer{font-size:10px;color:var(--muted);margin-top:25px}.toast{position:fixed;bottom:22px;right:24px;z-index:10;padding:13px 17px;background:#2c3151;color:white;font-weight:800;border-radius:11px;box-shadow:0 12px 40px #0004;font-size:12px;max-width:calc(100vw - 25px)}.modal-cover{position:fixed;inset:0;background:#080c1caf;z-index:8;display:grid;place-items:center;padding:15px}.modal{max-height:94vh;overflow:auto;background:var(--paper);width:min(550px,100%);padding:24px;border-radius:17px;box-shadow:0 30px 80px #0004}.modal h2{margin:0 0 5px;font-size:20px}.modal p{margin:0 0 19px;color:var(--muted);font-size:12px}.modal .footerbtns{display:flex;gap:9px;justify-content:flex-end;margin-top:20px}.modal .fields{margin-top:12px}.screen-reader{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media(max-width:1190px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.columns{grid-template-columns:minmax(0,1.2fr) minmax(250px,1fr)}}
@media(max-width:960px){.app{grid-template-columns:minmax(0,1fr)}.sidebar{position:relative;height:auto;padding:14px 16px 10px}.brand{padding:0 4px 11px}.logo{width:34px;height:34px;border-radius:10px}.brand b{font-size:18px}.nav-caption,.sidebar-note{display:none}.nav{display:flex;overflow-x:auto;gap:4px;padding-bottom:3px}.nav button{white-space:nowrap;padding:9px 11px;flex-shrink:0}.main{padding:22px 19px 45px}.topbar{margin-bottom:24px}}
@media(max-width:700px){.columns{grid-template-columns:minmax(0,1fr)}.hero{align-items:flex-start}.hero>.flex{display:none}.topbar .pill{display:none}.metric-grid{gap:10px}.metric{padding:14px}.metric-num{font-size:24px}.fields,.scenario-grid{grid-template-columns:1fr}.card-head{padding:18px 16px 0}.card-body{padding:16px}.hero h1{font-size:29px}.bar-row{grid-template-columns:90px minmax(0,1fr) 48px}}
@media(max-width:390px){.main{padding:18px 12px 32px}.metric{padding:11px}.metric-num{font-size:21px}.actions{gap:5px}.btn,.iconbtn{padding:8px 9px}.crumb{font-size:11px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition:none!important}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar"><div class="brand"><div class="logo">✦</div><div><b>TalentFlow</b><small>WORKFORCE INTELLIGENCE</small></div></div><div class="nav-caption">WORKSPACE</div><nav class="nav" id="nav" aria-label="App sections"><button data-view="overview" class="active"><span>▦</span> Command center</button><button data-view="forecast"><span>⌁</span> Workforce forecast</button><button data-view="scenarios"><span>◉</span> Scenario studio</button><button data-view="teams"><span>▤</span> Team registry</button><button data-view="method"><span>◎</span> Methodology</button></nav><div class="sidebar-note"><span class="online"></span> OFFLINE DEMONSTRATION<b>Decisions start with visibility.</b>Scenario-based planning using fictional teams and editable assumptions. No data leaves your browser.</div></aside>
<main class="main"><div class="topbar"><div class="crumb">Portfolio / <strong id="crumb">Command center</strong></div><div class="actions"><span class="pill">✦ 100% LOCAL MODEL</span><button id="reset" class="iconbtn" title="Reset example data" aria-label="Reset example data">↻</button><button id="theme" class="iconbtn" title="Toggle dark mode" aria-label="Toggle dark mode">☾</button><button id="saveJSON" class="btn">↓ Backup JSON</button></div></div>
<section class="view active" id="view-overview"><header class="hero"><div><div class="eyebrow">Workforce strategy / Overview</div><h1>Make capacity your advantage.</h1><p>Connect staffing, skill readiness, workload and cost in one transparent planning workspace.</p></div><div class="flex"><button class="btn" data-goto="teams">Manage teams</button><button class="btn primary" data-goto="scenarios">Explore scenarios ↗</button></div></header><div class="metric-grid" id="metrics"></div><div class="columns"><div class="stack"><div class="card"><div class="card-head"><div><h2>12-month workforce outlook</h2><p>Modeled monthly demand and effective service capacity, hours</p></div><span class="tag">12-MONTH MODEL</span></div><div class="card-body"><div class="chart-wrap" id="trend"></div><div class="legend"><span><i class="swatch"></i> Effective capacity</span><span><i class="swatch red"></i> Workload demand</span></div></div></div><div class="card"><div class="card-head"><div><h2>Coverage by team</h2><p>First-month demand covered by effective workforce capacity</p></div><span class="tag" id="teamcount"></span></div><div class="card-body" id="teamCoverage"></div></div></div><div class="stack"><div class="card"><div class="card-head"><div><h2>Staffing signals</h2><p>Where the baseline plan needs attention</p></div></div><div class="card-body" id="signals"></div></div><div class="card"><div class="card-head"><div><h2>Scenario snapshot</h2><p>How outcomes change under four assumptions</p></div></div><div class="card-body" id="snapshot"></div></div><div class="notice"><strong>Transparent simulation, not an employment forecast</strong><p>Headcounts, costs, productivity and workload are fictional. Replace demo inputs before using the tool for any real planning decisions.</p></div></div></div></section>
<section class="view" id="view-forecast"><header class="hero"><div><div class="eyebrow">Planning & performance</div><h1>Model the next 12 months.</h1><p>Examine capacity, workload, staffing gaps and labor spend over time. Results update as assumptions change.</p></div><div class="flex"><button class="btn primary" id="exportForecast">↓ Export monthly CSV</button></div></header><div class="metric-grid" id="forecastMetrics"></div><div class="columns"><div class="stack"><div class="card"><div class="card-head"><div><h2>Capacity versus demand</h2><p>Expected supply and modeled workload by month</p></div><span class="tag">SIMULATED</span></div><div class="card-body"><div class="chart-wrap" id="forecastChart"></div><div class="legend"><span><i class="swatch"></i> Capacity</span><span><i class="swatch red"></i> Demand</span></div></div></div><div class="card"><div class="card-head"><div><h2>Monthly operating plan</h2><p>Expected staffing uses fractional full-time equivalents after attrition</p></div></div><div class="table-scroll" id="forecastTable"></div></div></div><div class="stack"><div class="card"><div class="card-head"><div><h2>Workforce coverage by month</h2><p>Coverage is capped at 100% for reporting</p></div></div><div class="card-body" id="monthBars"></div></div><div class="card"><div class="card-head"><div><h2>Planning assumptions</h2><p>Applies to all teams in the current scenario</p></div></div><div class="card-body" id="forecastAssumptions"></div></div></div></div></section>
<section class="view" id="view-scenarios"><header class="hero"><div><div class="eyebrow">Sensitivity testing</div><h1>Pressure-test every plan.</h1><p>Choose a fictional scenario and tune hiring, demand and retention to explore tradeoffs between cost and service capacity.</p></div><div class="flex"><button class="btn primary" id="exportScenarios">↓ Export comparison</button></div></header><div class="columns"><div class="stack"><div class="card"><div class="card-head"><div><h2>Scenario presets</h2><p>Each preset is editable without changing underlying team data</p></div></div><div class="card-body scenario-grid" id="scenarioCards"></div></div><div class="card"><div class="card-head"><div><h2>Scenario controls</h2><p>Customize shock parameters for the selected profile</p></div></div><div class="card-body" id="scenarioControls"></div></div><div class="card"><div class="card-head"><div><h2>Deterministic sensitivity band</h2><p>300 seeded perturbations of demand and attrition, illustrative only</p></div></div><div class="card-body" id="sensitivity"></div></div></div><div class="stack"><div class="card"><div class="card-head"><div><h2>Selected scenario</h2><p>Annual modeled coverage and labor cost</p></div></div><div class="card-body" id="scenarioSummary"></div></div><div class="card"><div class="card-head"><div><h2>Side-by-side comparison</h2><p>All four scenario profiles applied to the same teams</p></div></div><div class="card-body" id="scenarioComparison"></div></div><div class="note">The scenarios are thought experiments, not statistically calibrated forecasts. Numerical uncertainty bands illustrate assumed input variation, not probability intervals for real-world outcomes.</div></div></div></section>
<section class="view" id="view-teams"><header class="hero"><div><div class="eyebrow">Editable team portfolio</div><h1>Build your workforce model.</h1><p>Maintain team headcounts, workloads, skill readiness, annual attrition, quarterly hiring and labor costs.</p></div><div class="flex"><button class="btn" id="importJSON">↑ Import backup</button><button class="btn" id="exportTeams">↓ Export CSV</button><button class="btn primary" id="addTeam">+ Add team</button></div></header><div class="card"><div class="card-head"><div><h2>Team registry</h2><p>All team data is local and editable; archive teams without deleting their history</p></div><div class="flex"><span class="tag" id="registryCount"></span><input id="teamSearch" class="search" placeholder="Search teams..." aria-label="Search teams"></div></div><div class="table-scroll" id="teamTable"></div></div><div class="note" style="margin-top:17px">Demo records may be archived and restored. Back up the JSON to move your data between browsers. Importing replaces the current model only after confirmation.</div></section>
<section class="view" id="view-method"><header class="hero"><div><div class="eyebrow">Methods & transparency</div><h1>Know the assumptions.</h1><p>Every calculation is designed to be explainable, reproducible and suitable for classroom demonstrations.</p></div></header><div class="columns"><div class="stack"><div class="card"><div class="card-head"><div><h2>Workforce calculation model</h2><p>Transparent, deterministic monthly mechanics</p></div></div><div class="card-body method"><h3>1. Effective workforce capacity</h3><p><code>capacity = FTE × 140 hours × (0.65 + 0.35 × readiness/100) × (1 + productivity uplift)</code>. This assumes 140 productive base hours per FTE per month and uses skill readiness as a simplified productivity multiplier. This coefficient is hypothetical.</p><h3>2. Workforce progression</h3><p><code>FTE(next) = FTE(current) × (1 − annual attrition/12) + quarterly hires</code>. Hires arrive in months 3, 6, 9 and 12. FTE may be fractional because it is an expected-value projection, not a literal people count.</p><h3>3. Demand & gaps</h3><p><code>demand(month) = starting hours × (1 + monthly growth)^(month−1) × (1 + scenario demand shock)</code>. Unmet demand is <code>max(0, demand − capacity)</code>. Reported coverage is the sum of <code>min(demand,capacity)</code> divided by summed demand, not a prediction of customer service quality.</p><h3>4. Costs & staffing recommendations</h3><p>Modeled cost is <code>FTE × monthly cost per FTE</code>. Shortfall FTE assumes immediate availability at the current productivity level. It does not model hiring lead times, overhead, learning curves, leave, legal constraints or payroll taxes.</p></div></div><div class="card"><div class="card-head"><div><h2>What this tool does not do</h2></div><div class="card-body method"><p>No AI model, labor-market data, live HR integration, employee tracking, predictive hiring engine, employment advice or calibrated financial forecast. It cannot establish the causal impact of headcount on business performance. All sample teams are fictional and user-configured values remain unverified.</p><p>Do not upload personal employee records to a public Space. This browser-only demo is intended for aggregate, non-sensitive planning inputs.</p></div></div></div><div class="stack"><div class="card"><div class="card-head"><div><h2>Data & portability</h2><p>Simple, privacy-conscious demonstration architecture</p></div></div><div class="card-body method"><p>Everything executes in your browser. No server, third-party scripts, paid models, API keys or outbound requests are required.</p><p>Optional <code>localStorage</code> keeps your inputs on the current browser; clearing browser data can erase them. Export a versioned JSON backup before important changes.</p><p>JSON imports are limited to 300 KB, 1 to 40 teams, validated numerical ranges and unique IDs. Text values are escaped before HTML rendering. CSV fields are quoted and spreadsheet-formula-like prefixes are escaped.</p></div></div><div class="notice"><strong>Open, reproducible portfolio project</strong><p>Built as a self-contained Static Space: README.md, index.html, LICENSE. MIT licensed. No external dependencies.</p></div><button class="btn primary" id="methodBackup">↓ Export model JSON</button></div></div></section>
<p class="footer">TalentFlow · Project 95 · Hugdrigo portfolio · Synthetic demonstration · Client-side only · MIT</p>
</main></div>
<div id="dialogHost"></div><div id="toastHost" aria-live="polite"></div><input class="screen-reader" id="fileInput" type="file" accept="application/json,.json" aria-label="Import JSON backup">
<script>
'use strict';
(function(){
const STORAGE='talentflow_95_v1';const SCENARIOS={baseline:{name:'Steady state',subtitle:'Current operating plan',demand:0,attrition:0,hiring:0,productivity:0},growth:{name:'Growth acceleration',subtitle:'Demand outpaces hiring',demand:30,attrition:2,hiring:1,productivity:0},retention:{name:'Retention pressure',subtitle:'Higher employee turnover',demand:8,attrition:16,hiring:0,productivity:0},automation:{name:'Productivity boost',subtitle:'Workflow efficiency gains',demand:12,attrition:-2,hiring:0,productivity:22}};
const SAMPLE=[
{id:'t1',name:'Customer Success',category:'Customer operations',fte:22,demand:2650,growth:1.5,attrition:12,hires:2,cost:4200,readiness:87,archived:false},
{id:'t2',name:'Data & Analytics',category:'Technology',fte:11,demand:1530,growth:2.6,attrition:10,hires:1,cost:7100,readiness:92,archived:false},
{id:'t3',name:'Fulfillment',category:'Operations',fte:26,demand:3750,growth:0.9,attrition:18,hires:1,cost:3300,readiness:76,archived:false},
{id:'t4',name:'Product Design',category:'Technology',fte:9,demand:1220,growth:1.1,attrition:8,hires:1,cost:6700,readiness:89,archived:false},
{id:'t5',name:'Sales Enablement',category:'Commercial',fte:14,demand:1850,growth:1.8,attrition:14,hires:1,cost:5100,readiness:79,archived:false},
{id:'t6',name:'Finance & Controls',category:'Business services',fte:8,demand:920,growth:0.5,attrition:6,hires:0,cost:5800,readiness:96,archived:false},
{id:'t7',name:'Supply Planning',category:'Operations',fte:12,demand:1580,growth:1.4,attrition:11,hires:1,cost:4850,readiness:84,archived:false},
{id:'t8',name:'People Operations',category:'Business services',fte:7,demand:850,growth:1.0,attrition:9,hires:0,cost:4900,readiness:91,archived:false}];
const clone=x=>JSON.parse(JSON.stringify(x));
const defaultState=()=>({version:1,teams:clone(SAMPLE),scenario:'baseline',overrides:null,dark:false});
let state=defaultState();let current='overview';let search='';let toastTimer;let editId=null;
const $=id=>document.getElementById(id), esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n||0);const money=n=>'$'+fmt(n);const pct=n=>(Number.isFinite(n)?n:0).toFixed(1)+'%';
const clamp=(n,a,b)=>Math.min(b,Math.max(a,n));const active=()=>state.teams.filter(t=>!t.archived);
function validTeam(t){return t&&typeof t==='object'&&!Array.isArray(t)&&typeof t.id==='string'&&t.id.length>=1&&t.id.length<=64&&typeof t.name==='string'&&t.name.trim().length>0&&t.name.length<=64&&typeof t.category==='string'&&t.category.length<=48&&[['fte',0,1000],['demand',0,200000],['growth',-10,15],['attrition',0,60],['hires',0,100],['cost',0,100000],['readiness',0,100]].every(([k,a,b])=>typeof t[k]==='number'&&Number.isFinite(t[k])&&t[k]>=a&&t[k]<=b)&&typeof t.archived==='boolean'}
function validModel(x){return x&&typeof x==='object'&&!Array.isArray(x)&&x.version===1&&Array.isArray(x.teams)&&x.teams.length>=1&&x.teams.length<=40&&x.teams.every(validTeam)&&new Set(x.teams.map(t=>t.id)).size===x.teams.length&&Object.hasOwn(SCENARIOS,x.scenario)&&typeof x.dark==='boolean'&&(x.overrides===null||validOverrides(x.overrides));}
function validOverrides(x){return x&&typeof x==='object'&&!Array.isArray(x)&&[['demand',-30,70],['attrition',-20,30],['hiring',-2,5],['productivity',-10,50]].every(([k,a,b])=>typeof x[k]==='number'&&Number.isFinite(x[k])&&x[k]>=a&&x[k]<=b)}
function cleanModel(x){return {version:1,teams:x.teams.map(t=>({id:t.id,name:t.name,category:t.category,fte:t.fte,demand:t.demand,growth:t.growth,attrition:t.attrition,hires:t.hires,cost:t.cost,readiness:t.readiness,archived:t.archived})),scenario:x.scenario,overrides:x.overrides?{demand:x.overrides.demand,attrition:x.overrides.attrition,hiring:x.overrides.hiring,productivity:x.overrides.productivity}:null,dark:x.dark};}
try{const raw=localStorage.getItem(STORAGE);if(raw){const parsed=JSON.parse(raw);if(validModel(parsed))state=cleanModel(parsed);}}catch(e){}
const conf=()=>state.overrides||SCENARIOS[state.scenario];
function sim(teams,settings){const periods=[];const roster=teams.map(t=>({...t,work:t.fte}));for(let m=1;m<=12;m++){let fte=0,demand=0,capacity=0,served=0,cost=0;const detail=[];for(const t of roster){let rate=clamp((t.attrition+settings.attrition)/100,0,.85);if(m>1){t.work=t.work*(1-rate/12);if([3,6,9,12].includes(m))t.work+=Math.max(0,t.hires+settings.hiring);}const skill=(.65+.35*t.readiness/100);const cap=t.work*140*skill*clamp(1+settings.productivity/100,.1,2);const dem=Math.max(0,t.demand*Math.pow(1+t.growth/100,m-1)*(1+settings.demand/100));const fill=Math.min(cap,dem);const spent=t.work*t.cost;const row={id:t.id,name:t.name,fte:t.work,cap,demand:dem,served:fill,gap:Math.max(0,dem-cap),cost:spent,coverage:dem?fill/dem:1,requiredFte:Math.max(0,dem-cap)/(140*skill*clamp(1+settings.productivity/100,.1,2))};detail.push(row);fte+=t.work;demand+=dem;capacity+=cap;served+=fill;cost+=spent;}periods.push({month:m,fte,demand,capacity,served,cost,gap:Math.max(0,demand-capacity),coverage:demand?served/demand:1,detail});}
const total=periods.reduce((a,r)=>({demand:a.demand+r.demand,served:a.served+r.served,cost:a.cost+r.cost,shortfall:a.shortfall+r.detail.reduce((s,d)=>s+d.gap,0)}),{demand:0,served:0,cost:0,shortfall:0});return {periods,total,annualCoverage:total.demand?total.served/total.demand:1,first:periods[0],last:periods[11]};}
function seriesSvg(records){const W=760,H=280,ml=52,mr=16,mt=20,mb=43;const h=H-mt-mb,w=W-ml-mr;const max=Math.max(1,...records.map(d=>Math.max(d.capacity,d.demand)))*1.12;const X=i=>ml+i*w/11;const Y=v=>mt+h-v/max*h;const path=key=>records.map((r,i)=>(i?'L':'M')+X(i).toFixed(2)+' '+Y(r[key]).toFixed(2)).join(' ');const grid=Array.from({length:5},(_,i)=>{let y=mt+h/4*i,v=max*(1-i/4);return `<line x1="${ml}" y1="${y}" x2="${W-mr}" y2="${y}" stroke="var(--border)" stroke-dasharray="4,5"/><text x="${ml-8}" y="${y+4}" text-anchor="end">${esc(fmt(v))}</text>`}).join('');const labels=records.map((r,i)=>`<text x="${X(i)}" y="${H-17}" text-anchor="middle">M${i+1}</text>`).join('');return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" role="img" aria-label="Monthly capacity and demand line chart"><defs><linearGradient id="shadeCap" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#8b80e9" stop-opacity=".22"/><stop offset="1" stop-color="#8b80e9" stop-opacity="0"/></linearGradient></defs>${grid}<path d="${path('capacity')} L${X(11)} ${mt+h} L${X(0)} ${mt+h} Z" fill="url(#shadeCap)"/><path d="${path('capacity')}" fill="none" stroke="#8270df" stroke-width="3.5" stroke-linejoin="round"/><path d="${path('demand')}" fill="none" stroke="#ea8498" stroke-width="3.5" stroke-linejoin="round" stroke-dasharray="7,4"/>${records.map((r,i)=>`<circle cx="${X(i)}" cy="${Y(r.capacity)}" r="3.2" fill="#8270df"/><circle cx="${X(i)}" cy="${Y(r.demand)}" r="3.2" fill="#ea8498"/>`).join('')}${labels}</svg>`;}
function metricsHTML(a){const first=a.first,last=a.last;const fteGap=first.detail.reduce((s,d)=>s+d.requiredFte,0);return `<div class="metric"><div class="metric-top">CURRENT WORKFORCE <span class="metric-icon">♟</span></div><div class="metric-num">${fmt(first.fte)} <span style="font-size:12px;color:var(--muted)">FTE</span></div><small>Active people-equivalent roles</small></div><div class="metric"><div class="metric-top">FIRST-MONTH COVERAGE <span class="metric-icon">◉</span></div><div class="metric-num">${pct(first.coverage*100)}</div><small>Of current workload demand</small></div><div class="metric"><div class="metric-top">CAPACITY SHORTFALL <span class="metric-icon">↗</span></div><div class="metric-num">${fmt(fteGap)} <span style="font-size:12px;color:var(--muted)">FTE</span></div><small>Immediate equivalent gap by team</small></div><div class="metric"><div class="metric-top">ANNUAL LABOR COST <span class="metric-icon">$</span></div><div class="metric-num">${money(a.total.cost)}</div><small>12-month modeled total</small></div>`;}
function coverageHTML(row){return `<div class="teamline"><div class="teamrow"><strong>${esc(row.name)}</strong><small>${pct(row.coverage*100)} · ${fmt(row.fte)} FTE</small></div><div class="progress"><i class="${row.coverage>=.95?'good':row.coverage<.8?'warn':''}" style="width:${clamp(row.coverage*100,0,100)}%"></i></div></div>`}
function showOverview(){const a=sim(active(),conf());$('metrics').innerHTML=metricsHTML(a);$('trend').innerHTML=seriesSvg(a.periods);$('teamcount').textContent=active().length+' ACTIVE';$('teamCoverage').innerHTML=a.first.detail.length?a.first.detail.map(coverageHTML).join(''):'<div class="empty">No active teams. Restore or add one.</div>';const gaps=[...a.first.detail].sort((x,y)=>y.requiredFte-x.requiredFte).slice(0,4);$('signals').innerHTML=gaps.length?gaps.map(g=>`<div class="mini-line"><div><b>${esc(g.name)}</b><br><span>${pct(g.coverage*100)} first-month coverage</span></div><strong style="color:var(--red);font-size:13px">${g.requiredFte>0?'+'+g.requiredFte.toFixed(1)+' FTE':'On target'}</strong></div>`).join(''):'<div class="empty">No active teams.</div>';$('snapshot').innerHTML=Object.keys(SCENARIOS).map(k=>{let r=sim(active(),SCENARIOS[k]);return `<div class="mini-line"><div><b>${esc(SCENARIOS[k].name)}</b><br><span>Annual covered demand</span></div><strong>${pct(r.annualCoverage*100)}</strong></div>`}).join('');}
function showForecast(){const a=sim(active(),conf());$('forecastMetrics').innerHTML=metricsHTML(a);$('forecastChart').innerHTML=seriesSvg(a.periods);$('monthBars').innerHTML=a.periods.map(r=>`<div class="bar-row"><span class="bar-label">Month ${r.month}</span><div class="bar-track"><div class="bar-fill" style="width:${clamp(r.coverage*100,0,100)}%"></div></div><b>${pct(r.coverage*100)}</b></div>`).join('');$('forecastTable').innerHTML=`<table class="table"><thead><tr><th>Month</th><th class="right">FTE</th><th class="right">Demand hours</th><th class="right">Capacity hours</th><th class="right">Unmet hours*</th><th class="right">Covered</th><th class="right">Labor cost</th></tr></thead><tbody>${a.periods.map(r=>`<tr><td><b>M${r.month}</b></td><td class="right">${r.fte.toFixed(1)}</td><td class="right">${fmt(r.demand)}</td><td class="right">${fmt(r.capacity)}</td><td class="right">${fmt(r.detail.reduce((s,t)=>s+t.gap,0))}</td><td class="right">${pct(r.coverage*100)}</td><td class="right">${money(r.cost)}</td></tr>`).join('')}</tbody></table><p style="padding:0 15px;color:var(--muted);font-size:10px">* Sum of team-level unmet hours, not net total capacity deficit across teams.</p>`;$('forecastAssumptions').innerHTML=`<div class="mini-line"><span>Base productive hours/FTE/month</span><b>140</b></div><div class="mini-line"><span>Active teams</span><b>${active().length}</b></div><div class="mini-line"><span>Demand adjustment</span><b>${conf().demand}%</b></div><div class="mini-line"><span>Attrition adjustment (annual points)</span><b>${conf().attrition}</b></div><div class="mini-line"><span>Hiring adjustment/quarter/team</span><b>${conf().hiring}</b></div><div class="mini-line"><span>Capacity uplift</span><b>${conf().productivity}%</b></div><div class="mini-line"><span>12-month modeled gap</span><b>${fmt(a.total.shortfall)} h</b></div>`;}
const ctrl=[['demand','Workload demand adjustment','%','-30','70','1'],['attrition','Annual attrition adjustment','pp','-20','30','1'],['hiring','Quarterly hires per team change','FTE','-2','5','1'],['productivity','Effective productivity uplift','%','-10','50','1']];
function showScenarios(){const settings=conf(),res=sim(active(),settings);$('scenarioCards').innerHTML=Object.entries(SCENARIOS).map(([id,s])=>`<button class="scenario ${state.scenario===id?'active':''}" data-scenario="${id}" aria-pressed="${state.scenario===id}"><strong>${esc(s.name)}</strong><small>${esc(s.subtitle)}</small></button>`).join('');$('scenarioControls').innerHTML=ctrl.map(([id,label,unit,minimum,maximum,step])=>`<div class="control"><div><label for="slider-${id}">${label}</label><output id="value-${id}" for="slider-${id}">${settings[id]} ${unit}</output></div><input type="range" id="slider-${id}" data-slider="${id}" min="${minimum}" max="${maximum}" step="${step}" value="${settings[id]}"></div>`).join('')+`<button class="btn ghost" id="resetOverrides">↻ Restore preset assumptions</button>`;$('scenarioSummary').innerHTML=`<div class="statlist"><div class="statbox"><span>ANNUAL COVERAGE</span><b>${pct(res.annualCoverage*100)}</b></div><div class="statbox"><span>ANNUAL WORKFORCE COST</span><b>${money(res.total.cost)}</b></div><div class="statbox"><span>UNSERVED DEMAND (HOURS)</span><b>${fmt(res.total.shortfall)}</b></div><div class="statbox"><span>FINAL EXPECTED FTE</span><b>${res.last.fte.toFixed(1)}</b></div></div>`;$('scenarioComparison').innerHTML=Object.entries(SCENARIOS).map(([id,s])=>{const r=sim(active(),s);return `<div class="teamline"><div class="teamrow"><strong>${esc(s.name)}</strong><small>${pct(r.annualCoverage*100)}</small></div><div class="progress"><i class="${r.annualCoverage>.93?'good':''}" style="width:${clamp(r.annualCoverage*100,0,100)}%"></i></div><small style="color:var(--muted)">${money(r.total.cost)} annual cost</small></div>`}).join('');let draws=sensitivity(settings);$('sensitivity').innerHTML=`<div class="note" style="margin-bottom:12px">Coverage across 300 reproducible hypothetical parameter perturbations (10th / median / 90th percentiles).</div><div class="statlist"><div class="statbox"><span>P10 ANNUAL COVERAGE</span><b>${pct(draws[0]*100)}</b></div><div class="statbox"><span>MEDIAN ANNUAL COVERAGE</span><b>${pct(draws[1]*100)}</b></div><div class="statbox"><span>P90 ANNUAL COVERAGE</span><b>${pct(draws[2]*100)}</b></div></div>`;}
function sensitivity(settings){let z=987654321;const rand=()=>{z=(Math.imul(1664525,z)+1013904223)>>>0;return z/4294967296};const arr=[];for(let i=0;i<300;i++){const s={...settings,demand:clamp(settings.demand+(rand()-.5)*20,-30,70),attrition:clamp(settings.attrition+(rand()-.5)*8,-20,30)};arr.push(sim(active(),s).annualCoverage)}arr.sort((a,b)=>a-b);return [arr[Math.floor(.1*(arr.length-1))],arr[Math.floor(.5*(arr.length-1))],arr[Math.floor(.9*(arr.length-1))]];}
function showTeams(){const list=state.teams.filter(t=>(t.name+' '+t.category).toLowerCase().includes(search.toLowerCase()));$('registryCount').textContent=active().length+' ACTIVE · '+state.teams.length+' TOTAL';$('teamTable').innerHTML=`<table class="table"><thead><tr><th>Team</th><th class="right">FTE</th><th class="right">Hours/mo</th><th class="right">Readiness</th><th class="right">Attrition/y</th><th class="right">Hires/q</th><th class="right">Cost/FTE</th><th>Status</th><th>Actions</th></tr></thead><tbody>${list.map(t=>`<tr><td><b>${esc(t.name)}</b><small>${esc(t.category)}</small></td><td class="right">${fmt(t.fte)}</td><td class="right">${fmt(t.demand)}</td><td class="right">${fmt(t.readiness)}%</td><td class="right">${fmt(t.attrition)}%</td><td class="right">${fmt(t.hires)}</td><td class="right">${money(t.cost)}</td><td><span class="chip ${t.archived?'warn':''}">${t.archived?'Archived':'Active'}</span></td><td><button class="table-btn" data-edit="${esc(t.id)}">Edit</button> <button class="table-btn ${t.archived?'':'danger'}" data-archive="${esc(t.id)}">${t.archived?'Restore':'Archive'}</button></td></tr>`).join('')}</tbody></table>${list.length?'':'<div class="empty">No teams match your search.</div>'}`;}
function paint(){document.body.classList.toggle('dark',state.dark);document.querySelectorAll('.view').forEach(n=>n.classList.toggle('active',n.id==='view-'+current));document.querySelectorAll('[data-view]').forEach(b=>{b.classList.toggle('active',b.dataset.view===current);b.setAttribute('aria-current',b.dataset.view===current?'page':'false')});const names={overview:'Command center',forecast:'Workforce forecast',scenarios:'Scenario studio',teams:'Team registry',method:'Methodology'};$('crumb').textContent=names[current]||names.overview;if(current==='overview')showOverview();if(current==='forecast')showForecast();if(current==='scenarios')showScenarios();if(current==='teams')showTeams();}
function save(){try{localStorage.setItem(STORAGE,JSON.stringify(state));}catch(e){notice('Browser storage unavailable. Export JSON to preserve changes.');}}function update(){save();paint();}
function notice(m){const host=$('toastHost');host.innerHTML='<div class="toast" role="status">'+esc(m)+'</div>';clearTimeout(toastTimer);toastTimer=setTimeout(()=>host.innerHTML='',3300)}
function goto(id){if(!['overview','forecast','scenarios','teams','method'].includes(id))return;current=id;paint();window.scrollTo({top:0,behavior:'instant'});}
function csvSafe(x){let v=String(x??'');if(/^[\s\t\r\n]*[=+@\-]/.test(v))v="'"+v;return '"'+v.replace(/"/g,'""')+'"';}function makeCSV(rows){return rows.map(r=>r.map(csvSafe).join(',')).join('\r\n')+'\r\n';}function file(name,content,type){const blob=new Blob([content],{type});const u=URL.createObjectURL(blob);const a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),15000);}function backup(){file('talentflow-workforce-backup.json',JSON.stringify(state,null,2),'application/json');notice('JSON backup prepared.');}
function exportTeams(){const header=['Name','Category','FTE','Monthly demand hours','Demand growth % monthly','Annual attrition %','Hires per quarter','Monthly cost per FTE USD','Skill readiness %','Archived'];file('talentflow-teams.csv',makeCSV([header,...state.teams.map(t=>[t.name,t.category,t.fte,t.demand,t.growth,t.attrition,t.hires,t.cost,t.readiness,t.archived])]),'text/csv');notice('Team registry CSV prepared.');}
function exportForecast(){const a=sim(active(),conf());file('talentflow-monthly-forecast.csv',makeCSV([['Month','Expected FTE','Demand hours','Capacity hours','Unserved hours by team','Coverage percent','Monthly labor USD'],...a.periods.map(r=>[r.month,r.fte.toFixed(3),r.demand.toFixed(2),r.capacity.toFixed(2),r.detail.reduce((s,d)=>s+d.gap,0).toFixed(2),(r.coverage*100).toFixed(2),r.cost.toFixed(2)])]),'text/csv');notice('Monthly forecast CSV prepared.');}
function exportScenarios(){file('talentflow-scenario-comparison.csv',makeCSV([['Scenario','Annual coverage %','Annual labor cost USD','Unserved hours','Month 12 FTE'],...Object.entries(SCENARIOS).map(([key,s])=>{const r=sim(active(),s);return [s.name,(r.annualCoverage*100).toFixed(2),r.total.cost.toFixed(2),r.total.shortfall.toFixed(1),r.last.fte.toFixed(2)]})]),'text/csv');notice('Scenario comparison CSV prepared.');}
function field(name,label,value,type='number',min='0',max='100000',step='1'){return `<label class="field">${esc(label)}<input name="${esc(name)}" type="${esc(type)}" ${type==='number'?`min="${min}" max="${max}" step="${step}"`:''} value="${esc(value)}" required></label>`;}
function openEditor(id){let original=id?state.teams.find(t=>t.id===id):null;if(id&&!original)return;editId=id||null;const t=original||{name:'',category:'Operations',fte:5,demand:600,growth:1,attrition:10,hires:1,cost:5000,readiness:85};$('dialogHost').innerHTML=`<div class="modal-cover" id="modalCover"><form class="modal" id="teamForm"><h2>${original?'Edit workforce team':'Add workforce team'}</h2><p>All entries are synthetic planning assumptions, not personnel records.</p>${field('name','Team name',t.name,'text')}<div class="fields">${field('category','Category',t.category,'text')}${field('fte','Current FTE',t.fte,'number','0','1000','1')}${field('demand','Demand hours per month',t.demand,'number','0','200000','1')}${field('growth','Monthly demand growth %',t.growth,'number','-10','15','0.1')}${field('attrition','Annual attrition %',t.attrition,'number','0','60','0.1')}${field('hires','Hires per quarter',t.hires,'number','0','100','1')}${field('cost','Monthly cost per FTE (USD)',t.cost,'number','0','100000','1')}${field('readiness','Skill readiness %',t.readiness,'number','0','100','1')}</div><div class="footerbtns"><button type="button" class="btn" id="cancelModal">Cancel</button><button type="submit" class="btn primary">${original?'Save changes':'Create team'}</button></div></form></div>`;$('teamForm').querySelector('[name=name]').focus();}
function closeEditor(){$('dialogHost').innerHTML='';editId=null;}
function onSaveTeam(form){const f=new FormData(form);const orig=editId?state.teams.find(t=>t.id===editId):null;const t={id:orig?orig.id:'t'+Date.now().toString(36)+Math.random().toString(36).slice(2,7),name:String(f.get('name')||'').trim(),category:String(f.get('category')||'').trim(),fte:Number(f.get('fte')),demand:Number(f.get('demand')),growth:Number(f.get('growth')),attrition:Number(f.get('attrition')),hires:Number(f.get('hires')),cost:Number(f.get('cost')),readiness:Number(f.get('readiness')),archived:orig?orig.archived:false};if(!validTeam(t)){notice('Check the name and numeric field ranges.');return;}if(state.teams.length>=40&&!orig){notice('The demo supports up to 40 teams.');return;}if(orig)state.teams=state.teams.map(x=>x.id===orig.id?t:x);else state.teams.push(t);closeEditor();update();notice(orig?'Team updated.':'Team created.');}
async function handleImport(input){const f=input.files&&input.files[0];input.value='';if(!f)return;if(f.size>300*1024){notice('Backup is too large (300 KB maximum).');return;}try{const text=await f.text();const data=JSON.parse(text);if(!validModel(data)){notice('Rejected: invalid or incompatible JSON backup.');return;}if(!window.confirm('Replace the current workforce model with this valid backup?'))return;state=cleanModel(data);search='';$('teamSearch').value='';update();notice('Workforce model restored from JSON.');}catch(e){notice('Could not read or validate this JSON file.');}}
function bind(){document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.view){goto(b.dataset.view);return;}if(b.dataset.goto){goto(b.dataset.goto);return;}if(b.dataset.scenario){state.scenario=b.dataset.scenario;state.overrides=null;update();return;}if(b.dataset.edit){openEditor(b.dataset.edit);return;}if(b.dataset.archive){const t=state.teams.find(x=>x.id===b.dataset.archive);if(t){t.archived=!t.archived;update();notice(t.archived?'Team archived.':'Team restored.')}return;}if(b.id==='reset'){if(window.confirm('Restore the original demonstration data? This replaces any unsaved changes.')){const keepDark=state.dark;state=defaultState();state.dark=keepDark;search='';$('teamSearch').value='';update();notice('Demonstration restored.')}return;}if(b.id==='theme'){state.dark=!state.dark;update();return;}if(b.id==='saveJSON'||b.id==='methodBackup'){backup();return;}if(b.id==='exportForecast'){exportForecast();return;}if(b.id==='exportTeams'){exportTeams();return;}if(b.id==='exportScenarios'){exportScenarios();return;}if(b.id==='addTeam'){openEditor(null);return;}if(b.id==='importJSON'){$('fileInput').click();return;}if(b.id==='cancelModal'){closeEditor();return;}if(b.id==='resetOverrides'){state.overrides=null;update();notice('Scenario preset restored.');return;}});document.addEventListener('input',e=>{if(e.target.id==='teamSearch'){search=e.target.value;showTeams();return;}if(e.target.dataset.slider){const id=e.target.dataset.slider;const params={...conf(),[id]:Number(e.target.value)};state.overrides=params;const output=$('value-'+id);if(output)output.textContent=params[id]+' '+({demand:'%',attrition:'pp',hiring:'FTE',productivity:'%'}[id]);}});document.addEventListener('change',e=>{if(e.target.dataset.slider){update();}if(e.target.id==='fileInput'){handleImport(e.target);}});document.addEventListener('submit',e=>{if(e.target.id==='teamForm'){e.preventDefault();onSaveTeam(e.target);}});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('dialogHost').innerHTML)closeEditor();});document.addEventListener('click',e=>{if(e.target.id==='modalCover')closeEditor();});}
bind();paint();
window.__talentflow_test={sim,validModel,validTeam,SCENARIOS,SAMPLE,sensitivity:()=>sensitivity(conf()),getState:()=>clone(state)};
})();
</script>
</body>
</html>