feat: what-if simulator (no data changes)

Adds a "Симулятор" block that recomputes KPIs against a virtual scenario
without modifying the source data:

- optimization slider: reduce every function's time by 0–80%
- exclude top-N longest functions slider
- exclude an entire auto-stage (dropdown)
- recruiters (FTE) slider: 1–10
- vacancies-per-month slider: 0–50

Output is a side-by-side table (Было / Стало / Δ%) for cycle time,
days, weeks, monthly load, FTE %, capacity, overload, recruiters needed,
and cost. Color-codes improvements green, regressions red.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Askarova A 2026-05-22 14:02:29 +05:00
parent 3006833705
commit 218c93df25
3 changed files with 173 additions and 4 deletions

125
app.js
View File

@ -38,7 +38,15 @@ const state = {
search: '', search: '',
vacancies: 15, vacancies: 15,
salary: 500000, salary: 500000,
sort: { col: 'num', dir: 1 }, // 1=asc, -1=desc sort: { col: 'num', dir: 1 },
sim: {
enabled: false,
optimizePct: 0, // % сокращения времени всех функций
excludeTopN: 0, // исключить N самых долгих функций
excludeStage: '', // исключить целый этап (auto-stage)
recruiters: 1, // кол-во рекрутеров (FTE)
vacancies: 15, // план вакансий в симуляции
},
}; };
// === Init === // === Init ===
@ -49,12 +57,41 @@ addEventListener('DOMContentLoaded', () => {
$('group-filter').onchange = e => { state.groupFilter = [...e.target.selectedOptions].map(o => o.value); render(); }; $('group-filter').onchange = e => { state.groupFilter = [...e.target.selectedOptions].map(o => o.value); render(); };
$('search-input').oninput = e => { state.search = e.target.value.toLowerCase().trim(); render(); }; $('search-input').oninput = e => { state.search = e.target.value.toLowerCase().trim(); render(); };
$('theme-toggle').onclick = toggleTheme; $('theme-toggle').onclick = toggleTheme;
$('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; render(); }; $('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; state.sim.vacancies = state.vacancies; renderSimControls(); render(); };
$('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); }; $('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); };
initTableSort(); initTableSort();
initSimulator();
autoLoad(); autoLoad();
}); });
function initSimulator() {
$('sim-toggle').onchange = e => { state.sim.enabled = e.target.checked; $('sim-controls').hidden = !e.target.checked; render(); };
$('sim-reset').onclick = () => {
state.sim = { enabled: true, optimizePct: 0, excludeTopN: 0, excludeStage: '', recruiters: 1, vacancies: state.vacancies };
renderSimControls();
render();
};
const bind = (id, key, parse = v => +v) => {
$(id).oninput = e => { state.sim[key] = parse(e.target.value); renderSimControls(); render(); };
};
bind('sim-optimize', 'optimizePct');
bind('sim-exclude-top', 'excludeTopN');
bind('sim-recruiters', 'recruiters');
bind('sim-vacancies', 'vacancies');
$('sim-stage').onchange = e => { state.sim.excludeStage = e.target.value; render(); };
}
function renderSimControls() {
$('sim-optimize-val').textContent = `${state.sim.optimizePct}%`;
$('sim-exclude-top-val').textContent = state.sim.excludeTopN;
$('sim-recruiters-val').textContent = state.sim.recruiters;
$('sim-vacancies-val').textContent = state.sim.vacancies;
$('sim-optimize').value = state.sim.optimizePct;
$('sim-exclude-top').value = state.sim.excludeTopN;
$('sim-recruiters').value = state.sim.recruiters;
$('sim-vacancies').value = state.sim.vacancies;
}
function initTableSort() { function initTableSort() {
document.querySelectorAll('#table thead th').forEach(th => { document.querySelectorAll('#table thead th').forEach(th => {
th.style.cursor = 'pointer'; th.style.cursor = 'pointer';
@ -266,6 +303,88 @@ function render() {
chartStages(m.byStage); chartStages(m.byStage);
chartFteScenario(m.daysPerCycle); chartFteScenario(m.daysPerCycle);
renderTable(items, m.bottleneckIds); renderTable(items, m.bottleneckIds);
// Симулятор
populateStageOptions(m.byStage);
renderSim(items, m);
}
function populateStageOptions(byStage) {
const sel = $('sim-stage');
const current = sel.value;
const stages = Object.keys(byStage);
sel.innerHTML = `<option value="">— (не исключать) —</option>` + stages.map(s => `<option value="${esc(s)}">${esc(s)}</option>`).join('');
if (stages.includes(current)) sel.value = current;
}
function simulateItems(items) {
const { optimizePct, excludeTopN, excludeStage } = state.sim;
let arr = items.filter(i => i.minutes != null);
if (excludeStage) arr = arr.filter(i => i.stage !== excludeStage);
if (excludeTopN > 0) {
const sorted = [...arr].sort((a, b) => b.minutes - a.minutes);
const excludeIds = new Set(sorted.slice(0, excludeTopN).map(i => i.num));
arr = arr.filter(i => !excludeIds.has(i.num));
}
const k = Math.max(0, 1 - optimizePct / 100);
return arr.map(i => ({ ...i, minutes: i.minutes * k }));
}
function renderSim(items, base) {
if (!state.sim.enabled) { $('sim-output').hidden = true; return; }
$('sim-output').hidden = false;
const simItems = simulateItems(items);
const simTotal = simItems.reduce((a, b) => a + b.minutes, 0);
const simHours = simTotal / 60;
const simDays = simTotal / MIN_DAY;
const simWeeks = simDays / DAYS_WEEK;
const v = state.sim.vacancies;
const r = Math.max(1, state.sim.recruiters);
const simMonthlyDays = v * simDays;
const simFte = v > 0 ? (v * simHours) / (HOURS_MONTH * r) * 100 : 0;
const simCapacity = simHours > 0 ? (HOURS_MONTH * r) / simHours : 0;
const simOverload = simMonthlyDays - DAYS_MONTH * r;
const simRecruitersNeeded = Math.max(1, Math.ceil(simMonthlyDays / DAYS_MONTH));
const simCost = state.salary > 0 ? state.salary * simHours / HOURS_MONTH * r : null;
const rows = [
['Функций в работе', base.count, simItems.length, x => x],
['Время на 1 цикл, ч', base.hours, simHours, x => x.toFixed(1)],
['Время на 1 цикл, дни', base.daysPerCycle, simDays, x => x.toFixed(2)],
['Время на 1 цикл, недели',base.weeksPerCycle, simWeeks, x => x.toFixed(2)],
['Дней/мес (план)', base.monthlyDays, simMonthlyDays, x => x.toFixed(1)],
['Загрузка FTE, %', base.fte, simFte, x => Math.round(x) + '%'],
['Capacity, вак/мес', base.capacity, simCapacity, x => x.toFixed(1)],
['Перегруз/запас, дней', base.monthOverload, simOverload, x => (x >= 0 ? '+' : '') + x.toFixed(1)],
['Рекрутеров надо', base.recruitersNeeded, simRecruitersNeeded, x => x],
['Стоимость цикла, ₸', base.cost, simCost, x => x != null ? fmt(Math.round(x)) : '—'],
];
$('sim-output').innerHTML = `
<table class="sim-table">
<thead><tr><th>Метрика</th><th>Было</th><th>Стало</th><th>Δ</th></tr></thead>
<tbody>${rows.map(([lbl, was, now, f]) => {
const wasN = typeof was === 'number' ? was : 0;
const nowN = typeof now === 'number' ? now : 0;
const delta = nowN - wasN;
const pct = wasN ? (delta / wasN * 100) : 0;
const better = (lbl.includes('FTE') || lbl.includes('Перегруз') || lbl.includes('Дней') || lbl.includes('Время') || lbl.includes('Стоимость') || lbl.includes('Рекрутеров надо')) ? delta < 0 : delta > 0;
const cls = delta === 0 ? '' : (better ? 'good' : 'bad');
return `<tr>
<td>${lbl}</td>
<td>${was == null ? '—' : f(was)}</td>
<td><b>${now == null ? '—' : f(now)}</b></td>
<td class="${cls}">${delta === 0 || was == null || now == null ? '' : `${delta > 0 ? '+' : ''}${pct.toFixed(0)}%`}</td>
</tr>`;
}).join('')}</tbody>
</table>
<p class="hint">
Сценарий: оптимизация всех функций на <b>${state.sim.optimizePct}%</b>,
исключено топ-<b>${state.sim.excludeTopN}</b>,
исключён этап: <b>${state.sim.excludeStage || '—'}</b>,
рекрутеров: <b>${r}</b>, вакансий/мес: <b>${v}</b>.
</p>
`;
} }
function renderCards(id, cards) { function renderCards(id, cards) {
@ -379,7 +498,7 @@ function chartFteScenario(daysPerCycle) {
const colors = ftePct.map(p => p <= 100 ? '#34d399' : p <= 150 ? '#fbbf24' : '#f87171'); const colors = ftePct.map(p => p <= 100 ? '#34d399' : p <= 150 ? '#fbbf24' : '#f87171');
Plotly.newPlot('chart-fte-scenario', [{ Plotly.newPlot('chart-fte-scenario', [{
type: 'bar', x: xs, y: ftePct, marker: { color: colors }, type: 'bar', x: xs, y: ftePct, marker: { color: colors },
text: xs.map((v, i) => v === state.vacancies ? '← план' : ''), text: xs.map(v => v === state.vacancies ? '← план' : ''),
textposition: 'outside', textposition: 'outside',
hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>', hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>',
}], layout({ }], layout({

View File

@ -44,6 +44,41 @@
<div id="workload-kpi" class="kpi-grid"></div> <div id="workload-kpi" class="kpi-grid"></div>
</section> </section>
<section class="block">
<h2>🧪 Симулятор «что если»</h2>
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
<label class="sim-toggle"><input type="checkbox" id="sim-toggle"> Включить симулятор</label>
<div id="sim-controls" class="sim-controls" hidden>
<div class="sim-row">
<label>Оптимизация всех функций: <span id="sim-optimize-val">0%</span>
<input type="range" id="sim-optimize" min="0" max="80" step="5" value="0">
</label>
</div>
<div class="sim-row">
<label>Исключить топ-N длинных функций: <span id="sim-exclude-top-val">0</span>
<input type="range" id="sim-exclude-top" min="0" max="10" step="1" value="0">
</label>
</div>
<div class="sim-row">
<label>Исключить этап (авто-классификатор):
<select id="sim-stage"></select>
</label>
</div>
<div class="sim-row">
<label>Рекрутеров (FTE): <span id="sim-recruiters-val">1</span>
<input type="range" id="sim-recruiters" min="1" max="10" step="1" value="1">
</label>
<label>Вакансий/мес: <span id="sim-vacancies-val">15</span>
<input type="range" id="sim-vacancies" min="0" max="50" step="1" value="15">
</label>
</div>
<button id="sim-reset" type="button">↺ Сбросить</button>
</div>
<div id="sim-output" hidden></div>
</section>
<section class="block"> <section class="block">
<h2>📈 Сценарий: FTE % vs число вакансий</h2> <h2>📈 Сценарий: FTE % vs число вакансий</h2>
<p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p> <p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p>
@ -107,6 +142,6 @@
<p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p> <p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
<script src="./app.js?v=5"></script> <script src="./app.js?v=6"></script>
</body> </body>
</html> </html>

View File

@ -58,6 +58,21 @@ tr.bottleneck td:nth-child(2) { font-weight: 600; }
#table thead th { user-select: none; } #table thead th { user-select: none; }
#table thead th:hover { background: var(--accent); color: #fff; } #table thead th:hover { background: var(--accent); color: #fff; }
.sim-toggle { display: inline-flex; gap: .5rem; align-items: center; cursor: pointer; margin-bottom: 1rem; font-weight: 500; }
.sim-controls { display: flex; flex-direction: column; gap: .9rem; padding: 1rem; background: var(--card); border-radius: 8px; margin-bottom: 1rem; }
.sim-row { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.sim-row label { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: .35rem; font-size: .85rem; color: var(--muted); }
.sim-row input[type=range] { width: 100%; accent-color: var(--accent); }
.sim-row span { color: var(--accent); font-weight: 700; font-size: 1rem; }
#sim-reset { background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: .45rem .9rem; cursor: pointer; align-self: flex-start; }
#sim-reset:hover { background: var(--accent); color: #fff; }
.sim-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.sim-table th, .sim-table td { padding: .55rem .7rem; border-bottom: 1px solid var(--border); text-align: right; }
.sim-table th:first-child, .sim-table td:first-child { text-align: left; }
.sim-table td.good { color: #34d399; font-weight: 600; }
.sim-table td.bad { color: #f87171; font-weight: 600; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { padding: .55rem .7rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; font-size: .88rem; } th, td { padding: .55rem .7rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; font-size: .88rem; }
th { background: var(--card); font-weight: 600; position: sticky; top: 0; } th { background: var(--card); font-weight: 600; position: sticky; top: 0; }