Compare commits

...

2 Commits

Author SHA1 Message Date
Anuar
285409ba92 Merge remote-tracking branch 'origin/main' 2026-05-22 16:58:19 +05:00
Anuar
f03de26637 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>
2026-05-22 16:46:36 +05:00
2 changed files with 151 additions and 14 deletions

164
app.js
View File

@ -294,12 +294,21 @@ function metrics(items) {
// Bottleneck top-3 num set
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 {
total, hours, count: items.length, avg: valid.length ? total / valid.length : 0,
longest, top10, byGroup,
fte, capacity, bottleneck, cost,
daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload, recruitersNeeded,
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);
chartPareto(m.pareto, m.pareto80);
chartHistogram(m.histogram);
@ -538,22 +550,146 @@ function chartStages(byStage) {
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 => 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: '%' },
const MAX_VAC = 20;
const xs = Array.from({ length: MAX_VAC + 1 }, (_, i) => i);
const fteFor = (v, r) => r > 0 ? v * daysPerCycle / DAYS_MONTH / r * 100 : 0;
const TEAMS = [1, 2, 3];
const COLORS = ['#f87171', '#fbbf24', '#34d399'];
const NAMES = ['1 рекрутер', '2 рекрутера', '3 рекрутера'];
const DASHES = ['solid', 'dash', 'dot'];
// Cap Y so chart stays readable
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: [
{ 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());
}

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; }
.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; }
tr.bottleneck td { background: rgba(248, 113, 113, 0.12); }