Compare commits
No commits in common. "285409ba925be37ece75d80943118b99ca419965" and "89011a6e2844c879b643b8dd493fd214c88dd3ca" have entirely different histories.
285409ba92
...
89011a6e28
164
app.js
164
app.js
@ -294,21 +294,12 @@ 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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,9 +340,6 @@ 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);
|
||||||
@ -550,146 +538,22 @@ 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 MAX_VAC = 20;
|
const ftePct = xs.map(v => v * daysPerCycle / DAYS_MONTH * 100);
|
||||||
const xs = Array.from({ length: MAX_VAC + 1 }, (_, i) => i);
|
const colors = ftePct.map(p => p <= 100 ? '#34d399' : p <= 150 ? '#fbbf24' : '#f87171');
|
||||||
const fteFor = (v, r) => r > 0 ? v * daysPerCycle / DAYS_MONTH / r * 100 : 0;
|
Plotly.newPlot('chart-fte-scenario', [{
|
||||||
|
type: 'bar', x: xs, y: ftePct, marker: { color: colors },
|
||||||
const TEAMS = [1, 2, 3];
|
text: xs.map(v => v === state.vacancies ? '← план' : ''),
|
||||||
const COLORS = ['#f87171', '#fbbf24', '#34d399'];
|
textposition: 'outside',
|
||||||
const NAMES = ['1 рекрутер', '2 рекрутера', '3 рекрутера'];
|
hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>',
|
||||||
const DASHES = ['solid', 'dash', 'dot'];
|
}], layout({
|
||||||
|
height: 320, margin: { l: 50, r: 20, t: 20, b: 50 },
|
||||||
// Cap Y so chart stays readable
|
xaxis: { title: 'Вакансий в месяц' },
|
||||||
const rawMax = fteFor(MAX_VAC, 1);
|
yaxis: { title: 'Загрузка FTE, %', ticksuffix: '%' },
|
||||||
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: [
|
annotations: [{ x: 30, y: 100, xanchor: 'right', yanchor: 'bottom', text: 'Норма 100% FTE', showarrow: false, font: { color: '#f87171' } }],
|
||||||
{ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -125,7 +125,6 @@ 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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user