feat: emoji, 2-recruiter split, weekly chart, FTE multi-line scenario

- добавлены эмодзи во все заголовки и кнопки
- новый раздел «Распределение на 2 рекрутеров» с KPI-карточками
- понедельный план: сгруппированный бар-чарт (Рекрутер 1 / Рекрутер 2)
- сценарий вакансий переделан в мульти-линейный (1/2/3 рекрутера),
  цветовые зоны, ромбы ёмкости, аннотации FTE на текущем плане

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Anuar 2026-05-22 16:46:36 +05:00
parent 1244c52e41
commit f03de26637
3 changed files with 177 additions and 32 deletions

164
app.js
View File

@ -294,12 +294,21 @@ function metrics(items) {
// Bottleneck top-3 num set // Bottleneck top-3 num set
const bottleneckIds = new Set(top10.slice(0, 3).map(i => i.num)); const bottleneckIds = new Set(top10.slice(0, 3).map(i => i.num));
// Распределение на 2 рекрутеров
const r2DaysEach = monthlyDays / 2;
const r2WeeksEach = r2DaysEach / DAYS_WEEK;
const r2FteEach = fte / 2;
const r2OverloadEach = r2DaysEach - DAYS_MONTH;
const r2DaysPerVac = daysPerCycle / 2; // дней на 1 вакансию при работе двоих
const r2DaysPerWeek = r2DaysEach / (DAYS_MONTH / DAYS_WEEK); // дней/нед на 1 рекрутера
return { return {
total, hours, count: items.length, avg: valid.length ? total / valid.length : 0, total, hours, count: items.length, avg: valid.length ? total / valid.length : 0,
longest, top10, byGroup, longest, top10, byGroup,
fte, capacity, bottleneck, cost, fte, capacity, bottleneck, cost,
daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload, recruitersNeeded, daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload, recruitersNeeded,
pareto, pareto80, histogram, duration, durationMin, byStage, bottleneckIds, pareto, pareto80, histogram, duration, durationMin, byStage, bottleneckIds,
r2DaysEach, r2WeeksEach, r2FteEach, r2OverloadEach, r2DaysPerVac, r2DaysPerWeek,
}; };
} }
@ -340,6 +349,9 @@ function render() {
}, },
]); ]);
renderTwoRecruiterKPI(m);
chartWeeklyDistribution(m.daysPerCycle, state.vacancies);
chartTop10(m.top10); chartTop10(m.top10);
chartPareto(m.pareto, m.pareto80); chartPareto(m.pareto, m.pareto80);
chartHistogram(m.histogram); chartHistogram(m.histogram);
@ -538,22 +550,146 @@ function chartStages(byStage) {
function chartFteScenario(daysPerCycle) { function chartFteScenario(daysPerCycle) {
if (!daysPerCycle) return Plotly.purge('chart-fte-scenario'); 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 MAX_VAC = 20;
const colors = ftePct.map(p => p <= 100 ? '#34d399' : p <= 150 ? '#fbbf24' : '#f87171'); const xs = Array.from({ length: MAX_VAC + 1 }, (_, i) => i);
Plotly.newPlot('chart-fte-scenario', [{ const fteFor = (v, r) => r > 0 ? v * daysPerCycle / DAYS_MONTH / r * 100 : 0;
type: 'bar', x: xs, y: ftePct, marker: { color: colors },
text: xs.map(v => v === state.vacancies ? '← план' : ''), const TEAMS = [1, 2, 3];
textposition: 'outside', const COLORS = ['#f87171', '#fbbf24', '#34d399'];
hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>', const NAMES = ['1 рекрутер', '2 рекрутера', '3 рекрутера'];
}], layout({ const DASHES = ['solid', 'dash', 'dot'];
height: 320, margin: { l: 50, r: 20, t: 20, b: 50 },
xaxis: { title: 'Вакансий в месяц' }, // Cap Y so chart stays readable
yaxis: { title: 'Загрузка FTE, %', ticksuffix: '%' }, const rawMax = fteFor(MAX_VAC, 1);
const capY = Math.min(rawMax, 600);
const traces = TEAMS.map((r, i) => ({
type: 'scatter', mode: 'lines+markers',
name: NAMES[i],
x: xs,
y: xs.map(v => +Math.min(fteFor(v, r), capY + 10).toFixed(1)),
line: { color: COLORS[i], width: 2.5, dash: DASHES[i] },
marker: { size: 5 },
hovertemplate: `<b>${NAMES[i]}</b><br>%{x} вак → %{y:.0f}% FTE<extra></extra>`,
}));
// Capacity — max vacancies at 100% FTE per team size
const capTraces = TEAMS.map((r, i) => {
const capVac = DAYS_MONTH * r / daysPerCycle;
return {
type: 'scatter', mode: 'markers+text',
name: `Макс. ${NAMES[i]}`,
x: [+capVac.toFixed(1)], y: [100],
marker: { symbol: 'diamond', size: 10, color: COLORS[i], line: { color: '#fff', width: 1 } },
text: [`${capVac.toFixed(1)} вак`],
textposition: i === 0 ? 'top right' : i === 1 ? 'top center' : 'top left',
textfont: { color: COLORS[i], size: 10 },
showlegend: false,
hovertemplate: `<b>${NAMES[i]}</b><br>Макс при норме: %{x:.1f} вак/мес<extra></extra>`,
};
});
// Plan FTE annotations for each team size
const planAnnot = TEAMS.map((r, i) => ({
x: state.vacancies,
y: Math.min(fteFor(state.vacancies, r), capY + 10),
text: `${Math.round(fteFor(state.vacancies, r))}%`,
showarrow: true, arrowhead: 2,
ax: 32 - i * 20, ay: -22 - i * 14,
font: { color: COLORS[i], size: 11, weight: 700 },
arrowcolor: COLORS[i],
}));
Plotly.newPlot('chart-fte-scenario', [...traces, ...capTraces], layout({
height: 400,
margin: { l: 58, r: 20, t: 20, b: 70 },
xaxis: { title: 'Вакансий в месяц', range: [0, MAX_VAC], dtick: 2 },
yaxis: { title: 'Загрузка FTE, %', ticksuffix: '%', range: [0, capY + 30] },
shapes: [ shapes: [
{ type: 'line', xref: 'x', yref: 'y', x0: -0.5, x1: 30.5, y0: 100, y1: 100, line: { color: '#f87171', width: 2, dash: 'dash' } }, // цветовые зоны фона
{ type: 'rect', xref: 'paper', yref: 'y', x0: 0, x1: 1, y0: 0, y1: 100, fillcolor: 'rgba(52,211,153,.07)', line: { width: 0 } },
{ type: 'rect', xref: 'paper', yref: 'y', x0: 0, x1: 1, y0: 100, y1: 150, fillcolor: 'rgba(251,191,36,.07)', line: { width: 0 } },
{ type: 'rect', xref: 'paper', yref: 'y', x0: 0, x1: 1, y0: 150, y1: capY + 30, fillcolor: 'rgba(248,113,113,.07)', line: { width: 0 } },
// норма 100%
{ type: 'line', xref: 'paper', yref: 'y', x0: 0, x1: 1, y0: 100, y1: 100, line: { color: '#94a3b8', width: 1.5, dash: 'dash' } },
// 150%
{ type: 'line', xref: 'paper', yref: 'y', x0: 0, x1: 1, y0: 150, y1: 150, line: { color: '#fbbf24', width: 1, dash: 'dot' } },
// вертикаль — текущий план
{ type: 'line', xref: 'x', yref: 'paper', x0: state.vacancies, x1: state.vacancies, y0: 0, y1: 1, line: { color: '#a78bfa', width: 2, dash: 'dash' } },
], ],
annotations: [{ x: 30, y: 100, xanchor: 'right', yanchor: 'bottom', text: 'Норма 100% FTE', showarrow: false, font: { color: '#f87171' } }], annotations: [
{ xref: 'paper', yref: 'y', x: 0.01, y: 100, xanchor: 'left', yanchor: 'bottom', text: '100% норма', showarrow: false, font: { color: '#94a3b8', size: 10 } },
{ xref: 'paper', yref: 'y', x: 0.01, y: 150, xanchor: 'left', yanchor: 'bottom', text: '150% перегруз', showarrow: false, font: { color: '#fbbf24', size: 10 } },
{ xref: 'x', yref: 'paper', x: state.vacancies, y: 1.02, xanchor: 'center', text: `план: ${state.vacancies} вак`, showarrow: false, font: { color: '#a78bfa', size: 10 } },
...planAnnot,
],
legend: { orientation: 'h', y: -0.22 },
}), cfg());
}
function renderTwoRecruiterKPI(m) {
const overEach = m.r2OverloadEach;
renderCards('two-recruiter-kpi', [
{ value: `${m.r2DaysEach.toFixed(1)} дн`, label: 'Дней/мес на 1 рекрутера', sub: `${state.vacancies} вак × ${m.r2DaysPerVac.toFixed(1)} дн` },
{ value: `${m.r2WeeksEach.toFixed(2)} нед`, label: 'Недель/мес на 1 рекрутера', sub: `норма — ${(DAYS_MONTH / DAYS_WEEK).toFixed(1)} нед` },
{ value: `${Math.round(m.r2FteEach)}%`, label: 'Загрузка FTE каждого', sub: 'при равномерном делении' },
{ value: `${m.r2DaysPerWeek.toFixed(1)} дн/нед`, label: 'Нагрузка на 1 рекрутера в неделю', sub: 'норма — 5 дн/нед' },
{
value: overEach >= 0 ? `+${overEach.toFixed(1)} дн` : `${overEach.toFixed(1)} дн`,
label: overEach > 0 ? '⚠ Перегруз при 2 рекрутерах' : '✓ Запас при 2 рекрутерах',
sub: overEach > 0
? `ещё нужно +${(overEach / DAYS_MONTH).toFixed(2)} FTE`
: `свободно ${Math.abs(overEach).toFixed(1)} дн`,
},
]);
}
function chartWeeklyDistribution(daysPerCycle, vacancies) {
const el = 'chart-weekly-split';
if (!daysPerCycle || !vacancies) return Plotly.purge(el);
const WEEKS = 4;
const base = Math.floor(vacancies / WEEKS);
const extra = vacancies % WEEKS;
const vacDist = Array.from({ length: WEEKS }, (_, i) => base + (i < extra ? 1 : 0));
const weeks = ['Неделя 1', 'Неделя 2', 'Неделя 3', 'Неделя 4'];
const r1 = vacDist.map(v => +(v * daysPerCycle / 2).toFixed(2));
const r2 = vacDist.map(v => +(v * daysPerCycle / 2).toFixed(2));
Plotly.newPlot(el, [
{
type: 'bar', name: '👤 Рекрутер 1',
x: weeks, y: r1,
marker: { color: '#60a5fa' },
text: r1.map(d => `${d.toFixed(1)} дн`), textposition: 'outside',
hovertemplate: '<b>%{x}</b><br>Рекрутер 1: %{y:.1f} дн<extra></extra>',
},
{
type: 'bar', name: '👤 Рекрутер 2',
x: weeks, y: r2,
marker: { color: '#34d399' },
text: r2.map(d => `${d.toFixed(1)} дн`), textposition: 'outside',
hovertemplate: '<b>%{x}</b><br>Рекрутер 2: %{y:.1f} дн<extra></extra>',
},
], layout({
height: 340,
barmode: 'group',
margin: { l: 55, r: 20, t: 30, b: 80 },
yaxis: { title: 'Рабочих дней' },
shapes: [{
type: 'line', xref: 'paper', yref: 'y',
x0: 0, x1: 1, y0: DAYS_WEEK, y1: DAYS_WEEK,
line: { color: '#f87171', width: 2, dash: 'dash' },
}],
annotations: [{
x: 1, y: DAYS_WEEK, xref: 'paper', yref: 'y',
xanchor: 'right', yanchor: 'bottom',
text: '5 дн — норма недели', showarrow: false,
font: { color: '#f87171', size: 11 },
}],
legend: { orientation: 'h', y: -0.22 },
}), cfg()); }), cfg());
} }

View File

@ -11,7 +11,7 @@
<body> <body>
<header> <header>
<img src="./assets/logo.svg" alt="telecom.quest" class="logo"> <img src="./assets/logo.svg" alt="telecom.quest" class="logo">
<h1>Дашборд функций рекрутинга</h1> <h1>🚀 Дашборд функций рекрутинга</h1>
<button id="theme-toggle" aria-label="Тема">🌙</button> <button id="theme-toggle" aria-label="Тема">🌙</button>
</header> </header>
@ -26,7 +26,7 @@
<section class="hero"> <section class="hero">
<div class="hero-text"> <div class="hero-text">
<h2>Аналитика рекрутинга</h2> <h2>🔬 Аналитика рекрутинга</h2>
<p>Время функций → KPI, нагрузка, узкие места, симулятор «что если». Без сервера: данные парсятся прямо в браузере.</p> <p>Время функций → KPI, нагрузка, узкие места, симулятор «что если». Без сервера: данные парсятся прямо в браузере.</p>
</div> </div>
<img src="./assets/hero.svg" alt="" class="hero-img" loading="lazy"> <img src="./assets/hero.svg" alt="" class="hero-img" loading="lazy">
@ -34,12 +34,12 @@
<main id="dashboard" hidden> <main id="dashboard" hidden>
<section class="block"> <section class="block">
<h2>Базовые показатели</h2> <h2>🔢 Базовые показатели</h2>
<div id="kpi" class="kpi-grid"></div> <div id="kpi" class="kpi-grid"></div>
</section> </section>
<section class="block"> <section class="block">
<h2>📊 Стратегические KPI</h2> <h2>🏹 Стратегические KPI</h2>
<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>
@ -54,7 +54,7 @@
<section class="block with-mascot"> <section class="block with-mascot">
<img src="./assets/recruiter.svg" alt="" class="mascot" loading="lazy"> <img src="./assets/recruiter.svg" alt="" class="mascot" loading="lazy">
<div class="block-body"> <div class="block-body">
<h2>👤 Нагрузка на сотрудника (дни и недели)</h2> <h2>🧑‍💼 Нагрузка на сотрудника (дни и недели)</h2>
<div id="workload-kpi" class="kpi-grid"></div> <div id="workload-kpi" class="kpi-grid"></div>
</div> </div>
</section> </section>
@ -62,22 +62,30 @@
<section class="block with-mascot mascot-right"> <section class="block with-mascot mascot-right">
<img src="./assets/candidate.svg" alt="" class="mascot" loading="lazy"> <img src="./assets/candidate.svg" alt="" class="mascot" loading="lazy">
<div class="block-body"> <div class="block-body">
<h2>✏️ Ручные правки времени (без изменения xlsx)</h2> <h2>🛠️ Ручные правки времени (без изменения xlsx)</h2>
<p class="hint">Заменить минуты конкретной функции по №. Влияет на все KPI и графики. Пусто = убрать правку.</p> <p class="hint">Заменить минуты конкретной функции по №. Влияет на все KPI и графики. Пусто = убрать правку.</p>
<div class="ovr-form"> <div class="ovr-form">
<label>№ функции <input type="number" id="ovr-num" min="1" placeholder="например 39"></label> <label>№ функции <input type="number" id="ovr-num" min="1" placeholder="например 39"></label>
<label>Время, мин <input type="number" id="ovr-min" min="0" placeholder="например 60"></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-apply">💾 Применить</button>
<button type="button" id="ovr-clear" class="secondary">Очистить все</button> <button type="button" id="ovr-clear" class="secondary">🧹 Очистить все</button>
</div> </div>
<div id="ovr-list" class="ovr-list"></div> <div id="ovr-list" class="ovr-list"></div>
</div> </div>
</section> </section>
<section class="block">
<h2>👥 Распределение на 2 рекрутеров</h2>
<p class="hint">Равномерное распределение текущей нагрузки без изменения данных xlsx.</p>
<div id="two-recruiter-kpi" class="kpi-grid"></div>
<h3 class="section-subhead">📅 Понедельный план месяца</h3>
<div id="chart-weekly-split"></div>
</section>
<section class="block with-mascot"> <section class="block with-mascot">
<img src="./assets/thinker.svg" alt="" class="mascot" loading="lazy"> <img src="./assets/thinker.svg" alt="" class="mascot" loading="lazy">
<div class="block-body"> <div class="block-body">
<h2>🧪 Симулятор «что если»</h2> <h2>🎮 Симулятор «что если»</h2>
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p> <p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
<label class="sim-toggle"><input type="checkbox" id="sim-toggle"> Включить симулятор</label> <label class="sim-toggle"><input type="checkbox" id="sim-toggle"> Включить симулятор</label>
@ -113,48 +121,48 @@
</section> </section>
<section class="block"> <section class="block">
<h2>📈 Сценарий: FTE % vs число вакансий</h2> <h2>📡 Сценарий: FTE % vs число вакансий</h2>
<p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p> <p class="hint">3 линии — 1, 2 и 3 рекрутера. Ромбы ◆ — максимум вакансий при 100% норме. Фиолетовый пунктир — текущий план. Зоны: зелёная ≤100%, жёлтая ≤150%, красная — критично.</p>
<div id="chart-fte-scenario"></div> <div id="chart-fte-scenario"></div>
</section> </section>
<section class="block"> <section class="block">
<h2>Топ-10 самых долгих функций</h2> <h2>⏳ Топ-10 самых долгих функций</h2>
<div id="chart-top10"></div> <div id="chart-top10"></div>
</section> </section>
<section class="block"> <section class="block">
<h2>📉 Pareto: накопительный % времени</h2> <h2>🎯 Pareto: накопительный % времени</h2>
<p class="hint">Какие функции дают 80% всей нагрузки. Жёлтая линия — порог 80%.</p> <p class="hint">Какие функции дают 80% всей нагрузки. Жёлтая линия — порог 80%.</p>
<div id="chart-pareto"></div> <div id="chart-pareto"></div>
</section> </section>
<section class="charts-row"> <section class="charts-row">
<div class="block"> <div class="block">
<h2>📊 Гистограмма длительности</h2> <h2>📶 Гистограмма длительности</h2>
<div id="chart-histogram"></div> <div id="chart-histogram"></div>
</div> </div>
<div class="block"> <div class="block">
<h2>⏱ Категории функций</h2> <h2>🗂️ Категории функций</h2>
<div id="chart-duration"></div> <div id="chart-duration"></div>
</div> </div>
</section> </section>
<section class="block"> <section class="block">
<h2>🧭 Авто-этапы (по ключевым словам в названии)</h2> <h2>🔄 Авто-этапы (по ключевым словам в названии)</h2>
<p class="hint">Эвристика — не правит данные. Привлечение / Скрининг / Интервью / Оффер / Адаптация / Документы-СПП / Коммуникация.</p> <p class="hint">Эвристика — не правит данные. Привлечение / Скрининг / Интервью / Оффер / Адаптация / Документы-СПП / Коммуникация.</p>
<div id="chart-stages"></div> <div id="chart-stages"></div>
</section> </section>
<section class="block"> <section class="block">
<h2>🗺 Treemap: группа → функция (площадь = время)</h2> <h2>🌳 Treemap: группа → функция (площадь = время)</h2>
<div id="chart-treemap"></div> <div id="chart-treemap"></div>
</section> </section>
<section class="block with-mascot mascot-right mascot-wide"> <section class="block with-mascot mascot-right mascot-wide">
<img src="./assets/team.svg" alt="" class="mascot" loading="lazy"> <img src="./assets/team.svg" alt="" class="mascot" loading="lazy">
<div class="block-body"> <div class="block-body">
<h2>Все функции <span class="hint">(клик по заголовку — сортировка)</span></h2> <h2>📑 Все функции <span class="hint">(клик по заголовку — сортировка)</span></h2>
<table id="table"> <table id="table">
<thead><tr> <thead><tr>
<th data-col="num">№</th> <th data-col="num">№</th>

View File

@ -125,6 +125,7 @@ main { padding: 1.5rem 2rem; display: flex; flex-direction: column; gap: 1.25rem
#chart-histogram, #chart-duration { min-height: 300px; } #chart-histogram, #chart-duration { min-height: 300px; }
.hint { color: var(--muted); font-size: .8rem; margin: -.5rem 0 1rem; font-weight: normal; } .hint { color: var(--muted); font-size: .8rem; margin: -.5rem 0 1rem; font-weight: normal; }
.section-subhead { font-size: .95rem; font-weight: 600; margin: 1.5rem 0 .5rem; opacity: .85; letter-spacing: .01em; }
h2 .hint { display: inline; margin-left: .5rem; font-size: .75rem; } 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); }