feat: per-business-unit time multiplier (no data changes)

Adds a "Структурных подразделений (СП)" input. The xlsx source is not
modified; instead, at compute time functions whose name mentions СПП/ССП,
подразделения, руководители, комиссия, or линейн get their minutes
multiplied by the СП count. The table tags those rows with 🏢×N and shows
the base × multiplier breakdown.

This propagates through every KPI, chart, simulator, and FTE scenario
automatically because the multiplication happens in filtered() before
metrics().

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

38
app.js
View File

@ -15,6 +15,11 @@ const WORDS = {
'девять':9,'девяти':9,'десять':10,'десяти':10, 'девять':9,'девяти':9,'десять':10,'десяти':10,
}; };
// Функции, время которых масштабируется по числу структурных подразделений (СП).
// Признак — упоминание СПП/ССП, подразделений, руководителей в названии.
const PER_UNIT_RE = /сп[пс]|подразделен|руководител|комисси|линейн/i;
function isPerUnit(name) { return PER_UNIT_RE.test(String(name || '')); }
// Авто-классификация по этапам процесса (ключевые слова в названии функции) // Авто-классификация по этапам процесса (ключевые слова в названии функции)
const STAGE_RULES = [ const STAGE_RULES = [
['Привлечение', /публикац|hh\.kz|samruk|qsamruk|qazyna|поиск|базы|базы\s*холод|холодн|источник|ln\b/i], ['Привлечение', /публикац|hh\.kz|samruk|qsamruk|qazyna|поиск|базы|базы\s*холод|холодн|источник|ln\b/i],
@ -38,6 +43,7 @@ const state = {
search: '', search: '',
vacancies: 15, vacancies: 15,
salary: 500000, salary: 500000,
units: 1, // число структурных подразделений (СП)
sort: { col: 'num', dir: 1 }, sort: { col: 'num', dir: 1 },
sim: { sim: {
enabled: false, enabled: false,
@ -59,6 +65,7 @@ addEventListener('DOMContentLoaded', () => {
$('theme-toggle').onclick = toggleTheme; $('theme-toggle').onclick = toggleTheme;
$('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; state.sim.vacancies = state.vacancies; renderSimControls(); 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(); };
$('inp-units').oninput = e => { state.units = Math.max(1, +e.target.value || 1); render(); };
initTableSort(); initTableSort();
initSimulator(); initSimulator();
autoLoad(); autoLoad();
@ -529,12 +536,17 @@ function renderTable(items, bottleneckIds = new Set()) {
}); });
document.querySelector('#table tbody').innerHTML = sorted.map(i => { document.querySelector('#table tbody').innerHTML = sorted.map(i => {
const hot = bottleneckIds.has(i.num); const hot = bottleneckIds.has(i.num);
const u = i.perUnit && i.unitsApplied > 1;
const tag = (hot ? '🔥 ' : '') + (i.perUnit ? `🏢×${i.unitsApplied} ` : '');
const minCell = i.minutes != null
? (u ? `${Math.round(i.minutes)} <span class="mut">(${Math.round(i.baseMinutes)}×${i.unitsApplied})</span>` : Math.round(i.minutes))
: '—';
return ` return `
<tr class="${hot ? 'bottleneck' : ''}"> <tr class="${hot ? 'bottleneck' : ''} ${i.perUnit ? 'per-unit' : ''}">
<td>${i.num}</td> <td>${i.num}</td>
<td>${hot ? '🔥 ' : ''}${esc(i.name)}</td> <td>${tag}${esc(i.name)}</td>
<td>${esc(String(i.rawTime ?? '—'))}</td> <td>${esc(String(i.rawTime ?? '—'))}</td>
<td>${i.minutes != null ? Math.round(i.minutes) : '—'}</td> <td>${minCell}</td>
<td>${esc(i.stage)}</td> <td>${esc(i.stage)}</td>
<td>${esc(trunc(i.group, 60))}</td> <td>${esc(trunc(i.group, 60))}</td>
</tr>`; </tr>`;
@ -553,10 +565,22 @@ function sortVal(it, col) {
// === Helpers === // === Helpers ===
function filtered() { function filtered() {
return state.items.filter(i => return state.items
(!state.groupFilter.length || state.groupFilter.includes(i.group)) && .filter(i =>
(!state.search || i.name.toLowerCase().includes(state.search)) (!state.groupFilter.length || state.groupFilter.includes(i.group)) &&
); (!state.search || i.name.toLowerCase().includes(state.search))
)
.map(i => {
const perUnit = isPerUnit(i.name);
const k = perUnit ? state.units : 1;
return {
...i,
perUnit,
unitsApplied: state.units,
minutes: i.minutes == null ? null : i.minutes * k,
baseMinutes: i.minutes,
};
});
} }
function populateGroups() { function populateGroups() {

View File

@ -35,6 +35,10 @@
<div class="inputs"> <div class="inputs">
<label>Вакансий/мес <input type="number" id="inp-vacancies" value="15" min="0"></label> <label>Вакансий/мес <input type="number" id="inp-vacancies" value="15" min="0"></label>
<label>Ставка ₸/мес <input type="number" id="inp-salary" value="500000" min="0" step="10000"></label> <label>Ставка ₸/мес <input type="number" id="inp-salary" value="500000" min="0" step="10000"></label>
<label title="Время функций, помеченных 🏢, умножается на это число">
Структурных подразделений (СП)
<input type="number" id="inp-units" value="1" min="1" step="1">
</label>
</div> </div>
<div id="strategic-kpi" class="kpi-grid"></div> <div id="strategic-kpi" class="kpi-grid"></div>
</section> </section>
@ -142,6 +146,6 @@
<p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p> <p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
<script src="./app.js?v=6"></script> <script src="./app.js?v=7"></script>
</body> </body>
</html> </html>

View File

@ -54,6 +54,8 @@ h2 .hint { display: inline; margin-left: .5rem; font-size: .75rem; }
tr.bottleneck td { background: rgba(248, 113, 113, 0.12); } tr.bottleneck td { background: rgba(248, 113, 113, 0.12); }
tr.bottleneck td:nth-child(2) { font-weight: 600; } tr.bottleneck td:nth-child(2) { font-weight: 600; }
tr.per-unit td:nth-child(2) { color: var(--accent); }
.mut { color: var(--muted); font-weight: normal; font-size: .8em; }
#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; }