Compare commits
No commits in common. "main" and "gitea-pages" have entirely different histories.
main
...
gitea-page
164
app.js
164
app.js
@ -294,21 +294,12 @@ function metrics(items) {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -349,9 +340,6 @@ function render() {
|
||||
},
|
||||
]);
|
||||
|
||||
renderTwoRecruiterKPI(m);
|
||||
chartWeeklyDistribution(m.daysPerCycle, state.vacancies);
|
||||
|
||||
chartTop10(m.top10);
|
||||
chartPareto(m.pareto, m.pareto80);
|
||||
chartHistogram(m.histogram);
|
||||
@ -550,146 +538,22 @@ function chartStages(byStage) {
|
||||
|
||||
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] },
|
||||
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 => 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: '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' } },
|
||||
{ type: 'line', xref: 'x', yref: 'y', x0: -0.5, x1: 30.5, y0: 100, y1: 100, line: { color: '#f87171', 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 },
|
||||
annotations: [{ x: 30, y: 100, xanchor: 'right', yanchor: 'bottom', text: 'Норма 100% FTE', showarrow: false, font: { color: '#f87171' } }],
|
||||
}), cfg());
|
||||
}
|
||||
|
||||
|
||||
735
index.html
735
index.html
@ -1,603 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<!doctype html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
<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>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Дашборд функций рекрутинга — telecom.quest</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>📊 Дашборд Текучести Персонала</h1>
|
||||
<span class="period">Январь — Ноябрь 2025 · 11 месяцев</span>
|
||||
<img src="./assets/logo.svg" alt="telecom.quest" class="logo">
|
||||
<h1>Дашборд функций рекрутинга</h1>
|
||||
<button id="theme-toggle" aria-label="Тема">🌙</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="toolbar">
|
||||
<label class="btn-primary">
|
||||
📁 Загрузить .xlsx
|
||||
<input type="file" id="file-input" accept=".xlsx" hidden>
|
||||
</label>
|
||||
<select id="group-filter" multiple size="3" hidden></select>
|
||||
<input type="search" id="search-input" placeholder="🔍 Поиск функции..." hidden>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<h2>Аналитика рекрутинга</h2>
|
||||
<p>Время функций → KPI, нагрузка, узкие места, симулятор «что если». Без сервера: данные парсятся прямо в браузере.</p>
|
||||
</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>
|
||||
<img src="./assets/hero.svg" alt="" class="hero-img" loading="lazy">
|
||||
</section>
|
||||
|
||||
<main id="dashboard" hidden>
|
||||
<section class="block">
|
||||
<h2>Базовые показатели</h2>
|
||||
<div id="kpi" class="kpi-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>📊 Стратегические KPI</h2>
|
||||
<div class="inputs">
|
||||
<label>Вакансий/мес <input type="number" id="inp-vacancies" value="15" min="0"></label>
|
||||
<label>Ставка ₸/мес <input type="number" id="inp-salary" value="500000" min="0" step="10000"></label>
|
||||
<label title="Время функций, помеченных 🏢, умножается на это число">
|
||||
Структурных подразделений (СП)
|
||||
<input type="number" id="inp-units" value="1" min="1" step="1">
|
||||
</label>
|
||||
</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 id="strategic-kpi" class="kpi-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="block with-mascot">
|
||||
<img src="./assets/recruiter.svg" alt="" class="mascot" loading="lazy">
|
||||
<div class="block-body">
|
||||
<h2>👤 Нагрузка на сотрудника (дни и недели)</h2>
|
||||
<div id="workload-kpi" class="kpi-grid"></div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="block with-mascot mascot-right">
|
||||
<img src="./assets/candidate.svg" alt="" class="mascot" loading="lazy">
|
||||
<div class="block-body">
|
||||
<h2>✏️ Ручные правки времени (без изменения xlsx)</h2>
|
||||
<p class="hint">Заменить минуты конкретной функции по №. Влияет на все KPI и графики. Пусто = убрать правку.</p>
|
||||
<div class="ovr-form">
|
||||
<label>№ функции <input type="number" id="ovr-num" min="1" placeholder="например 39"></label>
|
||||
<label>Время, мин <input type="number" id="ovr-min" min="0" placeholder="например 60"></label>
|
||||
<button type="button" id="ovr-apply">Применить</button>
|
||||
<button type="button" id="ovr-clear" class="secondary">Очистить все</button>
|
||||
</div>
|
||||
<div 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 id="ovr-list" class="ovr-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block with-mascot">
|
||||
<img src="./assets/thinker.svg" alt="" class="mascot" loading="lazy">
|
||||
<div class="block-body">
|
||||
<h2>🧪 Симулятор «что если»</h2>
|
||||
<p class="hint">Не меняет исходные данные. Покажет, как изменятся KPI при заданных условиях.</p>
|
||||
<label class="sim-toggle"><input type="checkbox" id="sim-toggle"> Включить симулятор</label>
|
||||
|
||||
<div id="sim-controls" class="sim-controls" hidden>
|
||||
<div class="sim-row">
|
||||
<label>Оптимизация всех функций: <span id="sim-optimize-val">0%</span>
|
||||
<input type="range" id="sim-optimize" min="0" max="80" step="5" value="0">
|
||||
</label>
|
||||
</div>
|
||||
<div class="sim-row">
|
||||
<label>Исключить топ-N длинных функций: <span id="sim-exclude-top-val">0</span>
|
||||
<input type="range" id="sim-exclude-top" min="0" max="10" step="1" value="0">
|
||||
</label>
|
||||
</div>
|
||||
<div class="sim-row">
|
||||
<label>Исключить этап (авто-классификатор):
|
||||
<select id="sim-stage"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sim-row">
|
||||
<label>Рекрутеров (FTE): <span id="sim-recruiters-val">1</span>
|
||||
<input type="range" id="sim-recruiters" min="1" max="10" step="1" value="1">
|
||||
</label>
|
||||
<label>Вакансий/мес: <span id="sim-vacancies-val">15</span>
|
||||
<input type="range" id="sim-vacancies" min="0" max="50" step="1" value="15">
|
||||
</label>
|
||||
</div>
|
||||
<button id="sim-reset" type="button">↺ Сбросить</button>
|
||||
</div>
|
||||
|
||||
<!-- 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 id="sim-output" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<section class="block">
|
||||
<h2>📈 Сценарий: FTE % vs число вакансий</h2>
|
||||
<p class="hint">Зелёный — норма (≤100%), жёлтый — перегруз ≤150%, красный — критично. Пунктир — 100% FTE.</p>
|
||||
<div id="chart-fte-scenario"></div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Топ-10 самых долгих функций</h2>
|
||||
<div id="chart-top10"></div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>📉 Pareto: накопительный % времени</h2>
|
||||
<p class="hint">Какие функции дают 80% всей нагрузки. Жёлтая линия — порог 80%.</p>
|
||||
<div id="chart-pareto"></div>
|
||||
</section>
|
||||
|
||||
<section class="charts-row">
|
||||
<div class="block">
|
||||
<h2>📊 Гистограмма длительности</h2>
|
||||
<div id="chart-histogram"></div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<h2>⏱ Категории функций</h2>
|
||||
<div id="chart-duration"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>🧭 Авто-этапы (по ключевым словам в названии)</h2>
|
||||
<p class="hint">Эвристика — не правит данные. Привлечение / Скрининг / Интервью / Оффер / Адаптация / Документы-СПП / Коммуникация.</p>
|
||||
<div id="chart-stages"></div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>🗺 Treemap: группа → функция (площадь = время)</h2>
|
||||
<div id="chart-treemap"></div>
|
||||
</section>
|
||||
|
||||
<section class="block with-mascot mascot-right mascot-wide">
|
||||
<img src="./assets/team.svg" alt="" class="mascot" loading="lazy">
|
||||
<div class="block-body">
|
||||
<h2>Все функции <span class="hint">(клик по заголовку — сортировка)</span></h2>
|
||||
<table id="table">
|
||||
<thead><tr>
|
||||
<th data-col="num">№</th>
|
||||
<th data-col="name">Функция</th>
|
||||
<th data-col="rawTime">Исх. время</th>
|
||||
<th data-col="minutes">Мин</th>
|
||||
<th data-col="stage">Этап</th>
|
||||
<th data-col="group">Группа</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
</section>
|
||||
</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 id="placeholder">
|
||||
<img src="./assets/empty.svg" alt="" loading="lazy">
|
||||
<p>Загрузка данных… Если дашборд не появился — нажмите «📁 Загрузить .xlsx».</p>
|
||||
</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>
|
||||
<script src="./app.js?v=11"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -125,7 +125,6 @@ main { padding: 1.5rem 2rem; display: flex; flex-direction: column; gap: 1.25rem
|
||||
#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); }
|
||||
|
||||
Reference in New Issue
Block a user