deploy: gh-pages static dashboard

This commit is contained in:
Anuar 2026-05-22 17:08:08 +05:00
commit 147c3adcfd
12 changed files with 1881 additions and 0 deletions

804
app.js Normal file
View File

@ -0,0 +1,804 @@
// Дашборд функций рекрутинга — клиентское SPA (Excel в браузере)
const HOURS_MONTH = 168; // 21 рабочий день × 8 часов
const MIN_DAY = 480; // 8 × 60
const DAYS_WEEK = 5; // рабочая неделя
const DAYS_MONTH = 21; // рабочих дней в месяце
const WORDS = {
'один':1,'одна':1,'одной':1,'одного':1,
'два':2,'две':2,'двух':2,
'три':3,'трех':3,'трёх':3,
'четыре':4,'четырех':4,'четырёх':4,
'пять':5,'пяти':5,'шесть':6,'шести':6,
'семь':7,'семи':7,'восемь':8,'восьми':8,
'девять':9,'девяти':9,'десять':10,'десяти':10,
};
// Функции, время которых масштабируется по числу структурных подразделений (СП).
// Признак — упоминание СПП/ССП, подразделений, руководителей в названии.
const PER_UNIT_RE = /сп[пс]|подразделен|руководител|комисси|линейн/i;
function isPerUnit(name) { return PER_UNIT_RE.test(String(name || '')); }
// Авто-классификация по этапам процесса (ключевые слова в названии функции)
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,
units: 1,
overrides: {}, // { [num]: minutes } — ручные правки времени без изменения xlsx
sort: { col: 'num', dir: 1 },
sim: {
enabled: false,
optimizePct: 0, // % сокращения времени всех функций
excludeTopN: 0, // исключить N самых долгих функций
excludeStage: '', // исключить целый этап (auto-stage)
recruiters: 1, // кол-во рекрутеров (FTE)
vacancies: 15, // план вакансий в симуляции
},
};
// === Init ===
const DEFAULT_XLSX = './Функции и вес рекрутинг 2026 (2).xlsx';
addEventListener('DOMContentLoaded', () => {
$('file-input').onchange = onFile;
$('group-filter').onchange = e => { state.groupFilter = [...e.target.selectedOptions].map(o => o.value); render(); };
$('search-input').oninput = e => { state.search = e.target.value.toLowerCase().trim(); render(); };
$('theme-toggle').onclick = toggleTheme;
$('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; state.sim.vacancies = state.vacancies; renderSimControls(); render(); };
$('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); };
$('inp-units').oninput = e => { state.units = Math.max(1, +e.target.value || 1); render(); };
initTableSort();
initSimulator();
initOverrides();
autoLoad();
});
function initOverrides() {
$('ovr-apply').onclick = () => {
const num = +$('ovr-num').value;
const min = $('ovr-min').value;
if (!Number.isFinite(num)) return;
if (min === '' || min == null) delete state.overrides[num];
else state.overrides[num] = +min;
renderOverrides();
render();
};
$('ovr-clear').onclick = () => { state.overrides = {}; renderOverrides(); render(); };
}
function renderOverrides() {
const entries = Object.entries(state.overrides);
$('ovr-list').innerHTML = entries.length === 0
? '<span class="hint">Нет правок. Все значения берутся из xlsx.</span>'
: entries.map(([num, min]) => {
const it = state.items.find(i => i.num === +num);
return `<span class="chip">№${num} → ${min} мин <button data-num="${num}" class="chip-x">×</button> ${it ? `<span class="mut">— ${esc(trunc(it.name, 40))}</span>` : ''}</span>`;
}).join('');
$('ovr-list').querySelectorAll('.chip-x').forEach(b => {
b.onclick = () => { delete state.overrides[+b.dataset.num]; renderOverrides(); render(); };
});
}
function initSimulator() {
$('sim-toggle').onchange = e => { state.sim.enabled = e.target.checked; $('sim-controls').hidden = !e.target.checked; render(); };
$('sim-reset').onclick = () => {
state.sim = { enabled: true, optimizePct: 0, excludeTopN: 0, excludeStage: '', recruiters: 1, vacancies: state.vacancies };
renderSimControls();
render();
};
const bind = (id, key, parse = v => +v) => {
$(id).oninput = e => { state.sim[key] = parse(e.target.value); renderSimControls(); render(); };
};
bind('sim-optimize', 'optimizePct');
bind('sim-exclude-top', 'excludeTopN');
bind('sim-recruiters', 'recruiters');
bind('sim-vacancies', 'vacancies');
$('sim-stage').onchange = e => { state.sim.excludeStage = e.target.value; render(); };
}
function renderSimControls() {
$('sim-optimize-val').textContent = `${state.sim.optimizePct}%`;
$('sim-exclude-top-val').textContent = state.sim.excludeTopN;
$('sim-recruiters-val').textContent = state.sim.recruiters;
$('sim-vacancies-val').textContent = state.sim.vacancies;
$('sim-optimize').value = state.sim.optimizePct;
$('sim-exclude-top').value = state.sim.excludeTopN;
$('sim-recruiters').value = state.sim.recruiters;
$('sim-vacancies').value = state.sim.vacancies;
}
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);
if (!res.ok) return;
const buf = await res.arrayBuffer();
loadWorkbook(buf);
} catch (_) { /* fallback: ручная загрузка */ }
}
function loadWorkbook(buf) {
const wb = XLSX.read(buf, { type: 'array' });
console.log('[xlsx] sheets:', wb.SheetNames);
const sheet = wb.SheetNames.find(n => n.toLowerCase().includes('функ')) || wb.SheetNames[0];
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 'A', defval: null });
console.log('[xlsx] rows:', rows.length, 'sample row 2:', rows[2]);
state.items = parseRows(rows);
console.log('[xlsx] parsed items:', state.items.length);
if (!state.items.length) return;
applyDefaultOverrides();
populateGroups();
showDashboard();
renderOverrides();
render();
}
// Дефолтные правки по аномалиям в xlsx (текст «5до 7 дней» парсится как ~2880 мин,
// что нереалистично для разовой задачи контроля табелей).
function applyDefaultOverrides() {
const it = state.items.find(i => i.num === 39);
if (it && /табел/i.test(it.name) && state.overrides[39] == null) {
state.overrides[39] = 60; // «не более 60 мин» по умолчанию, редактируется в UI
}
}
// === Excel ===
async function onFile(e) {
const file = e.target.files[0];
if (!file) return;
try {
const buf = await file.arrayBuffer();
loadWorkbook(buf);
if (!state.items.length) alert('В файле не найдено функций. Проверьте формат (лист «функции»).');
} catch (err) {
alert(`Ошибка чтения файла: ${err.message}`);
}
}
function parseRows(rows) {
const out = [];
let group = 'Без группы';
for (const row of rows) {
if (!row) continue;
const num = row.B;
const desc = row.C;
const time = row.D;
if (!desc || String(desc).trim() === '' || String(desc).trim() === 'Функции:') continue;
if (num == null || num === '') { group = String(desc).trim(); continue; }
const n = Number(num);
if (!Number.isFinite(n)) continue;
const nm = String(desc).trim();
out.push({ num: n, name: nm, rawTime: time, minutes: parseTime(time), group, stage: autoStage(nm) });
}
return out;
}
// === Парсер времени → минуты ===
function parseTime(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'number') return raw;
let s = String(raw).toLowerCase().trim();
if (!s) return null;
if (s.includes('раз в год')) return null;
if (s.includes('в течение дня') && !/\d/.test(s)) return MIN_DAY;
// Заменить числовые слова на цифры (с Unicode-границами, т.к. \b не работает с кириллицей)
for (const [w, n] of Object.entries(WORDS)) {
s = s.replace(new RegExp(`(?<![а-яёa-z0-9])${w}(?![а-яёa-z0-9])`, 'g'), n);
}
const mul = /дн|сутк/.test(s) ? MIN_DAY : /\bчас/.test(s) ? 60 : 1;
const num = x => parseFloat(String(x).replace(',', '.'));
let m;
if (m = s.match(/от\s*(\d+(?:[.,]\d+)?)\s*до\s*(\d+(?:[.,]\d+)?)/)) return (num(m[1]) + num(m[2])) / 2 * mul;
if (m = s.match(/(\d+(?:[.,]\d+)?)\s*[-–—]\s*(\d+(?:[.,]\d+)?)/)) return (num(m[1]) + num(m[2])) / 2 * mul;
if (m = s.match(/до\s*(\d+(?:[.,]\d+)?)/)) return num(m[1]) / 2 * mul;
if (m = s.match(/(\d+(?:[.,]\d+)?)\s*и\s*более/)) return num(m[1]) * mul;
if (m = s.match(/течени[ие]\s*(\d+(?:[.,]\d+)?)/)) return num(m[1]) * MIN_DAY;
if (m = s.match(/(\d+(?:[.,]\d+)?)/)) return num(m[1]) * mul;
return null;
}
// === Метрики ===
function metrics(items) {
const valid = items.filter(i => i.minutes != null);
const total = valid.reduce((a, b) => a + b.minutes, 0);
const longest = valid.reduce((a, b) => !a || b.minutes > a.minutes ? b : a, null);
const top10 = [...valid].sort((a, b) => b.minutes - a.minutes).slice(0, 10);
const byGroup = {};
for (const it of valid) byGroup[it.group] = (byGroup[it.group] || 0) + it.minutes;
// Стратегические
const hours = total / 60;
const fte = state.vacancies > 0 ? state.vacancies * hours / HOURS_MONTH * 100 : 0;
const capacity = hours > 0 ? HOURS_MONTH / hours : 0;
const top3 = top10.slice(0, 3).reduce((a, b) => a + b.minutes, 0);
const bottleneck = total > 0 ? top3 / total * 100 : 0;
const cost = state.salary > 0 ? state.salary * hours / HOURS_MONTH : null;
// Нагрузка в днях/неделях
const daysPerCycle = total / MIN_DAY;
const weeksPerCycle = daysPerCycle / DAYS_WEEK;
const monthlyDays = state.vacancies * daysPerCycle;
const monthlyWeeks = monthlyDays / DAYS_WEEK;
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));
// Распределение на 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,
};
}
// === Render ===
function render() {
const items = filtered();
const m = metrics(items);
renderCards('kpi', [
{ value: `${m.hours.toFixed(1)} ч`, label: 'Сумма времени' },
{ value: m.count, label: 'Количество функций' },
{ value: `${Math.round(m.avg)} мин`, label: 'Средняя длительность' },
{ value: m.longest ? `${Math.round(m.longest.minutes)} мин` : '—', label: 'Самая долгая', sub: m.longest ? trunc(m.longest.name, 50) : '' },
]);
renderCards('strategic-kpi', [
{ value: `${m.hours.toFixed(1)} ч`, label: 'Время на 1 цикл' },
{ value: `${Math.round(m.fte)}%`, label: 'Загрузка FTE' },
{ value: `${m.capacity.toFixed(1)}`, label: 'Capacity, вак/мес' },
{ value: `${Math.round(m.bottleneck)}%`, label: 'Bottleneck (топ-3)' },
{ value: m.cost != null ? `${fmt(Math.round(m.cost))} ₸` : '—', label: 'Стоимость 1 цикла' },
]);
renderCards('workload-kpi', [
{ value: `${m.daysPerCycle.toFixed(1)} дн`, label: 'Нагрузка на 1 цикл', sub: '8-час. рабочий день' },
{ value: `${m.weeksPerCycle.toFixed(2)} нед`, label: 'То же в неделях', sub: '5-дневка' },
{ value: `${m.monthlyDays.toFixed(1)} дн`, label: `За месяц (${state.vacancies} вак.)`, sub: `план ${state.vacancies} × ${m.daysPerCycle.toFixed(1)} дн` },
{ value: `${m.monthlyWeeks.toFixed(2)} нед`, label: 'То же в неделях', sub: `норма — ${(DAYS_MONTH/DAYS_WEEK).toFixed(1)} нед/мес` },
{
value: m.monthOverload >= 0 ? `+${m.monthOverload.toFixed(1)} дн` : `${m.monthOverload.toFixed(1)} дн`,
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} дн ⌉`,
},
]);
renderTwoRecruiterKPI(m);
chartWeeklyDistribution(m.daysPerCycle, state.vacancies);
chartTop10(m.top10);
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);
// Симулятор
populateStageOptions(m.byStage);
renderSim(items, m);
}
function populateStageOptions(byStage) {
const sel = $('sim-stage');
const current = sel.value;
const stages = Object.keys(byStage);
sel.innerHTML = `<option value="">— (не исключать) —</option>` + stages.map(s => `<option value="${esc(s)}">${esc(s)}</option>`).join('');
if (stages.includes(current)) sel.value = current;
}
function simulateItems(items) {
const { optimizePct, excludeTopN, excludeStage } = state.sim;
let arr = items.filter(i => i.minutes != null);
if (excludeStage) arr = arr.filter(i => i.stage !== excludeStage);
if (excludeTopN > 0) {
const sorted = [...arr].sort((a, b) => b.minutes - a.minutes);
const excludeIds = new Set(sorted.slice(0, excludeTopN).map(i => i.num));
arr = arr.filter(i => !excludeIds.has(i.num));
}
const k = Math.max(0, 1 - optimizePct / 100);
return arr.map(i => ({ ...i, minutes: i.minutes * k }));
}
function renderSim(items, base) {
if (!state.sim.enabled) { $('sim-output').hidden = true; return; }
$('sim-output').hidden = false;
const simItems = simulateItems(items);
const simTotal = simItems.reduce((a, b) => a + b.minutes, 0);
const simHours = simTotal / 60;
const simDays = simTotal / MIN_DAY;
const simWeeks = simDays / DAYS_WEEK;
const v = state.sim.vacancies;
const r = Math.max(1, state.sim.recruiters);
const simMonthlyDays = v * simDays;
const simFte = v > 0 ? (v * simHours) / (HOURS_MONTH * r) * 100 : 0;
const simCapacity = simHours > 0 ? (HOURS_MONTH * r) / simHours : 0;
const simOverload = simMonthlyDays - DAYS_MONTH * r;
const simRecruitersNeeded = Math.max(1, Math.ceil(simMonthlyDays / DAYS_MONTH));
const simCost = state.salary > 0 ? state.salary * simHours / HOURS_MONTH * r : null;
const rows = [
['Функций в работе', base.count, simItems.length, x => x],
['Время на 1 цикл, ч', base.hours, simHours, x => x.toFixed(1)],
['Время на 1 цикл, дни', base.daysPerCycle, simDays, x => x.toFixed(2)],
['Время на 1 цикл, недели',base.weeksPerCycle, simWeeks, x => x.toFixed(2)],
['Дней/мес (план)', base.monthlyDays, simMonthlyDays, x => x.toFixed(1)],
['Загрузка FTE, %', base.fte, simFte, x => Math.round(x) + '%'],
['Capacity, вак/мес', base.capacity, simCapacity, x => x.toFixed(1)],
['Перегруз/запас, дней', base.monthOverload, simOverload, x => (x >= 0 ? '+' : '') + x.toFixed(1)],
['Рекрутеров надо', base.recruitersNeeded, simRecruitersNeeded, x => x],
['Стоимость цикла, ₸', base.cost, simCost, x => x != null ? fmt(Math.round(x)) : '—'],
];
$('sim-output').innerHTML = `
<table class="sim-table">
<thead><tr><th>Метрика</th><th>Было</th><th>Стало</th><th>Δ</th></tr></thead>
<tbody>${rows.map(([lbl, was, now, f]) => {
const wasN = typeof was === 'number' ? was : 0;
const nowN = typeof now === 'number' ? now : 0;
const delta = nowN - wasN;
const pct = wasN ? (delta / wasN * 100) : 0;
const better = (lbl.includes('FTE') || lbl.includes('Перегруз') || lbl.includes('Дней') || lbl.includes('Время') || lbl.includes('Стоимость') || lbl.includes('Рекрутеров надо')) ? delta < 0 : delta > 0;
const cls = delta === 0 ? '' : (better ? 'good' : 'bad');
return `<tr>
<td>${lbl}</td>
<td>${was == null ? '—' : f(was)}</td>
<td><b>${now == null ? '—' : f(now)}</b></td>
<td class="${cls}">${delta === 0 || was == null || now == null ? '' : `${delta > 0 ? '+' : ''}${pct.toFixed(0)}%`}</td>
</tr>`;
}).join('')}</tbody>
</table>
<p class="hint">
Сценарий: оптимизация всех функций на <b>${state.sim.optimizePct}%</b>,
исключено топ-<b>${state.sim.excludeTopN}</b>,
исключён этап: <b>${state.sim.excludeStage || '—'}</b>,
рекрутеров: <b>${r}</b>, вакансий/мес: <b>${v}</b>.
</p>
`;
}
function renderCards(id, cards) {
$(id).innerHTML = cards.map(c => `
<div class="kpi-card">
<div class="value">${c.value}</div>
<div class="label">${c.label}</div>
${c.sub ? `<div class="sub">${esc(c.sub)}</div>` : ''}
</div>
`).join('');
}
function chartTop10(top10) {
if (!top10.length) return Plotly.purge('chart-top10');
Plotly.newPlot('chart-top10', [{
type: 'bar', orientation: 'h',
y: top10.map(i => trunc(i.name, 60)).reverse(),
x: top10.map(i => i.minutes).reverse(),
marker: { color: cssVar('--accent') },
hovertemplate: '<b>%{y}</b><br>%{x:.0f} мин<extra></extra>',
}], layout({ height: 420, margin: { l: 260, r: 20, t: 10, b: 40 }, xaxis: { title: 'Минуты' } }), cfg());
}
function chartGroups(byGroup) {
const labels = Object.keys(byGroup);
if (!labels.length) return Plotly.purge('chart-groups');
Plotly.newPlot('chart-groups', [{
type: 'pie',
labels: labels.map(l => trunc(l, 40)),
values: labels.map(l => byGroup[l]),
textinfo: 'percent',
hovertemplate: '<b>%{label}</b><br>%{value:.0f} мин (%{percent})<extra></extra>',
}], layout({ height: 420, showlegend: true, legend: { orientation: 'h', y: -0.1 }, margin: { l: 10, r: 10, t: 10, b: 40 } }), cfg());
}
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 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: '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: [
{ 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());
}
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);
const u = i.perUnit && i.unitsApplied > 1;
const tag = (hot ? '🔥 ' : '') + (i.overridden ? '✏️ ' : '') + (i.perUnit ? `🏢×${i.unitsApplied} ` : '');
const minCell = i.minutes != null
? (u ? `${Math.round(i.minutes)} <span class="mut">(${Math.round(i.baseMinutes)}×${i.unitsApplied})</span>` : Math.round(i.minutes))
: '—';
const rawCell = i.overridden
? `<span class="mut">${esc(String(i.rawTime ?? '—'))} → ${i.baseMinutes} мин</span>`
: esc(String(i.rawTime ?? '—'));
return `
<tr class="${hot ? 'bottleneck' : ''} ${i.perUnit ? 'per-unit' : ''} ${i.overridden ? 'overridden' : ''}">
<td>${i.num}</td>
<td>${tag}${esc(i.name)}</td>
<td>${rawCell}</td>
<td>${minCell}</td>
<td>${esc(i.stage)}</td>
<td>${esc(trunc(i.group, 60))}</td>
</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 ===
function filtered() {
return state.items
.filter(i =>
(!state.groupFilter.length || state.groupFilter.includes(i.group)) &&
(!state.search || i.name.toLowerCase().includes(state.search))
)
.map(i => {
const override = state.overrides[i.num];
const baseMin = (override != null && Number.isFinite(override)) ? override : i.minutes;
const perUnit = isPerUnit(i.name);
const k = perUnit ? state.units : 1;
return {
...i,
perUnit,
unitsApplied: state.units,
overridden: override != null,
minutes: baseMin == null ? null : baseMin * k,
baseMinutes: baseMin,
origMinutes: i.minutes,
};
});
}
function populateGroups() {
const groups = [...new Set(state.items.map(i => i.group))];
const sel = $('group-filter');
sel.innerHTML = groups.map(g => `<option value="${esc(g)}">${esc(trunc(g, 50))}</option>`).join('');
sel.size = Math.min(Math.max(groups.length, 2), 5);
}
function showDashboard() {
$('placeholder').hidden = true;
$('dashboard').hidden = false;
$('group-filter').hidden = false;
$('search-input').hidden = false;
}
function toggleTheme() {
const html = document.documentElement;
const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
html.dataset.theme = next;
$('theme-toggle').textContent = next === 'dark' ? '🌙' : '☀️';
if (state.items.length) render();
}
function layout(extra) {
return Object.assign({
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: cssVar('--fg'), family: '-apple-system, sans-serif' },
colorway: ['#60a5fa','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c','#22d3ee','#f472b6'],
}, extra);
}
function cfg() { return { displayModeBar: false, responsive: true }; }
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function $(id) { return document.getElementById(id); }
function trunc(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function fmt(n) { return new Intl.NumberFormat('ru-RU').format(n); }
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

39
assets/candidate.svg Normal file
View File

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220" width="100%" height="100%">
<defs>
<linearGradient id="cbg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#34d399" stop-opacity=".22"/>
<stop offset="100%" stop-color="#60a5fa" stop-opacity=".05"/>
</linearGradient>
</defs>
<circle cx="110" cy="110" r="100" fill="url(#cbg)"/>
<!-- resume sheet -->
<g transform="translate(140 100) rotate(10)">
<rect x="0" y="0" width="60" height="80" rx="4" fill="white" stroke="#cbd5e1" stroke-width="1.5"/>
<rect x="0" y="0" width="60" height="14" fill="#60a5fa" rx="4"/>
<circle cx="12" cy="22" r="4" fill="#60a5fa"/>
<line x1="20" y1="22" x2="50" y2="22" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="34" x2="52" y2="34" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="42" x2="46" y2="42" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="50" x2="52" y2="50" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="58" x2="40" y2="58" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="66" x2="50" y2="66" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- body -->
<path d="M55 200 Q55 130 95 130 Q135 130 135 200 Z" fill="#34d399"/>
<!-- tie -->
<polygon points="92,138 98,138 98,165 95,180 92,165" fill="#1e3a8a"/>
<!-- head -->
<circle cx="95" cy="90" r="30" fill="#fde68a"/>
<!-- hair -->
<path d="M65 88 Q65 56 95 56 Q125 56 125 88 Q120 68 95 68 Q70 68 65 88" fill="#451a03"/>
<!-- eyes -->
<circle cx="85" cy="92" r="2.5" fill="#1e293b"/>
<circle cx="105" cy="92" r="2.5" fill="#1e293b"/>
<!-- big smile -->
<path d="M82 104 Q95 116 108 104" stroke="#92400e" stroke-width="2.2" fill="none" stroke-linecap="round"/>
<!-- arm holding resume -->
<path d="M125 145 L150 130" stroke="#34d399" stroke-width="14" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

23
assets/empty.svg Normal file
View File

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200" width="300" height="200">
<defs>
<linearGradient id="doc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
</defs>
<ellipse cx="150" cy="180" rx="100" ry="8" fill="#000" opacity="0.06"/>
<g transform="translate(100 30)">
<rect x="0" y="0" width="100" height="130" rx="6" fill="white" stroke="#cbd5e1" stroke-width="1.5"/>
<rect x="0" y="0" width="100" height="22" rx="6" fill="url(#doc)"/>
<rect x="0" y="16" width="100" height="6" fill="url(#doc)"/>
<text x="10" y="15" fill="white" font-family="-apple-system, sans-serif" font-size="10" font-weight="700">.xlsx</text>
<line x1="12" y1="40" x2="88" y2="40" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="55" x2="70" y2="55" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="70" x2="88" y2="70" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="85" x2="60" y2="85" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="100" x2="88" y2="100" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="115" x2="50" y2="115" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>
</g>
<circle cx="225" cy="55" r="20" fill="#34d399"/>
<path d="M215 55 L222 62 L235 49" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

77
assets/hero.svg Normal file
View File

@ -0,0 +1,77 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e3a8a" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#60a5fa" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="funnel1" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
<linearGradient id="funnel2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
<linearGradient id="funnel3" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#34d399"/>
<stop offset="100%" stop-color="#10b981"/>
</linearGradient>
<linearGradient id="funnel4" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#f59e0b"/>
</linearGradient>
<filter id="soft">
<feGaussianBlur stdDeviation="3"/>
</filter>
</defs>
<rect width="800" height="400" fill="url(#bg)" rx="16"/>
<!-- decorative circles -->
<circle cx="700" cy="60" r="80" fill="#60a5fa" opacity="0.08"/>
<circle cx="80" cy="340" r="100" fill="#a78bfa" opacity="0.08"/>
<circle cx="650" cy="350" r="40" fill="#34d399" opacity="0.1"/>
<!-- recruitment funnel -->
<g transform="translate(220 50)">
<!-- stage 1: attract -->
<path d="M0 0 L360 0 L320 60 L40 60 Z" fill="url(#funnel1)" opacity="0.95"/>
<text x="180" y="38" text-anchor="middle" fill="white" font-family="-apple-system, sans-serif" font-size="18" font-weight="700">Привлечение • 100%</text>
<!-- stage 2: screen -->
<path d="M40 70 L320 70 L286 130 L74 130 Z" fill="url(#funnel2)" opacity="0.95"/>
<text x="180" y="108" text-anchor="middle" fill="white" font-family="-apple-system, sans-serif" font-size="16" font-weight="700">Скрининг • 50%</text>
<!-- stage 3: interview -->
<path d="M74 140 L286 140 L252 200 L108 200 Z" fill="url(#funnel3)" opacity="0.95"/>
<text x="180" y="177" text-anchor="middle" fill="white" font-family="-apple-system, sans-serif" font-size="15" font-weight="700">Интервью • 20%</text>
<!-- stage 4: offer -->
<path d="M108 210 L252 210 L218 270 L142 270 Z" fill="url(#funnel4)" opacity="0.95"/>
<text x="180" y="245" text-anchor="middle" fill="white" font-family="-apple-system, sans-serif" font-size="14" font-weight="700">Оффер • 5%</text>
<!-- hire -->
<circle cx="180" cy="305" r="22" fill="#ef4444"/>
<text x="180" y="312" text-anchor="middle" fill="white" font-family="-apple-system, sans-serif" font-size="16" font-weight="800">✓</text>
</g>
<!-- bar chart icon left -->
<g transform="translate(60 130)" opacity="0.85">
<rect x="0" y="80" width="20" height="40" rx="3" fill="#60a5fa"/>
<rect x="28" y="50" width="20" height="70" rx="3" fill="#a78bfa"/>
<rect x="56" y="20" width="20" height="100" rx="3" fill="#34d399"/>
<rect x="84" y="60" width="20" height="60" rx="3" fill="#fbbf24"/>
<rect x="112" y="90" width="20" height="30" rx="3" fill="#f87171"/>
<line x1="-4" y1="125" x2="138" y2="125" stroke="#94a3b8" stroke-width="1.5"/>
</g>
<!-- people icons right -->
<g transform="translate(620 150)" opacity="0.85">
<circle cx="20" cy="20" r="14" fill="#60a5fa"/>
<path d="M0 56 Q20 36 40 56 L40 64 L0 64 Z" fill="#60a5fa"/>
<circle cx="60" cy="20" r="14" fill="#a78bfa"/>
<path d="M40 56 Q60 36 80 56 L80 64 L40 64 Z" fill="#a78bfa"/>
<circle cx="100" cy="20" r="14" fill="#34d399"/>
<path d="M80 56 Q100 36 120 56 L120 64 L80 64 Z" fill="#34d399"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

16
assets/logo.svg Normal file
View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 40" width="180" height="40">
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
<linearGradient id="lg2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#34d399"/>
</linearGradient>
</defs>
<circle cx="20" cy="20" r="16" fill="url(#lg)"/>
<path d="M12 20 L18 26 L29 13" stroke="white" stroke-width="2.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="44" y="18" font-family="-apple-system, system-ui, sans-serif" font-size="14" font-weight="800" fill="currentColor">telecom</text>
<text x="44" y="33" font-family="-apple-system, system-ui, sans-serif" font-size="12" font-weight="600" fill="url(#lg2)">.quest</text>
</svg>

After

Width:  |  Height:  |  Size: 920 B

10
assets/pattern.svg Normal file
View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
<defs>
<pattern id="p" width="60" height="60" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="1.5" fill="#60a5fa" opacity="0.18"/>
<circle cx="40" cy="30" r="1" fill="#a78bfa" opacity="0.18"/>
<circle cx="20" cy="50" r="1.2" fill="#34d399" opacity="0.18"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#p)"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

38
assets/recruiter.svg Normal file
View File

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="100%" height="100%">
<defs>
<linearGradient id="rbg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#a78bfa" stop-opacity=".25"/>
<stop offset="100%" stop-color="#60a5fa" stop-opacity=".05"/>
</linearGradient>
</defs>
<circle cx="120" cy="120" r="110" fill="url(#rbg)"/>
<!-- desk -->
<rect x="40" y="170" width="160" height="14" rx="2" fill="#94a3b8"/>
<rect x="50" y="184" width="6" height="38" fill="#64748b"/>
<rect x="184" y="184" width="6" height="38" fill="#64748b"/>
<!-- laptop -->
<rect x="80" y="140" width="80" height="50" rx="4" fill="#1e293b"/>
<rect x="86" y="146" width="68" height="38" rx="2" fill="#60a5fa"/>
<rect x="92" y="152" width="30" height="3" fill="white" opacity=".8"/>
<rect x="92" y="160" width="40" height="3" fill="white" opacity=".5"/>
<rect x="92" y="168" width="22" height="3" fill="white" opacity=".5"/>
<rect x="70" y="186" width="100" height="6" rx="2" fill="#334155"/>
<!-- person body -->
<path d="M85 150 Q85 110 120 110 Q155 110 155 150" fill="#8b5cf6"/>
<!-- head -->
<circle cx="120" cy="80" r="28" fill="#fde68a"/>
<!-- hair -->
<path d="M92 78 Q92 50 120 50 Q148 50 148 78 Q145 60 120 60 Q95 60 92 78" fill="#451a03"/>
<path d="M92 80 Q90 100 100 105 L100 85 Z" fill="#451a03"/>
<!-- eyes -->
<circle cx="110" cy="80" r="2.5" fill="#1e293b"/>
<circle cx="130" cy="80" r="2.5" fill="#1e293b"/>
<!-- smile -->
<path d="M112 92 Q120 98 128 92" stroke="#92400e" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- arm typing -->
<path d="M95 130 L80 155" stroke="#8b5cf6" stroke-width="12" stroke-linecap="round"/>
<path d="M145 130 L160 155" stroke="#8b5cf6" stroke-width="12" stroke-linecap="round"/>
<!-- speech bubble -->
<circle cx="190" cy="40" r="22" fill="white" stroke="#60a5fa" stroke-width="2"/>
<text x="190" y="46" text-anchor="middle" font-family="-apple-system, sans-serif" font-size="18" fill="#60a5fa">💼</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

47
assets/team.svg Normal file
View File

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200" width="100%" height="100%">
<defs>
<linearGradient id="tbg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#34d399" stop-opacity=".15"/>
<stop offset="100%" stop-color="#60a5fa" stop-opacity=".05"/>
</linearGradient>
</defs>
<rect width="300" height="200" rx="20" fill="url(#tbg)"/>
<!-- person 1 -->
<g transform="translate(40 50)">
<circle cx="30" cy="30" r="22" fill="#fbbf24"/>
<path d="M14 28 Q14 8 30 8 Q46 8 46 28" fill="#1e293b"/>
<circle cx="24" cy="32" r="2" fill="#1e293b"/>
<circle cx="36" cy="32" r="2" fill="#1e293b"/>
<path d="M25 42 Q30 46 35 42" stroke="#92400e" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<path d="M0 110 Q0 70 30 70 Q60 70 60 110 Z" fill="#60a5fa"/>
<rect x="5" y="105" width="50" height="35" fill="#60a5fa"/>
</g>
<!-- person 2 (taller) -->
<g transform="translate(110 30)">
<circle cx="30" cy="30" r="24" fill="#fde68a"/>
<path d="M12 28 Q12 4 30 4 Q48 4 48 28 Q46 14 30 14 Q14 14 12 28" fill="#451a03"/>
<circle cx="23" cy="32" r="2" fill="#1e293b"/>
<circle cx="37" cy="32" r="2" fill="#1e293b"/>
<path d="M24 44 Q30 48 36 44" stroke="#92400e" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<path d="M-4 130 Q-4 80 30 80 Q64 80 64 130 Z" fill="#a78bfa"/>
<rect x="0" y="125" width="60" height="35" fill="#a78bfa"/>
<!-- folder -->
<rect x="48" y="92" width="22" height="28" rx="2" fill="#fbbf24" transform="rotate(8 60 106)"/>
</g>
<!-- person 3 -->
<g transform="translate(195 50)">
<circle cx="30" cy="30" r="22" fill="#fda4af"/>
<path d="M12 30 Q12 6 30 6 Q48 6 48 30 Q40 10 30 10 Q20 10 12 30" fill="#7c2d12"/>
<circle cx="24" cy="32" r="2" fill="#1e293b"/>
<circle cx="36" cy="32" r="2" fill="#1e293b"/>
<path d="M25 42 Q30 46 35 42" stroke="#92400e" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<path d="M0 110 Q0 70 30 70 Q60 70 60 110 Z" fill="#34d399"/>
<rect x="5" y="105" width="50" height="35" fill="#34d399"/>
</g>
<!-- floor shadow -->
<ellipse cx="150" cy="186" rx="120" ry="6" fill="black" opacity=".08"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

41
assets/thinker.svg Normal file
View File

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220" width="100%" height="100%">
<defs>
<radialGradient id="thbg" cx="50%" cy="50%" r="60%">
<stop offset="0%" stop-color="#fbbf24" stop-opacity=".2"/>
<stop offset="100%" stop-color="#f87171" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="220" height="220" fill="url(#thbg)"/>
<!-- thought bubbles -->
<circle cx="155" cy="35" r="6" fill="white" stroke="#94a3b8" stroke-width="1.5"/>
<circle cx="170" cy="55" r="9" fill="white" stroke="#94a3b8" stroke-width="1.5"/>
<circle cx="195" cy="80" r="22" fill="white" stroke="#94a3b8" stroke-width="2"/>
<text x="195" y="88" text-anchor="middle" font-family="-apple-system, sans-serif" font-size="20">💡</text>
<!-- chair -->
<rect x="50" y="170" width="100" height="14" rx="2" fill="#64748b"/>
<rect x="55" y="184" width="6" height="30" fill="#64748b"/>
<rect x="139" y="184" width="6" height="30" fill="#64748b"/>
<!-- body -->
<path d="M65 170 Q65 110 105 110 Q145 110 145 170 Z" fill="#3b82f6"/>
<!-- legs -->
<rect x="78" y="160" width="14" height="40" rx="4" fill="#1e293b"/>
<rect x="118" y="160" width="14" height="40" rx="4" fill="#1e293b"/>
<!-- head -->
<circle cx="105" cy="85" r="28" fill="#fde68a"/>
<!-- hair -->
<path d="M77 85 Q77 55 105 55 Q133 55 133 85 Q128 65 105 65 Q82 65 77 85" fill="#1e293b"/>
<!-- eyes -->
<circle cx="95" cy="86" r="2.5" fill="#1e293b"/>
<circle cx="115" cy="86" r="2.5" fill="#1e293b"/>
<!-- thinking mouth -->
<path d="M97 100 L113 100" stroke="#92400e" stroke-width="2" stroke-linecap="round"/>
<!-- chin hand -->
<path d="M115 105 L130 95" stroke="#fde68a" stroke-width="10" stroke-linecap="round"/>
<circle cx="130" cy="95" r="7" fill="#fde68a"/>
<!-- arm -->
<path d="M132 110 L150 90" stroke="#3b82f6" stroke-width="14" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

603
index.html Normal file
View File

@ -0,0 +1,603 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Дашборд Текучести Персонала 2025</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--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>
<body>
<header>
<h1>📊 Дашборд Текучести Персонала</h1>
<span class="period">Январь — Ноябрь 2025 · 11 месяцев</span>
</header>
<main>
<!-- KPI -->
<div class="kpi-row">
<div class="kpi kpi-total">
<span class="kpi-icon">👥</span>
<span class="kpi-label">Всего уволено</span>
<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 -->
<div class="section-title">Динамика по месяцам</div>
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--navy)"></span>Количество увольнений по месяцам · 2025</div>
<div class="chart-wrap"><canvas id="chartMonthly"></canvas></div>
</div>
<!-- Row 2: Reasons + Departments -->
<div class="section-title">Причины увольнений и направления</div>
<div class="grid-2">
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--red)"></span>Причины увольнений</div>
<table>
<thead><tr><th>#</th><th>Причина</th><th>Кол-во</th><th>Доля</th></tr></thead>
<tbody id="tbReasons"></tbody>
</table>
</div>
<div class="card">
<div class="card-title"><span class="dot" style="background:var(--teal)"></span>По направлениям</div>
<div class="chart-wrap"><canvas id="chartDept"></canvas></div>
</div>
</div>
<!-- Row 3: Region + Gender + Grade -->
<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>
</html>

183
styles.css Normal file
View File

@ -0,0 +1,183 @@
:root {
--bg: #fff;
--surface: #f8fafc;
--card: #f1f5f9;
--fg: #0f172a;
--muted: #64748b;
--accent: #2563eb;
--border: #e2e8f0;
}
[data-theme="dark"] {
--bg: #0b1220;
--surface: #0f172a;
--card: #1e293b;
--fg: #f1f5f9;
--muted: #94a3b8;
--accent: #60a5fa;
--border: #1e293b;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-image: url("./assets/pattern.svg");
background-attachment: fixed;
}
[hidden] { display: none !important; }
header {
display: flex; gap: 1rem; align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, var(--surface) 0%, rgba(96,165,250,0.08) 100%);
backdrop-filter: blur(12px);
position: sticky; top: 0; z-index: 10;
}
.logo { height: 40px; filter: drop-shadow(0 2px 6px rgba(96,165,250,0.35)); }
header h1 {
flex: 1; font-size: 1.2rem; margin: 0; font-weight: 700;
background: linear-gradient(90deg, var(--fg), var(--accent));
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero {
display: grid; grid-template-columns: 1.1fr 1fr; align-items: center;
gap: 2rem; padding: 1.5rem 2rem;
background: linear-gradient(135deg, rgba(96,165,250,0.12) 0%, rgba(167,139,250,0.10) 100%);
border-bottom: 1px solid var(--border);
}
.hero-text h2 {
margin: 0 0 .6rem;
font-size: 1.7rem; font-weight: 800; line-height: 1.1;
background: linear-gradient(90deg, var(--accent), #a78bfa);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero-text p { margin: 0; color: var(--muted); font-size: 1rem; max-width: 540px; }
.hero-img { width: 100%; max-width: 540px; justify-self: end; display: block; }
#theme-toggle { background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: .4rem .7rem; font-size: 1.1rem; cursor: pointer; }
.toolbar { display: flex; gap: 1rem; padding: 1rem 2rem; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
.btn-primary { background: var(--accent); color: #fff; padding: .6rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 500; }
.btn-primary:hover { opacity: .9; }
select, input[type="search"], input[type="number"] { background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: .45rem .6rem; font: inherit; }
input[type="search"] { flex: 1; min-width: 200px; }
main { padding: 1.5rem 2rem; display: flex; flex-direction: column; gap: 1.25rem; }
.block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.3rem 1.6rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
transition: box-shadow .2s;
}
.block:hover { box-shadow: 0 4px 18px rgba(96,165,250,0.10); }
.block.with-mascot { display: grid; grid-template-columns: 130px 1fr; gap: 1.2rem; align-items: start; }
.block.with-mascot.mascot-right { grid-template-columns: 1fr 130px; }
.block.with-mascot.mascot-right .mascot { order: 2; }
.block.with-mascot.mascot-wide { grid-template-columns: 1fr 180px; }
.block .mascot { width: 100%; max-width: 150px; align-self: center; opacity: .95; transition: transform .25s ease; }
.block .mascot:hover { transform: scale(1.06) rotate(-2deg); }
.block .block-body { min-width: 0; }
@media (max-width: 760px) {
.block.with-mascot, .block.with-mascot.mascot-right, .block.with-mascot.mascot-wide { grid-template-columns: 1fr; }
.block.with-mascot .mascot { max-width: 110px; justify-self: end; order: 0 !important; }
}
.block h2 {
margin: 0 0 1rem;
font-size: 1.05rem; font-weight: 700;
display: flex; align-items: center; gap: .4rem;
}
.charts-row { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .85rem; }
.kpi-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.1rem;
position: relative; overflow: hidden;
transition: transform .15s, box-shadow .15s;
}
.kpi-card::before {
content: '';
position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: linear-gradient(180deg, var(--accent), #a78bfa);
}
.kpi-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(96,165,250,0.18); }
.kpi-card .value {
font-size: 1.7rem; font-weight: 800; line-height: 1.15;
background: linear-gradient(90deg, var(--accent), #a78bfa);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.kpi-card .label { font-size: .75rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin-top: .3rem; font-weight: 600; }
.kpi-card .sub { font-size: .75rem; color: var(--muted); margin-top: .3rem; }
.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, #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; }
.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); }
tr.bottleneck td:nth-child(2) { font-weight: 600; }
tr.per-unit td:nth-child(2) { color: var(--accent); }
tr.overridden td { background: rgba(167, 139, 250, 0.10); }
tr.overridden td:nth-child(3) { font-style: italic; }
.mut { color: var(--muted); font-weight: normal; font-size: .8em; }
.ovr-form { display: flex; gap: .8rem; flex-wrap: wrap; align-items: flex-end; margin-bottom: 1rem; }
.ovr-form label { display: flex; flex-direction: column; gap: .3rem; font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.ovr-form input { background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: .5rem .65rem; font: inherit; min-width: 140px; }
.ovr-form button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: .55rem 1rem; font: inherit; font-weight: 600; cursor: pointer; }
.ovr-form button:hover { opacity: .9; }
.ovr-form button.secondary { background: var(--card); color: var(--fg); border: 1px solid var(--border); }
.ovr-list { display: flex; flex-wrap: wrap; gap: .5rem; }
.chip { display: inline-flex; align-items: center; gap: .35rem; background: rgba(167,139,250,0.15); border: 1px solid rgba(167,139,250,0.4); color: var(--fg); padding: .3rem .65rem; border-radius: 16px; font-size: .85rem; }
.chip-x { background: transparent; border: 0; color: var(--fg); cursor: pointer; font-size: 1rem; padding: 0 .15rem; opacity: .7; }
.chip-x:hover { opacity: 1; color: #f87171; }
#table thead th { user-select: none; }
#table thead th:hover { background: var(--accent); color: #fff; }
.sim-toggle { display: inline-flex; gap: .5rem; align-items: center; cursor: pointer; margin-bottom: 1rem; font-weight: 500; }
.sim-controls { display: flex; flex-direction: column; gap: .9rem; padding: 1rem; background: var(--card); border-radius: 8px; margin-bottom: 1rem; }
.sim-row { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.sim-row label { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: .35rem; font-size: .85rem; color: var(--muted); }
.sim-row input[type=range] { width: 100%; accent-color: var(--accent); }
.sim-row span { color: var(--accent); font-weight: 700; font-size: 1rem; }
#sim-reset { background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: .45rem .9rem; cursor: pointer; align-self: flex-start; }
#sim-reset:hover { background: var(--accent); color: #fff; }
.sim-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.sim-table th, .sim-table td { padding: .55rem .7rem; border-bottom: 1px solid var(--border); text-align: right; }
.sim-table th:first-child, .sim-table td:first-child { text-align: left; }
.sim-table td.good { color: #34d399; font-weight: 600; }
.sim-table td.bad { color: #f87171; font-weight: 600; }
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; }
th { background: var(--card); font-weight: 600; position: sticky; top: 0; }
td:nth-child(1), td:nth-child(4) { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td:nth-child(5) { color: var(--muted); font-size: .8rem; max-width: 200px; }
tbody tr:hover { background: var(--card); }
#placeholder { text-align: center; color: var(--muted); padding: 3rem 2rem; }
#placeholder img { max-width: 280px; opacity: .9; margin-bottom: 1rem; }
#placeholder p { margin: 0; }
@media (max-width: 900px) {
.charts-row { grid-template-columns: 1fr; }
header, .toolbar, main, .hero { padding-left: 1rem; padding-right: 1rem; }
.hero { grid-template-columns: 1fr; text-align: center; }
.hero-img { max-width: 360px; justify-self: center; }
}