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:
parent
218c93df25
commit
3c937e091c
38
app.js
38
app.js
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user