feat: dashboard analytics pack — Pareto, histogram, treemap, duration, auto-stages, FTE scenario, recruiters needed, sortable table, bottleneck highlight

Adds analyses that don't require new data:

- Pareto cumulative chart (which N functions = 80% of time).
- Duration histogram (bucketed counts).
- Function length donut (short / medium / long).
- Auto-stage classifier by keywords in the function name
  (Привлечение / Скрининг / Интервью / Оффер / Адаптация /
  Документы-СПП / Коммуникация / Прочее) + horizontal bar.
- Treemap: group -> function, area = minutes.
- "FTE scenario" bar chart: 0..30 vacancies/month with color buckets
  (green ≤100%, amber ≤150%, red >150%) and a dashed 100% norm line.
- "Recruiters needed" card: ceil(monthlyDays / 21).
- Sortable table columns + Этап column.
- Top-3 bottleneck rows highlighted (red tint + 🔥).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Askarova A 2026-05-22 13:57:47 +05:00
parent 95fe078387
commit 3006833705
3 changed files with 267 additions and 13 deletions

221
app.js
View File

@ -15,12 +15,30 @@ const WORDS = {
'девять':9,'девяти':9,'десять':10,'десяти':10,
};
// Авто-классификация по этапам процесса (ключевые слова в названии функции)
const STAGE_RULES = [
['Привлечение', /публикац|hh\.kz|samruk|qsamruk|qazyna|поиск|базы|базы\s*холод|холодн|источник|ln\b/i],
['Скрининг', /резюме|скрининг|отбор|телефонн|первичн|проверк|соответств|квалификац/i],
['Интервью', /интервью|собеседован|встреч/i],
['Оффер', /оффер|offer|предложен|согласован|подготовк\s*-?\s*offer/i],
['Адаптация', /адаптац|онбоард|onboarding|выход\s*кандидат/i],
['Документы/СПП',/спп|сбалансирован|оклад|утвержден|активац|документ|заявк/i],
['Коммуникация', /сообщен|информирован|отправк|обновлен|статус|таблиц|рассмотрен/i],
];
function autoStage(name) {
const s = String(name || '').toLowerCase();
for (const [stage, re] of STAGE_RULES) if (re.test(s)) return stage;
return 'Прочее';
}
const state = {
items: [],
groupFilter: [],
search: '',
vacancies: 15,
salary: 500000,
sort: { col: 'num', dir: 1 }, // 1=asc, -1=desc
};
// === Init ===
@ -33,9 +51,22 @@ addEventListener('DOMContentLoaded', () => {
$('theme-toggle').onclick = toggleTheme;
$('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; render(); };
$('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); };
initTableSort();
autoLoad();
});
function initTableSort() {
document.querySelectorAll('#table thead th').forEach(th => {
th.style.cursor = 'pointer';
th.onclick = () => {
const col = th.dataset.col;
if (!col) return;
state.sort = state.sort.col === col ? { col, dir: -state.sort.dir } : { col, dir: 1 };
render();
};
});
}
async function autoLoad() {
try {
const res = await fetch(DEFAULT_XLSX);
@ -84,7 +115,8 @@ function parseRows(rows) {
if (num == null || num === '') { group = String(desc).trim(); continue; }
const n = Number(num);
if (!Number.isFinite(n)) continue;
out.push({ num: n, name: String(desc).trim(), rawTime: time, minutes: parseTime(time), group });
const nm = String(desc).trim();
out.push({ num: n, name: nm, rawTime: time, minutes: parseTime(time), group, stage: autoStage(nm) });
}
return out;
}
@ -138,13 +170,53 @@ function metrics(items) {
const weeksPerCycle = daysPerCycle / DAYS_WEEK;
const monthlyDays = state.vacancies * daysPerCycle;
const monthlyWeeks = monthlyDays / DAYS_WEEK;
const monthOverload = monthlyDays - DAYS_MONTH; // перегруз сверх месяца, дней
const monthOverload = monthlyDays - DAYS_MONTH;
const recruitersNeeded = Math.max(1, Math.ceil(monthlyDays / DAYS_MONTH));
// Pareto: накопительный % по убыванию минут
const sortedDesc = [...valid].sort((a, b) => b.minutes - a.minutes);
let cum = 0;
const pareto = sortedDesc.map(it => ({ name: it.name, minutes: it.minutes, cumPct: total ? ((cum += it.minutes) / total * 100) : 0 }));
const pareto80 = pareto.findIndex(p => p.cumPct >= 80) + 1; // сколько функций = 80% времени
// Гистограмма бакетов длительности
const BUCKETS = [
{ label: '<15 мин', lo: 0, hi: 15 },
{ label: '15–30 мин', lo: 15, hi: 30 },
{ label: '30–60 мин', lo: 30, hi: 60 },
{ label: '60–120 мин',lo: 60, hi: 120 },
{ label: '>120 мин', lo: 120, hi: Infinity },
];
const histogram = BUCKETS.map(b => ({
label: b.label,
count: valid.filter(i => i.minutes >= b.lo && i.minutes < b.hi).length,
}));
// Категории по длине
const duration = {
'Короткие (<30 мин)': valid.filter(i => i.minutes < 30).length,
'Средние (30–120 мин)':valid.filter(i => i.minutes >= 30 && i.minutes < 120).length,
'Длинные (≥120 мин)': valid.filter(i => i.minutes >= 120).length,
};
const durationMin = {
'Короткие': valid.filter(i => i.minutes < 30).reduce((a,b) => a+b.minutes, 0),
'Средние': valid.filter(i => i.minutes >= 30 && i.minutes < 120).reduce((a,b) => a+b.minutes, 0),
'Длинные': valid.filter(i => i.minutes >= 120).reduce((a,b) => a+b.minutes, 0),
};
// Авто-этапы
const byStage = {};
for (const it of valid) byStage[it.stage] = (byStage[it.stage] || 0) + it.minutes;
// Bottleneck top-3 num set
const bottleneckIds = new Set(top10.slice(0, 3).map(i => i.num));
return {
total, hours, count: items.length, avg: valid.length ? total / valid.length : 0,
longest, top10, byGroup,
fte, capacity, bottleneck, cost,
daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload,
daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload, recruitersNeeded,
pareto, pareto80, histogram, duration, durationMin, byStage, bottleneckIds,
};
}
@ -178,11 +250,22 @@ function render() {
label: m.monthOverload > 0 ? '⚠ Перегруз сверх месяца' : '✓ Запас в месяце',
sub: m.monthOverload > 0 ? `нужно +${(m.monthOverload/DAYS_MONTH).toFixed(2)} FTE` : `свободно ${Math.abs(m.monthOverload).toFixed(1)} дн`,
},
{
value: `${m.recruitersNeeded} 👥`,
label: 'Рекрутеров надо',
sub: `⌈ ${m.monthlyDays.toFixed(1)} / ${DAYS_MONTH} дн ⌉`,
},
]);
chartTop10(m.top10);
chartGroups(m.byGroup);
renderTable(items);
chartPareto(m.pareto, m.pareto80);
chartHistogram(m.histogram);
chartTreemap(items, m.byGroup);
chartDuration(m.duration, m.durationMin);
chartStages(m.byStage);
chartFteScenario(m.daysPerCycle);
renderTable(items, m.bottleneckIds);
}
function renderCards(id, cards) {
@ -218,15 +301,135 @@ function chartGroups(byGroup) {
}], layout({ height: 420, showlegend: true, legend: { orientation: 'h', y: -0.1 }, margin: { l: 10, r: 10, t: 10, b: 40 } }), cfg());
}
function renderTable(items) {
document.querySelector('#table tbody').innerHTML = items.map(i => `
<tr>
function chartPareto(pareto, pareto80) {
if (!pareto.length) return Plotly.purge('chart-pareto');
const xs = pareto.map((_, i) => i + 1);
const cum = pareto.map(p => +p.cumPct.toFixed(1));
const mins = pareto.map(p => Math.round(p.minutes));
Plotly.newPlot('chart-pareto', [
{ type: 'bar', x: xs, y: mins, name: 'Минуты', marker: { color: cssVar('--accent') }, yaxis: 'y' },
{ type: 'scatter', mode: 'lines+markers', x: xs, y: cum, name: 'Накопит. %', yaxis: 'y2', line: { color: '#f87171', width: 2 } },
], layout({
height: 380, margin: { l: 60, r: 60, t: 30, b: 50 },
xaxis: { title: 'Функции (по убыванию длительности)' },
yaxis: { title: 'Минуты' },
yaxis2: { title: '% от общего', overlaying: 'y', side: 'right', range: [0, 105], showgrid: false, ticksuffix: '%' },
shapes: [{ type: 'line', xref: 'x', yref: 'y2', x0: 0, x1: pareto.length + 1, y0: 80, y1: 80, line: { color: '#fbbf24', width: 1, dash: 'dash' } }],
annotations: pareto80 > 0 ? [{ x: pareto80, y: 80, yref: 'y2', text: `${pareto80} функций = 80%`, showarrow: true, arrowhead: 2, ax: 40, ay: -30, font: { color: cssVar('--fg') } }] : [],
legend: { orientation: 'h', y: -0.2 },
}), cfg());
}
function chartHistogram(hist) {
if (!hist.some(b => b.count)) return Plotly.purge('chart-histogram');
Plotly.newPlot('chart-histogram', [{
type: 'bar', x: hist.map(b => b.label), y: hist.map(b => b.count),
marker: { color: cssVar('--accent') },
text: hist.map(b => b.count || ''), textposition: 'outside',
hovertemplate: '<b>%{x}</b><br>%{y} функций<extra></extra>',
}], layout({ height: 320, margin: { l: 40, r: 20, t: 20, b: 50 }, yaxis: { title: 'Количество функций' } }), cfg());
}
function chartTreemap(items, byGroup) {
const valid = items.filter(i => i.minutes != null);
if (!valid.length) return Plotly.purge('chart-treemap');
const groups = Object.keys(byGroup);
const labels = ['Все', ...groups.map(g => trunc(g, 30)), ...valid.map(i => `№${i.num} ${trunc(i.name, 25)}`)];
const parents = ['', ...groups.map(_ => 'Все'), ...valid.map(i => trunc(i.group, 30))];
const values = [0, ...groups.map(g => byGroup[g]), ...valid.map(i => i.minutes)];
Plotly.newPlot('chart-treemap', [{
type: 'treemap', labels, parents, values,
textinfo: 'label+value', hovertemplate: '<b>%{label}</b><br>%{value:.0f} мин<extra></extra>',
marker: { colorscale: 'Blues' },
}], layout({ height: 460, margin: { l: 5, r: 5, t: 5, b: 5 } }), cfg());
}
function chartDuration(dur, durMin) {
const labels = Object.keys(dur);
if (!labels.some(l => dur[l])) return Plotly.purge('chart-duration');
Plotly.newPlot('chart-duration', [{
type: 'pie', hole: 0.55,
labels, values: labels.map(l => dur[l]),
text: labels.map(l => `${dur[l]} ф.`),
textinfo: 'label+percent',
hovertemplate: '<b>%{label}</b><br>%{value} функций<br>Сумма: ' + labels.map(l => `${Math.round(durMin[l.split(' ')[0]])} мин`).join(', ') + '<extra></extra>',
marker: { colors: ['#34d399', '#fbbf24', '#f87171'] },
}], layout({ height: 320, margin: { l: 10, r: 10, t: 20, b: 40 }, showlegend: true, legend: { orientation: 'h', y: -0.05 } }), cfg());
}
function chartStages(byStage) {
const labels = Object.keys(byStage).sort((a, b) => byStage[b] - byStage[a]);
if (!labels.length) return Plotly.purge('chart-stages');
const total = labels.reduce((a, l) => a + byStage[l], 0);
Plotly.newPlot('chart-stages', [{
type: 'bar', orientation: 'h',
y: labels.map(l => `${l} (${Math.round(byStage[l]/total*100)}%)`).reverse(),
x: labels.map(l => byStage[l]).reverse(),
marker: { color: ['#60a5fa','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c','#22d3ee','#f472b6'].slice(0, labels.length).reverse() },
text: labels.map(l => `${Math.round(byStage[l])} мин`).reverse(),
textposition: 'outside',
hovertemplate: '<b>%{y}</b><br>%{x:.0f} мин<extra></extra>',
}], layout({ height: 360, margin: { l: 220, r: 60, t: 10, b: 40 }, xaxis: { title: 'Минуты' } }), cfg());
}
function chartFteScenario(daysPerCycle) {
if (!daysPerCycle) return Plotly.purge('chart-fte-scenario');
const xs = Array.from({ length: 31 }, (_, i) => i); // 0..30 вакансий
const ftePct = xs.map(v => v * daysPerCycle / DAYS_MONTH * 100);
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 ? '← план' : ''),
textposition: 'outside',
hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>',
}], layout({
height: 320, margin: { l: 50, r: 20, t: 20, b: 50 },
xaxis: { title: 'Вакансий в месяц' },
yaxis: { title: 'Загрузка FTE, %', ticksuffix: '%' },
shapes: [
{ type: 'line', xref: 'x', yref: 'y', x0: -0.5, x1: 30.5, y0: 100, y1: 100, line: { color: '#f87171', width: 2, dash: 'dash' } },
],
annotations: [{ x: 30, y: 100, xanchor: 'right', yanchor: 'bottom', text: 'Норма 100% FTE', showarrow: false, font: { color: '#f87171' } }],
}), cfg());
}
function renderTable(items, bottleneckIds = new Set()) {
const { col, dir } = state.sort;
const sorted = [...items].sort((a, b) => {
const av = sortVal(a, col), bv = sortVal(b, col);
if (av < bv) return -dir;
if (av > bv) return dir;
return 0;
});
// Маркеры сортировки в заголовках
document.querySelectorAll('#table thead th').forEach(th => {
const c = th.dataset.col;
const base = th.dataset.label || th.textContent.replace(/[▲▼]\s*$/, '').trim();
th.dataset.label = base;
th.textContent = c === col ? `${base} ${dir > 0 ? '▲' : '▼'}` : base;
});
document.querySelector('#table tbody').innerHTML = sorted.map(i => {
const hot = bottleneckIds.has(i.num);
return `
<tr class="${hot ? 'bottleneck' : ''}">
<td>${i.num}</td>
<td>${esc(i.name)}</td>
<td>${hot ? '🔥 ' : ''}${esc(i.name)}</td>
<td>${esc(String(i.rawTime ?? '—'))}</td>
<td>${i.minutes != null ? Math.round(i.minutes) : '—'}</td>
<td>${esc(i.stage)}</td>
<td>${esc(trunc(i.group, 60))}</td>
</tr>`).join('');
</tr>`;
}).join('');
}
function sortVal(it, col) {
if (col === 'num') return it.num;
if (col === 'name') return it.name.toLowerCase();
if (col === 'minutes') return it.minutes ?? -1;
if (col === 'rawTime') return String(it.rawTime ?? '');
if (col === 'stage') return it.stage;
if (col === 'group') return it.group;
return 0;
}
// === Helpers ===

