Compare commits

..

No commits in common. "main" and "gitea-pages" have entirely different histories.

3 changed files with 182 additions and 742 deletions

164
app.js
View File

@ -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());
} }

View File

@ -1,603 +1,180 @@
<!DOCTYPE html> <!doctype html>
<html lang="ru"> <html lang="ru" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Дашборд Текучести Персонала 2025</title> <title>Дашборд функций рекрутинга — telecom.quest</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <link rel="stylesheet" href="./styles.css">
<style> <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
:root { <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
--navy: #1B2A4A;
--navy2: #243358;
--red: #C0392B;
--white: #FFFFFF;
--bg: #F0F4F9;
--card: #FFFFFF;
--text: #1E293B;
--muted: #64748B;
--border: #E2E8F0;
--teal: #0D9488;
--purple: #7C3AED;
--orange: #D97706;
--green: #059669;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ── Header ── */
header {
background: var(--navy);
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 4px solid var(--red);
box-shadow: 0 2px 16px rgba(0,0,0,.25);
}
header h1 {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: .5px;
}
header .period {
color: #94A8CC;
font-size: 13px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.12);
padding: 5px 14px;
border-radius: 20px;
}
/* ── Layout ── */
main { padding: 28px 32px 40px; max-width: 1440px; margin: 0 auto; }
/* ── KPI Strip ── */
.kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.kpi {
border-radius: 12px;
padding: 20px 18px 16px;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,.10);
transition: transform .15s, box-shadow .15s;
cursor: default;
}
.kpi:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,.15); }
.kpi::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: rgba(255,255,255,.35);
border-radius: 12px 12px 0 0;
}
.kpi-label { font-size: 10px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; opacity: .75; color: #fff; }
.kpi-value { font-size: 36px; font-weight: 800; color: #fff; line-height: 1.1; }
.kpi-sub { font-size: 12px; color: rgba(255,255,255,.65); margin-top: 2px; }
.kpi-icon { position: absolute; right: 14px; top: 14px; font-size: 28px; opacity: .2; }
.kpi-total { background: linear-gradient(135deg, #1B2A4A, #2D4070); }
.kpi-vol { background: linear-gradient(135deg, #B83232, #E74C3C); }
.kpi-agr { background: linear-gradient(135deg, #243358, #1A5276); }
.kpi-dept { background: linear-gradient(135deg, #065F46, #059669); }
.kpi-peak { background: linear-gradient(135deg, #6C3483, #8E44AD); }
/* ── Section ── */
.section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--muted);
margin: 28px 0 14px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ── Grid ── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-3 { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 20px; }
.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; }
.col-span2{ grid-column: span 2; }
/* ── Card ── */
.card {
background: var(--card);
border-radius: 14px;
padding: 22px 24px 18px;
box-shadow: 0 1px 6px rgba(0,0,0,.07);
border: 1px solid var(--border);
transition: box-shadow .15s;
}
.card:hover { box-shadow: 0 4px 18px rgba(0,0,0,.10); }
.card-title {
font-size: 13px;
font-weight: 700;
color: var(--navy);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title .dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
}
.chart-wrap { position: relative; }
.chart-wrap canvas { max-height: 280px; }
/* ── Table ── */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: var(--navy);
color: #fff;
padding: 9px 12px;
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: .5px;
}
thead th:first-child { border-radius: 8px 0 0 0; }
thead th:last-child { border-radius: 0 8px 0 0; }
tbody tr { border-bottom: 1px solid var(--border); transition: background .1s; }
tbody tr:hover { background: #F8FAFF; }
tbody td { padding: 9px 12px; color: var(--text); }
tbody tr:last-child td:first-child { border-radius: 0 0 0 8px; }
tbody tr:last-child td:last-child { border-radius: 0 0 8px 0; }
/* Progress bar in table */
.bar-wrap { display: flex; align-items: center; gap: 8px; }
.bar-track { flex: 1; height: 7px; background: var(--border); border-radius: 4px; min-width: 60px; }
.bar-fill { height: 7px; border-radius: 4px; }
.bar-pct { font-size: 11px; color: var(--muted); width: 38px; text-align: right; }
/* Badge */
.badge {
display: inline-block;
padding: 2px 9px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.badge-blue { background: #EEF2FF; color: #3730A3; }
.badge-red { background: #FEF2F2; color: #B91C1C; }
.badge-green { background: #F0FDF4; color: #15803D; }
.badge-grey { background: #F1F5F9; color: #475569; }
/* ── Gender donut label ── */
.gender-wrap { display: flex; flex-direction: column; gap: 12px; }
.gender-legend { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.gl-item { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.gl-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.gl-label { color: var(--text); flex: 1; }
.gl-val { font-weight: 700; color: var(--navy); }
.gl-pct { color: var(--muted); font-size: 12px; }
/* ── Footer ── */
footer {
text-align: center;
padding: 20px;
color: var(--muted);
font-size: 12px;
border-top: 1px solid var(--border);
margin-top: 36px;
}
/* ── Responsive ── */
@media (max-width: 1100px) {
.kpi-row { grid-template-columns: repeat(3,1fr); }
.grid-3 { grid-template-columns: 1fr 1fr; }
.grid-4 { grid-template-columns: repeat(2,1fr); }
.col-span2{ grid-column: span 1; }
}
@media (max-width: 700px) {
main { padding: 16px; }
.kpi-row { grid-template-columns: repeat(2,1fr); }
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
header h1 { font-size: 14px; }
}
</style>
</head> </head>
<body> <body>
<header>
<img src="./assets/logo.svg" alt="telecom.quest" class="logo">
<h1>Дашборд функций рекрутинга</h1>
<button id="theme-toggle" aria-label="Тема">🌙</button>
</header>
<header> <section class="toolbar">
<h1>📊 Дашборд Текучести Персонала</h1> <label class="btn-primary">
<span class="period">Январь — Ноябрь 2025 · 11 месяцев</span> 📁 Загрузить .xlsx
</header> <input type="file" id="file-input" accept=".xlsx" hidden>
</label>
<select id="group-filter" multiple size="3" hidden></select>
<input type="search" id="search-input" placeholder="🔍 Поиск функции..." hidden>
</section>
<main> <section class="hero">
<div class="hero-text">
<h2>Аналитика рекрутинга</h2>
<p>Время функций → KPI, нагрузка, узкие места, симулятор «что если». Без сервера: данные парсятся прямо в браузере.</p>
</div>
<img src="./assets/hero.svg" alt="" class="hero-img" loading="lazy">
</section>
<!-- KPI --> <main id="dashboard" hidden>
<div class="kpi-row"> <section class="block">
<div class="kpi kpi-total"> <h2>Базовые показатели</h2>
<span class="kpi-icon">👥</span> <div id="kpi" class="kpi-grid"></div>
<span class="kpi-label">Всего уволено</span> </section>
<span class="kpi-value">705</span>
<span class="kpi-sub">за 11 месяцев 2025</span>
</div>
<div class="kpi kpi-vol">
<span class="kpi-icon">🚪</span>
<span class="kpi-label">По собств. желанию</span>
<span class="kpi-value">348</span>
<span class="kpi-sub">49% от общего</span>
</div>
<div class="kpi kpi-agr">
<span class="kpi-icon">🤝</span>
<span class="kpi-label">По соглашению сторон</span>
<span class="kpi-value">302</span>
<span class="kpi-sub">43% от общего</span>
</div>
<div class="kpi kpi-dept">
<span class="kpi-icon">🏢</span>
<span class="kpi-label">Топ направление</span>
<span class="kpi-value">ПРОДАЖИ</span>
<span class="kpi-sub">343 чел · 49%</span>
</div>
<div class="kpi kpi-peak">
<span class="kpi-icon">📈</span>
<span class="kpi-label">Пиковый месяц</span>
<span class="kpi-value">Сен</span>
<span class="kpi-sub">86 увольнений</span>
</div>
</div>
<!-- Row 1: Monthly dynamics --> <section class="block">
<div class="section-title">Динамика по месяцам</div> <h2>📊 Стратегические KPI</h2>
<div class="card"> <div class="inputs">
<div class="card-title"><span class="dot" style="background:var(--navy)"></span>Количество увольнений по месяцам · 2025</div> <label>Вакансий/мес <input type="number" id="inp-vacancies" value="15" min="0"></label>
<div class="chart-wrap"><canvas id="chartMonthly"></canvas></div> <label>Ставка ₸/мес <input type="number" id="inp-salary" value="500000" min="0" step="10000"></label>
</div> <label title="Время функций, помеченных 🏢, умножается на это число">
Структурных подразделений (СП)
<input type="number" id="inp-units" value="1" min="1" step="1">
</label>
</div>
<div id="strategic-kpi" class="kpi-grid"></div>
</section>
<!-- Row 2: Reasons + Departments --> <section class="block with-mascot">
<div class="section-title">Причины увольнений и направления</div> <img src="./assets/recruiter.svg" alt="" class="mascot" loading="lazy">
<div class="grid-2"> <div class="block-body">
<div class="card"> <h2>👤 Нагрузка на сотрудника (дни и недели)</h2>
<div class="card-title"><span class="dot" style="background:var(--red)"></span>Причины увольнений</div> <div id="workload-kpi" class="kpi-grid"></div>
<table> </div>
<thead><tr><th>#</th><th>Причина</th><th>Кол-во</th><th>Доля</th></tr></thead> </section>
<tbody id="tbReasons"></tbody>
<section class="block with-mascot mascot-right">
<img src="./assets/candidate.svg" alt="" class="mascot" loading="lazy">
<div class="block-body">
<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>
</div>
</section>
<section class="block with-mascot">
<img src="./assets/thinker.svg" alt="" class="mascot" loading="lazy">
<div class="block-body">
<h2>🧪 Симулятор «что если»</h2>
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
<label class="sim-toggle"><input type="checkbox" id="sim-toggle"> Включить симулятор</label>
<div id="sim-controls" class="sim-controls" hidden>
<div class="sim-row">
<label>Оптимизация всех функций: <span id="sim-optimize-val">0%</span>
<input type="range" id="sim-optimize" min="0" max="80" step="5" value="0">
</label>
</div>
<div class="sim-row">
<label>Исключить топ-N длинных функций: <span id="sim-exclude-top-val">0</span>
<input type="range" id="sim-exclude-top" min="0" max="10" step="1" value="0">
</label>
</div>
<div class="sim-row">
<label>Исключить этап (авто-классификатор):
<select id="sim-stage"></select>
</label>
</div>
<div class="sim-row">
<label>Рекрутеров (FTE): <span id="sim-recruiters-val">1</span>
<input type="range" id="sim-recruiters" min="1" max="10" step="1" value="1">
</label>
<label>Вакансий/мес: <span id="sim-vacancies-val">15</span>
<input type="range" id="sim-vacancies" min="0" max="50" step="1" value="15">
</label>
</div>
<button id="sim-reset" type="button">↺ Сбросить</button>
</div>
<div id="sim-output" hidden></div>
</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="block">
<h2>Топ-10 самых долгих функций</h2>
<div id="chart-top10"></div>
</section>
<section class="block">
<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 with-mascot mascot-right mascot-wide">
<img src="./assets/team.svg" alt="" class="mascot" loading="lazy">
<div class="block-body">
<h2>Все функции <span class="hint">(клик по заголовку — сортировка)</span></h2>
<table id="table">
<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> </table>
</div> </div>
<div class="card"> </section>
<div class="card-title"><span class="dot" style="background:var(--teal)"></span>По направлениям</div> </main>
<div class="chart-wrap"><canvas id="chartDept"></canvas></div>
</div> <div id="placeholder">
<img src="./assets/empty.svg" alt="" loading="lazy">
<p>Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
</div> </div>
<!-- Row 3: Region + Gender + Grade --> <script src="./app.js?v=11"></script>
<div class="section-title">Регионы · Гендер · Грейды</div>
<div class="grid-3">
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--orange)"></span>По регионам</div>
<div class="chart-wrap"><canvas id="chartRegion"></canvas></div>
</div>
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--purple)"></span>Гендерный состав</div>
<div class="gender-wrap">
<div class="chart-wrap"><canvas id="chartGender" style="max-height:180px"></canvas></div>
<div class="gender-legend">
<div class="gl-item">
<span class="gl-dot" style="background:#1B2A4A"></span>
<span class="gl-label">Женщины</span>
<span class="gl-val">363</span>
<span class="gl-pct">51.5%</span>
</div>
<div class="gl-item">
<span class="gl-dot" style="background:#C0392B"></span>
<span class="gl-label">Мужчины</span>
<span class="gl-val">341</span>
<span class="gl-pct">48.4%</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--green)"></span>По грейдам (топ-8)</div>
<div class="chart-wrap"><canvas id="chartGrade"></canvas></div>
</div>
</div>
<!-- Row 4: Top positions -->
<div class="section-title">Топ должностей по увольнениям</div>
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--purple)"></span>Топ-10 должностей</div>
<table>
<thead><tr><th>#</th><th>Должность</th><th>Кол-во</th><th>Доля от общего</th></tr></thead>
<tbody id="tbPositions"></tbody>
</table>
</div>
</main>
<footer>Данные: Текучесть персонала за 11 месяцев 2025 &nbsp;|&nbsp; Всего записей: 705 &nbsp;|&nbsp; 01.01.2025 – 29.11.2025</footer>
<script>
// ── Data ────────────────────────────────────────────────────────────────────
const DATA = {
monthly: {
labels: ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя'],
values: [55, 57, 46, 71, 65, 72, 77, 75, 86, 67, 33]
},
reasons: [
{ label: 'По собственному желанию', count: 348 },
{ label: 'По соглашению сторон', count: 302 },
{ label: 'Уход на пенсию', count: 27 },
{ label: 'Преждевременный уход на пенсию', count: 12 },
{ label: 'Истечение срока договора', count: 8 },
{ label: 'Смерть сотрудника', count: 3 },
{ label: 'Нарушение труд. дисципл.', count: 2 },
{ label: 'Перевод в филиал / Сокращение', count: 2 },
],
depts: {
labels: ['ПРОДАЖИ','РиПС','Customer\nservice','ФИНАНСЫ','МАРКЕТИНГ','ЕХ','РУКОВОДСТВО'],
values: [343, 169, 138, 40, 12, 2, 1]
},
regions: {
labels: ['АЛМАТЫ','ЮГ','ЦЕНТР','ЗАПАД','ВОСТОК','СЕВЕР'],
values: [535, 60, 57, 23, 21, 9]
},
grades: {
labels: ['B1','A10','A9','B2','B3','B4','B5','B6'],
values: [207, 191, 119, 108, 28, 16, 13, 10]
},
gender: { labels: ['Женщины','Мужчины'], values: [363, 341] },
positions: [
'Специалист по обслуживанию',
'Хантер',
'Инсталлятор',
'Техник по работе с абонентами',
'Специалист',
'Вед. специалист по обслуживанию',
'Электромонтер Р4',
'Электромонтер Р7',
'Инженер',
'Главный специалист',
],
positionCounts: [193, 102, 77, 58, 49, 28, 21, 18, 14, 11]
};
const TOTAL = 705;
const NAVY = '#1B2A4A';
const RED = '#C0392B';
const TEAL = '#0D9488';
const ORANGE = '#D97706';
const PURPLE = '#7C3AED';
const GREEN = '#059669';
const BLUE = '#2563EB';
Chart.defaults.font.family = "'Segoe UI', Arial, sans-serif";
Chart.defaults.color = '#475569';
// ── Helpers ─────────────────────────────────────────────────────────────────
function pct(n) { return (n / TOTAL * 100).toFixed(1) + '%'; }
function makeBar(el, labels, values, color, opts = {}) {
return new Chart(el, {
type: 'bar',
data: {
labels,
datasets: [{ data: values, backgroundColor: color,
borderRadius: opts.horizontal ? 4 : 6,
borderSkipped: false }]
},
options: {
indexAxis: opts.horizontal ? 'y' : 'x',
responsive: true, maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: { callbacks: {
label: ctx => ` ${ctx.parsed[opts.horizontal ? 'x' : 'y']} чел. (${pct(ctx.parsed[opts.horizontal ? 'x' : 'y'])})`
}}
},
scales: {
x: { grid: { color: '#E2E8F0' }, ticks: { font: { size: 11 } } },
y: { grid: { color: opts.horizontal ? '#E2E8F0' : 'transparent' },
ticks: { font: { size: 11 } } }
}
}
});
}
// ── Monthly chart ────────────────────────────────────────────────────────────
new Chart(document.getElementById('chartMonthly'), {
type: 'bar',
data: {
labels: DATA.monthly.labels,
datasets: [{
label: 'Уволено',
data: DATA.monthly.values,
backgroundColor: DATA.monthly.values.map((v, i) =>
v === Math.max(...DATA.monthly.values) ? RED : NAVY),
borderRadius: 6,
borderSkipped: false,
}, {
type: 'line',
label: 'Тренд',
data: DATA.monthly.values,
borderColor: ORANGE,
backgroundColor: 'transparent',
pointBackgroundColor: ORANGE,
pointRadius: 4,
tension: 0.35,
borderWidth: 2,
borderDash: [6, 4],
pointHoverRadius: 6,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { boxWidth: 14, font: { size: 12 } } },
tooltip: { callbacks: {
label: ctx => ` ${ctx.parsed.y} чел.`
}}
},
scales: {
x: { grid: { display: false } },
y: { grid: { color: '#E2E8F0' },
ticks: { font: { size: 11 } } }
}
}
});
document.querySelector('#chartMonthly').parentElement.style.height = '260px';
// ── Dept chart ────────────────────────────────────────────────────────────
makeBar(
document.getElementById('chartDept'),
DATA.depts.labels,
DATA.depts.values,
[NAVY, TEAL, BLUE, ORANGE, PURPLE, RED, GREEN],
{ horizontal: true }
);
// ── Region chart ────────────────────────────────────────────────────────────
new Chart(document.getElementById('chartRegion'), {
type: 'bar',
data: {
labels: DATA.regions.labels,
datasets: [{
data: DATA.regions.values,
backgroundColor: [NAVY, TEAL, BLUE, ORANGE, PURPLE, RED],
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: { callbacks: {
label: ctx => ` ${ctx.parsed.y} чел. (${pct(ctx.parsed.y)})`
}}
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 11 } } },
y: { grid: { color: '#E2E8F0' }, ticks: { font: { size: 11 } } }
}
}
});
// ── Gender donut ─────────────────────────────────────────────────────────────
new Chart(document.getElementById('chartGender'), {
type: 'doughnut',
data: {
labels: DATA.gender.labels,
datasets: [{
data: DATA.gender.values,
backgroundColor: [NAVY, RED],
borderWidth: 3,
borderColor: '#fff',
hoverOffset: 8
}]
},
options: {
responsive: true, maintainAspectRatio: true,
cutout: '60%',
plugins: {
legend: { display: false },
tooltip: { callbacks: {
label: ctx => ` ${ctx.parsed} чел. (${pct(ctx.parsed)})`
}}
}
}
});
// ── Grade chart ────────────────────────────────────────────────────────────
new Chart(document.getElementById('chartGrade'), {
type: 'bar',
data: {
labels: DATA.grades.labels,
datasets: [{
data: DATA.grades.values,
backgroundColor: [NAVY,RED,TEAL,BLUE,ORANGE,PURPLE,GREEN,'#94A3B8'],
borderRadius: 5,
borderSkipped: false,
}]
},
options: {
indexAxis: 'y',
responsive: true, maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: { callbacks: {
label: ctx => ` ${ctx.parsed.x} чел. (${pct(ctx.parsed.x)})`
}}
},
scales: {
x: { grid: { color: '#E2E8F0' }, ticks: { font: { size: 11 } } },
y: { grid: { display: false }, ticks: { font: { size: 12 } } }
}
}
});
// ── Reasons table ────────────────────────────────────────────────────────────
const reasonColors = [RED, NAVY, TEAL, ORANGE, BLUE, '#6B7280', '#6B7280', '#6B7280'];
const tbody = document.getElementById('tbReasons');
DATA.reasons.forEach((r, i) => {
const p = (r.count / TOTAL * 100).toFixed(1);
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="color:var(--muted);font-weight:700;width:28px">${i+1}</td>
<td>${r.label}</td>
<td style="font-weight:700;color:${reasonColors[i]};white-space:nowrap">${r.count}</td>
<td>
<div class="bar-wrap">
<div class="bar-track">
<div class="bar-fill" style="width:${p}%;background:${reasonColors[i]}"></div>
</div>
<span class="bar-pct">${p}%</span>
</div>
</td>`;
tbody.appendChild(tr);
});
// ── Positions table ───────────────────────────────────────────────────────────
const posColors = [RED, NAVY, TEAL, ORANGE, BLUE, PURPLE, GREEN, '#0891B2', '#B45309', '#6B7280'];
const tbody2 = document.getElementById('tbPositions');
DATA.positions.forEach((pos, i) => {
const cnt = DATA.positionCounts[i];
const p = (cnt / TOTAL * 100).toFixed(1);
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="color:var(--muted);font-weight:700;width:28px">${i+1}</td>
<td>${pos}</td>
<td style="font-weight:700;color:${posColors[i]};white-space:nowrap">${cnt}</td>
<td>
<div class="bar-wrap">
<div class="bar-track">
<div class="bar-fill" style="width:${(p/27.4*100)}%;background:${posColors[i]}"></div>
</div>
<span class="bar-pct">${p}%</span>
</div>
</td>`;
tbody2.appendChild(tr);
});
</script>
</body> </body>
</html> </html>

View File

@ -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); }