feat: dashboard analytics pack — Pareto, histogram, treemap, duration, auto-stages, FTE scenario, recruiters needed, sortable table, bottleneck highlight
Adds analyses that don't require new data:
- Pareto cumulative chart (which N functions = 80% of time).
- Duration histogram (bucketed counts).
- Function length donut (short / medium / long).
- Auto-stage classifier by keywords in the function name
(Привлечение / Скрининг / Интервью / Оффер / Адаптация /
Документы-СПП / Коммуникация / Прочее) + horizontal bar.
- Treemap: group -> function, area = minutes.
- "FTE scenario" bar chart: 0..30 vacancies/month with color buckets
(green ≤100%, amber ≤150%, red >150%) and a dashed 100% norm line.
- "Recruiters needed" card: ceil(monthlyDays / 21).
- Sortable table columns + Этап column.
- Top-3 bottleneck rows highlighted (red tint + 🔥).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
95fe078387
commit
3006833705
221
app.js
221
app.js
@ -15,12 +15,30 @@ const WORDS = {
|
|||||||
'девять':9,'девяти':9,'десять':10,'десяти':10,
|
'девять':9,'девяти':9,'десять':10,'десяти':10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Авто-классификация по этапам процесса (ключевые слова в названии функции)
|
||||||
|
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 = {
|
const state = {
|
||||||
items: [],
|
items: [],
|
||||||
groupFilter: [],
|
groupFilter: [],
|
||||||
search: '',
|
search: '',
|
||||||
vacancies: 15,
|
vacancies: 15,
|
||||||
salary: 500000,
|
salary: 500000,
|
||||||
|
sort: { col: 'num', dir: 1 }, // 1=asc, -1=desc
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Init ===
|
// === Init ===
|
||||||
@ -33,9 +51,22 @@ addEventListener('DOMContentLoaded', () => {
|
|||||||
$('theme-toggle').onclick = toggleTheme;
|
$('theme-toggle').onclick = toggleTheme;
|
||||||
$('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; render(); };
|
$('inp-vacancies').oninput = e => { state.vacancies = +e.target.value || 0; render(); };
|
||||||
$('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); };
|
$('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); };
|
||||||
|
initTableSort();
|
||||||
autoLoad();
|
autoLoad();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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() {
|
async function autoLoad() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(DEFAULT_XLSX);
|
const res = await fetch(DEFAULT_XLSX);
|
||||||
@ -84,7 +115,8 @@ function parseRows(rows) {
|
|||||||
if (num == null || num === '') { group = String(desc).trim(); continue; }
|
if (num == null || num === '') { group = String(desc).trim(); continue; }
|
||||||
const n = Number(num);
|
const n = Number(num);
|
||||||
if (!Number.isFinite(n)) continue;
|
if (!Number.isFinite(n)) continue;
|
||||||
out.push({ num: n, name: String(desc).trim(), rawTime: time, minutes: parseTime(time), group });
|
const nm = String(desc).trim();
|
||||||
|
out.push({ num: n, name: nm, rawTime: time, minutes: parseTime(time), group, stage: autoStage(nm) });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -138,13 +170,53 @@ function metrics(items) {
|
|||||||
const weeksPerCycle = daysPerCycle / DAYS_WEEK;
|
const weeksPerCycle = daysPerCycle / DAYS_WEEK;
|
||||||
const monthlyDays = state.vacancies * daysPerCycle;
|
const monthlyDays = state.vacancies * daysPerCycle;
|
||||||
const monthlyWeeks = monthlyDays / DAYS_WEEK;
|
const monthlyWeeks = monthlyDays / DAYS_WEEK;
|
||||||
const monthOverload = monthlyDays - DAYS_MONTH; // перегруз сверх месяца, дней
|
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));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total, hours, count: items.length, avg: valid.length ? total / valid.length : 0,
|
total, hours, count: items.length, avg: valid.length ? total / valid.length : 0,
|
||||||
longest, top10, byGroup,
|
longest, top10, byGroup,
|
||||||
fte, capacity, bottleneck, cost,
|
fte, capacity, bottleneck, cost,
|
||||||
daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload,
|
daysPerCycle, weeksPerCycle, monthlyDays, monthlyWeeks, monthOverload, recruitersNeeded,
|
||||||
|
pareto, pareto80, histogram, duration, durationMin, byStage, bottleneckIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,11 +250,22 @@ function render() {
|
|||||||
label: m.monthOverload > 0 ? '⚠ Перегруз сверх месяца' : '✓ Запас в месяце',
|
label: m.monthOverload > 0 ? '⚠ Перегруз сверх месяца' : '✓ Запас в месяце',
|
||||||
sub: m.monthOverload > 0 ? `нужно +${(m.monthOverload/DAYS_MONTH).toFixed(2)} FTE` : `свободно ${Math.abs(m.monthOverload).toFixed(1)} дн`,
|
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} дн ⌉`,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
chartTop10(m.top10);
|
chartTop10(m.top10);
|
||||||
chartGroups(m.byGroup);
|
chartGroups(m.byGroup);
|
||||||
renderTable(items);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCards(id, cards) {
|
function renderCards(id, cards) {
|
||||||
@ -218,15 +301,135 @@ function chartGroups(byGroup) {
|
|||||||
}], layout({ height: 420, showlegend: true, legend: { orientation: 'h', y: -0.1 }, margin: { l: 10, r: 10, t: 10, b: 40 } }), cfg());
|
}], layout({ height: 420, showlegend: true, legend: { orientation: 'h', y: -0.1 }, margin: { l: 10, r: 10, t: 10, b: 40 } }), cfg());
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(items) {
|
function chartPareto(pareto, pareto80) {
|
||||||
document.querySelector('#table tbody').innerHTML = items.map(i => `
|
if (!pareto.length) return Plotly.purge('chart-pareto');
|
||||||
<tr>
|
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 xs = Array.from({ length: 31 }, (_, i) => i); // 0..30 вакансий
|
||||||
|
const ftePct = xs.map(v => v * daysPerCycle / DAYS_MONTH * 100);
|
||||||
|
const colors = ftePct.map(p => p <= 100 ? '#34d399' : p <= 150 ? '#fbbf24' : '#f87171');
|
||||||
|
Plotly.newPlot('chart-fte-scenario', [{
|
||||||
|
type: 'bar', x: xs, y: ftePct, marker: { color: colors },
|
||||||
|
text: xs.map((v, i) => v === state.vacancies ? '← план' : ''),
|
||||||
|
textposition: 'outside',
|
||||||
|
hovertemplate: '<b>%{x} вак/мес</b><br>FTE: %{y:.0f}%<extra></extra>',
|
||||||
|
}], layout({
|
||||||
|
height: 320, margin: { l: 50, r: 20, t: 20, b: 50 },
|
||||||
|
xaxis: { title: 'Вакансий в месяц' },
|
||||||
|
yaxis: { title: 'Загрузка FTE, %', ticksuffix: '%' },
|
||||||
|
shapes: [
|
||||||
|
{ type: 'line', xref: 'x', yref: 'y', x0: -0.5, x1: 30.5, y0: 100, y1: 100, line: { color: '#f87171', width: 2, dash: 'dash' } },
|
||||||
|
],
|
||||||
|
annotations: [{ x: 30, y: 100, xanchor: 'right', yanchor: 'bottom', text: 'Норма 100% FTE', showarrow: false, font: { color: '#f87171' } }],
|
||||||
|
}), 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);
|
||||||
|
return `
|
||||||
|
<tr class="${hot ? 'bottleneck' : ''}">
|
||||||
<td>${i.num}</td>
|
<td>${i.num}</td>
|
||||||
<td>${esc(i.name)}</td>
|
<td>${hot ? '🔥 ' : ''}${esc(i.name)}</td>
|
||||||
<td>${esc(String(i.rawTime ?? '—'))}</td>
|
<td>${esc(String(i.rawTime ?? '—'))}</td>
|
||||||
<td>${i.minutes != null ? Math.round(i.minutes) : '—'}</td>
|
<td>${i.minutes != null ? Math.round(i.minutes) : '—'}</td>
|
||||||
|
<td>${esc(i.stage)}</td>
|
||||||
<td>${esc(trunc(i.group, 60))}</td>
|
<td>${esc(trunc(i.group, 60))}</td>
|
||||||
</tr>`).join('');
|
</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 ===
|
// === Helpers ===
|
||||||
|
|||||||
47
index.html
47
index.html
@ -44,6 +44,12 @@
|
|||||||
<div id="workload-kpi" class="kpi-grid"></div>
|
<div id="workload-kpi" class="kpi-grid"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>📈 Сценарий: FTE % vs число вакансий</h2>
|
||||||
|
<p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p>
|
||||||
|
<div id="chart-fte-scenario"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="charts-row">
|
<section class="charts-row">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h2>Топ-10 самых долгих функций</h2>
|
<h2>Топ-10 самых долгих функций</h2>
|
||||||
@ -56,9 +62,44 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2>Все функции</h2>
|
<h2>📉 Pareto: накопительный % времени</h2>
|
||||||
|
<p class="hint">Какие функции дают 80% всей нагрузки. Жёлтая линия — порог 80%.</p>
|
||||||
|
<div id="chart-pareto"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="charts-row">
|
||||||
|
<div class="block">
|
||||||
|
<h2>📊 Гистограмма длительности</h2>
|
||||||
|
<div id="chart-histogram"></div>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<h2>⏱ Категории функций</h2>
|
||||||
|
<div id="chart-duration"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>🧭 Авто-этапы (по ключевым словам в названии)</h2>
|
||||||
|
<p class="hint">Эвристика — не правит данные. Привлечение / Скрининг / Интервью / Оффер / Адаптация / Документы-СПП / Коммуникация.</p>
|
||||||
|
<div id="chart-stages"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>🗺 Treemap: группа → функция (площадь = время)</h2>
|
||||||
|
<div id="chart-treemap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>Все функции <span class="hint">(клик по заголовку — сортировка)</span></h2>
|
||||||
<table id="table">
|
<table id="table">
|
||||||
<thead><tr><th>№</th><th>Функция</th><th>Исх. время</th><th>Мин</th><th>Группа</th></tr></thead>
|
<thead><tr>
|
||||||
|
<th data-col="num">№</th>
|
||||||
|
<th data-col="name">Функция</th>
|
||||||
|
<th data-col="rawTime">Исх. время</th>
|
||||||
|
<th data-col="minutes">Мин</th>
|
||||||
|
<th data-col="stage">Этап</th>
|
||||||
|
<th data-col="group">Группа</th>
|
||||||
|
</tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@ -66,6 +107,6 @@
|
|||||||
|
|
||||||
<p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
|
<p id="placeholder">Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
|
||||||
|
|
||||||
<script src="./app.js?v=4"></script>
|
<script src="./app.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
12
styles.css
12
styles.css
@ -46,7 +46,17 @@ main { padding: 1.5rem 2rem; display: flex; flex-direction: column; gap: 1.25rem
|
|||||||
.inputs { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
.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; }
|
.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 { min-height: 380px; }
|
#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; }
|
||||||
|
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; }
|
||||||
|
|
||||||
|
#table thead th { user-select: none; }
|
||||||
|
#table thead th:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
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, td { padding: .55rem .7rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; font-size: .88rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user