deploy: gh-pages static dashboard
804
app.js
Normal 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
39
assets/candidate.svg
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 | Всего записей: 705 | 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
@ -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; }
|
||||||
|
}
|
||||||