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:
parent
e57e210d31
commit
dee9899242
58
app.js
58
app.js
@ -43,7 +43,8 @@ const state = {
|
|||||||
search: '',
|
search: '',
|
||||||
vacancies: 15,
|
vacancies: 15,
|
||||||
salary: 500000,
|
salary: 500000,
|
||||||
units: 1, // число структурных подразделений (СП)
|
units: 1,
|
||||||
|
overrides: {}, // { [num]: minutes } — ручные правки времени без изменения xlsx
|
||||||
sort: { col: 'num', dir: 1 },
|
sort: { col: 'num', dir: 1 },
|
||||||
sim: {
|
sim: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -68,9 +69,36 @@ addEventListener('DOMContentLoaded', () => {
|
|||||||
$('inp-units').oninput = e => { state.units = Math.max(1, +e.target.value || 1); render(); };
|
$('inp-units').oninput = e => { state.units = Math.max(1, +e.target.value || 1); render(); };
|
||||||
initTableSort();
|
initTableSort();
|
||||||
initSimulator();
|
initSimulator();
|
||||||
|
initOverrides();
|
||||||
autoLoad();
|
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() {
|
function initSimulator() {
|
||||||
$('sim-toggle').onchange = e => { state.sim.enabled = e.target.checked; $('sim-controls').hidden = !e.target.checked; render(); };
|
$('sim-toggle').onchange = e => { state.sim.enabled = e.target.checked; $('sim-controls').hidden = !e.target.checked; render(); };
|
||||||
$('sim-reset').onclick = () => {
|
$('sim-reset').onclick = () => {
|
||||||
@ -129,11 +157,22 @@ function loadWorkbook(buf) {
|
|||||||
state.items = parseRows(rows);
|
state.items = parseRows(rows);
|
||||||
console.log('[xlsx] parsed items:', state.items.length);
|
console.log('[xlsx] parsed items:', state.items.length);
|
||||||
if (!state.items.length) return;
|
if (!state.items.length) return;
|
||||||
|
applyDefaultOverrides();
|
||||||
populateGroups();
|
populateGroups();
|
||||||
showDashboard();
|
showDashboard();
|
||||||
|
renderOverrides();
|
||||||
render();
|
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 ===
|
// === Excel ===
|
||||||
async function onFile(e) {
|
async function onFile(e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
@ -537,15 +576,18 @@ 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 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
|
const minCell = i.minutes != null
|
||||||
? (u ? `${Math.round(i.minutes)} <span class="mut">(${Math.round(i.baseMinutes)}×${i.unitsApplied})</span>` : Math.round(i.minutes))
|
? (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 `
|
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>${i.num}</td>
|
||||||
<td>${tag}${esc(i.name)}</td>
|
<td>${tag}${esc(i.name)}</td>
|
||||||
<td>${esc(String(i.rawTime ?? '—'))}</td>
|
<td>${rawCell}</td>
|
||||||
<td>${minCell}</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>
|
||||||
@ -571,14 +613,18 @@ function filtered() {
|
|||||||
(!state.search || i.name.toLowerCase().includes(state.search))
|
(!state.search || i.name.toLowerCase().includes(state.search))
|
||||||
)
|
)
|
||||||
.map(i => {
|
.map(i => {
|
||||||
|
const override = state.overrides[i.num];
|
||||||
|
const baseMin = (override != null && Number.isFinite(override)) ? override : i.minutes;
|
||||||
const perUnit = isPerUnit(i.name);
|
const perUnit = isPerUnit(i.name);
|
||||||
const k = perUnit ? state.units : 1;
|
const k = perUnit ? state.units : 1;
|
||||||
return {
|
return {
|
||||||
...i,
|
...i,
|
||||||
perUnit,
|
perUnit,
|
||||||
unitsApplied: state.units,
|
unitsApplied: state.units,
|
||||||
minutes: i.minutes == null ? null : i.minutes * k,
|
overridden: override != null,
|
||||||
baseMinutes: i.minutes,
|
minutes: baseMin == null ? null : baseMin * k,
|
||||||
|
baseMinutes: baseMin,
|
||||||
|
origMinutes: i.minutes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
14
index.html
14
index.html
@ -56,6 +56,18 @@
|
|||||||
<div id="workload-kpi" class="kpi-grid"></div>
|
<div id="workload-kpi" class="kpi-grid"></div>
|
||||||
</section>
|
</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">
|
<section class="block">
|
||||||
<h2>🧪 Симулятор «что если»</h2>
|
<h2>🧪 Симулятор «что если»</h2>
|
||||||
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
|
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
|
||||||
@ -157,6 +169,6 @@
|
|||||||
<p>Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
|
<p>Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="./app.js?v=8"></script>
|
<script src="./app.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
13
styles.css
13
styles.css
@ -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 { 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); }
|
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; }
|
.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 { 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