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:
parent
3006833705
commit
218c93df25
125
app.js
125
app.js
@ -38,7 +38,15 @@ const state = {
|
||||
search: '',
|
||||
vacancies: 15,
|
||||
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 ===
|
||||
@ -49,12 +57,41 @@ addEventListener('DOMContentLoaded', () => {
|
||||
$('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(); };
|
||||
$('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(); };
|
||||
initTableSort();
|
||||
initSimulator();
|
||||
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() {
|
||||
document.querySelectorAll('#table thead th').forEach(th => {
|
||||
th.style.cursor = 'pointer';
|
||||
@ -266,6 +303,88 @@ function render() {
|
||||
chartStages(m.byStage);
|
||||
chartFteScenario(m.daysPerCycle);
|
||||
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) {
|
||||
@ -379,7 +498,7 @@ function chartFteScenario(daysPerCycle) {
|
||||
const colors = ftePct.map(p => p <= 100 ? '#34d399' : p <= 150 ? '#fbbf24' : '#f87171');
|
||||
Plotly.newPlot('chart-fte-scenario', [{
|
||||
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',
|
||||
hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>',
|
||||
}], layout({
|
||||
|
||||
37
index.html
37
index.html
@ -44,6 +44,41 @@
|
||||
<div id="workload-kpi" class="kpi-grid"></div>
|
||||
</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">
|
||||
<h2>📈 Сценарий: FTE % vs число вакансий</h2>
|
||||
<p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p>
|
||||
@ -107,6 +142,6 @@
|
||||
|
||||
<p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
|
||||
|
||||
<script src="./app.js?v=5"></script>
|
||||
<script src="./app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
15
styles.css
15
styles.css
@ -58,6 +58,21 @@ tr.bottleneck td:nth-child(2) { font-weight: 600; }
|
||||
#table thead th { user-select: none; }
|
||||
#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; }
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user