View File

@ -44,6 +44,12 @@
<div id="workload-kpi" class="kpi-grid"></div>
</section>
<section class="block">
<h2>📈 Сценарий: FTE % vs число вакансий</h2>
<p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p>
<div id="chart-fte-scenario"></div>
</section>
<section class="charts-row">
<div class="block">
<h2>Топ-10 самых долгих функций</h2>
@ -56,9 +62,44 @@
</section>
<section class="block">
<h2>Все функции</h2>
<h2>📉 Pareto: накопительный % времени</h2>
<p class="hint">Какие функции дают 80% всей нагрузки. Жёлтая линия — порог 80%.</p>
<div id="chart-pareto"></div>
</section>
<section class="charts-row">
<div class="block">
<h2>📊 Гистограмма длительности</h2>
<div id="chart-histogram"></div>
</div>
<div class="block">
<h2>⏱ Категории функций</h2>
<div id="chart-duration"></div>
</div>
</section>
<section class="block">
<h2>🧭 Авто-этапы (по ключевым словам в названии)</h2>
<p class="hint">Эвристика — не правит данные. Привлечение / Скрининг / Интервью / Оффер / Адаптация / Документы-СПП / Коммуникация.</p>
<div id="chart-stages"></div>
</section>
<section class="block">
<h2>🗺 Treemap: группа → функция (площадь = время)</h2>
<div id="chart-treemap"></div>
</section>
<section class="block">
<h2>Все функции <span class="hint">(клик по заголовку — сортировка)</span></h2>
<table id="table">
<thead><tr><th>№</th><th>Функция</th><th>Исх. время</th><th>Мин</th><th>Группа</th></tr></thead>
<thead><tr>
<th data-col="num">№</th>
<th data-col="name">Функция</th>
<th data-col="rawTime">Исх. время</th>
<th data-col="minutes">Мин</th>
<th data-col="stage">Этап</th>
<th data-col="group">Группа</th>
</tr></thead>
<tbody></tbody>
</table>
</section>
@ -66,6 +107,6 @@
<p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
<script src="./app.js?v=4"></script>
<script src="./app.js?v=5"></script>
</body>
</html>

View File

@ -46,7 +46,17 @@ main { padding: 1.5rem 2rem; display: flex; flex-direction: column; gap: 1.25rem
.inputs { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.inputs label { display: flex; flex-direction: column; gap: .3rem; font-size: .75rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
#chart-top10, #chart-groups { min-height: 380px; }
#chart-top10, #chart-groups, #chart-pareto, #chart-treemap, #chart-fte-scenario, #chart-stages { min-height: 320px; }
#chart-histogram, #chart-duration { min-height: 300px; }
.hint { color: var(--muted); font-size: .8rem; margin: -.5rem 0 1rem; font-weight: normal; }
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; }
#table thead th { user-select: none; }
#table thead th:hover { background: var(--accent); color: #fff; }
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; }