feat: per-function time overrides (no xlsx changes)

Adds a UI block "Ручные правки времени" that lets a user override the
parsed time of a specific function by number, without modifying the xlsx
file. The override propagates through every KPI, chart, simulator, and
FTE scenario via filtered().

The table marks overridden rows with ✏️ and shows the original raw value
struck through. Overrides appear as removable chips below the form.

Also seeds a default override for function №39 ("Контроль табелей") to
60 minutes, because the raw "5до 7 дней" string parses to ~2880 min
(48 h) which is unrealistic for a one-off check and skews every
aggregate. The user can change or remove it from the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Askarova A 2026-05-22 14:19:29 +05:00
parent e57e210d31
commit dee9899242
3 changed files with 78 additions and 7 deletions

58
app.js
View File

@ -43,7 +43,8 @@ const state = {
search: '',
vacancies: 15,
salary: 500000,
units: 1, // число структурных подразделений (СП)
units: 1,
overrides: {}, // { [num]: minutes } — ручные правки времени без изменения xlsx
sort: { col: 'num', dir: 1 },
sim: {
enabled: false,
@ -68,9 +69,36 @@ addEventListener('DOMContentLoaded', () => {
$('inp-units').oninput = e => { state.units = Math.max(1, +e.target.value || 1); render(); };
initTableSort();
initSimulator();
initOverrides();
autoLoad();
});
function initOverrides() {
$('ovr-apply').onclick = () => {
const num = +$('ovr-num').value;
const min = $('ovr-min').value;
if (!Number.isFinite(num)) return;
if (min === '' || min == null) delete state.overrides[num];
else state.overrides[num] = +min;
renderOverrides();
render();
};
$('ovr-clear').onclick = () => { state.overrides = {}; renderOverrides(); render(); };
}
function renderOverrides() {
const entries = Object.entries(state.overrides);
$('ovr-list').innerHTML = entries.length === 0
? '<span class="hint">Нет правок. Все значения берутся из xlsx.</span>'
: entries.map(([num, min]) => {
const it = state.items.find(i => i.num === +num);
return `<span class="chip">№${num} → ${min} мин <button data-num="${num}" class="chip-x">×</button> ${it ? `<span class="mut">— ${esc(trunc(it.name, 40))}</span>` : ''}</span>`;
}).join('');
$('ovr-list').querySelectorAll('.chip-x').forEach(b => {
b.onclick = () => { delete state.overrides[+b.dataset.num]; renderOverrides(); render(); };
});
}
function initSimulator() {
$('sim-toggle').onchange = e => { state.sim.enabled = e.target.checked; $('sim-controls').hidden = !e.target.checked; render(); };
$('sim-reset').onclick = () => {
@ -129,11 +157,22 @@ function loadWorkbook(buf) {
state.items = parseRows(rows);
console.log('[xlsx] parsed items:', state.items.length);
if (!state.items.length) return;
applyDefaultOverrides();
populateGroups();
showDashboard();
renderOverrides();
render();
}
// Дефолтные правки по аномалиям в xlsx (текст «5до 7 дней» парсится как ~2880 мин,
// что нереалистично для разовой задачи контроля табелей).
function applyDefaultOverrides() {
const it = state.items.find(i => i.num === 39);
if (it && /табел/i.test(it.name) && state.overrides[39] == null) {
state.overrides[39] = 60; // «не более 60 мин» по умолчанию, редактируется в UI
}
}
// === Excel ===
async function onFile(e) {
const file = e.target.files[0];
@ -537,15 +576,18 @@ function renderTable(items, bottleneckIds = new Set()) {
document.querySelector('#table tbody').innerHTML = sorted.map(i => {
const hot = bottleneckIds.has(i.num);
const u = i.perUnit && i.unitsApplied > 1;
const tag = (hot ? '🔥 ' : '') + (i.perUnit ? `🏢×${i.unitsApplied} ` : '');
const tag = (hot ? '🔥 ' : '') + (i.overridden ? '✏️ ' : '') + (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))
: '—';
const rawCell = i.overridden
? `<span class="mut">${esc(String(i.rawTime ?? '—'))} → ${i.baseMinutes} мин</span>`
: esc(String(i.rawTime ?? '—'));
return `
<tr class="${hot ? 'bottleneck' : ''} ${i.perUnit ? 'per-unit' : ''}">
<tr class="${hot ? 'bottleneck' : ''} ${i.perUnit ? 'per-unit' : ''} ${i.overridden ? 'overridden' : ''}">
<td>${i.num}</td>
<td>${tag}${esc(i.name)}</td>
<td>${esc(String(i.rawTime ?? '—'))}</td>
<td>${rawCell}</td>
<td>${minCell}</td>
<td>${esc(i.stage)}</td>
<td>${esc(trunc(i.group, 60))}</td>
@ -571,14 +613,18 @@ function filtered() {
(!state.search || i.name.toLowerCase().includes(state.search))
)
.map(i => {
const override = state.overrides[i.num];
const baseMin = (override != null && Number.isFinite(override)) ? override : i.minutes;
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,
overridden: override != null,
minutes: baseMin == null ? null : baseMin * k,
baseMinutes: baseMin,
origMinutes: i.minutes,
};
});
}

View File

@ -56,6 +56,18 @@
<div id="workload-kpi" class="kpi-grid"></div>
</section>
<section class="block">
<h2>✏️ Ручные правки времени (без изменения xlsx)</h2>
<p class="hint">Заменить минуты конкретной функции по №. Влияет на все KPI и графики. Пусто = убрать правку.</p>
<div class="ovr-form">
<label>№ функции <input type="number" id="ovr-num" min="1" placeholder="например 39"></label>
<label>Время, мин <input type="number" id="ovr-min" min="0" placeholder="например 60"></label>
<button type="button" id="ovr-apply">Применить</button>
<button type="button" id="ovr-clear" class="secondary">Очистить все</button>
</div>
<div id="ovr-list" class="ovr-list"></div>
</section>
<section class="block">
<h2>🧪 Симулятор «что если»</h2>
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
@ -157,6 +169,6 @@
<p>Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
</div>
<script src="./app.js?v=8"></script>
<script src="./app.js?v=9"></script>
</body>
</html>

View File

@ -118,8 +118,21 @@ h2 .hint { display: inline; margin-left: .5rem; font-size: .75rem; }
tr.bottleneck td { background: rgba(248, 113, 113, 0.12); }
tr.bottleneck td:nth-child(2) { font-weight: 600; }
tr.per-unit td:nth-child(2) { color: var(--accent); }
tr.overridden td { background: rgba(167, 139, 250, 0.10); }
tr.overridden td:nth-child(3) { font-style: italic; }
.mut { color: var(--muted); font-weight: normal; font-size: .8em; }
.ovr-form { display: flex; gap: .8rem; flex-wrap: wrap; align-items: flex-end; margin-bottom: 1rem; }
.ovr-form label { display: flex; flex-direction: column; gap: .3rem; font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.ovr-form input { background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: .5rem .65rem; font: inherit; min-width: 140px; }
.ovr-form button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: .55rem 1rem; font: inherit; font-weight: 600; cursor: pointer; }
.ovr-form button:hover { opacity: .9; }
.ovr-form button.secondary { background: var(--card); color: var(--fg); border: 1px solid var(--border); }
.ovr-list { display: flex; flex-wrap: wrap; gap: .5rem; }
.chip { display: inline-flex; align-items: center; gap: .35rem; background: rgba(167,139,250,0.15); border: 1px solid rgba(167,139,250,0.4); color: var(--fg); padding: .3rem .65rem; border-radius: 16px; font-size: .85rem; }
.chip-x { background: transparent; border: 0; color: var(--fg); cursor: pointer; font-size: 1rem; padding: 0 .15rem; opacity: .7; }
.chip-x:hover { opacity: 1; color: #f87171; }
#table thead th { user-select: none; }
#table thead th:hover { background: var(--accent); color: #fff; }