feat: дашборд функций рекрутинга (MVP)

- index.html / styles.css / app.js — клиентский SPA на SheetJS + Plotly
- Парсит .xlsx в браузере, без бэкенда
- Базовые KPI: сумма времени, кол-во функций, среднее, самая долгая
- Стратегические KPI: цикл, FTE-загрузка, capacity, bottleneck, стоимость
- Топ-10 функций, распределение по группам, таблица с фильтрами
- Тёмная/светлая тема, кириллический парсер времени
- assets/logo.svg — плейсхолдер логотипа telecom.quest
- PRD.md / TRD.md обновлены под Gitea Pages деплой

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Askarova A 2026-05-22 11:35:51 +05:00
parent d668b75595
commit f97af2b749
7 changed files with 1237 additions and 0 deletions

2
.gitignore vendored
View File

@ -25,6 +25,8 @@ target/
*.sqlite*
*.db
*.log
*.xlsx
*.xls
# OS / IDE
.DS_Store

165
PRD.md
View File

@ -0,0 +1,165 @@
# PRD — Веб-дашборд функций рекрутинга
## 1. Цель
Превратить Excel-файл с функциями рекрутера и временем их выполнения в наглядный веб-дашборд на одном экране. Дашборд показывает суммарную нагрузку, топ самых долгих функций и распределение по этапам процесса рекрутинга.
## 2. Пользователи
- **Основной:** HR-руководитель / рекрутер (Askarova A).
- **Сценарий:** загрузить актуальный Excel → сразу увидеть метрики → использовать для анализа нагрузки и планирования.
## 3. Источник данных
**Файл:** `Функции и вес рекрутинг 2026 (2).xlsx`
**Лист:** `функции`
**Структура (колонки):**
| № колонки | Содержание |
|-----------|------------|
| B | Номер функции (1–45) |
| C | Описание функции |
| D | Время выполнения (число в минутах ИЛИ текст: «до 60», «от 3 до 7 дней», «течение трех дней», «два раза в год») |
**Группы функций:** определяются **автоматически** по заголовку в Excel (строка с эмодзи 🔍 и описанием этапа, без номера в колонке B). Парсер идёт сверху вниз: встретил строку-заголовок → последующие пронумерованные функции принадлежат этой группе до следующего заголовка.
Пример из текущего файла: заголовок «🔍 Работа с вакансиями и источниками. Конкурсные процедуры. Подготовка - offer. Адаптация.» → все 45 функций в одной группе. Если в будущих версиях файла будут промежуточные заголовки — группы выделятся сами.
## 4. Парсинг времени
Поле «Время» содержит смешанные значения. Правила приведения к минутам:
| Тип значения | Пример | Правило |
|--------------|--------|---------|
| Число | `30`, `60` | Брать как минуты |
| `до N` | `до 60` | Среднее от 0 до N → N/2 |
| `от N до M` | `от 3 до 7 дней` | Среднее (N+M)/2 |
| `N и более` | `15 и более` | Брать N |
| `в течение N дней` / `течение N дней` | `течение трех дней` | N × 8 часов × 60 мин (рабочий день) |
| `в течение дня` | `в течение дня` | 8 × 60 = 480 мин |
| `два раза в год` | — | Исключить из суточной нагрузки, считать отдельно |
| Пусто / нераспознанное | — | Исключить, логировать |
Числовые слова («трех», «пяти») → перевести в цифры (3, 5).
Единицы:
- «мин» / число без единицы → минуты
- «дней» → минуты (N × 480)
## 5. Метрики на дашборде
### 5.1. KPI-карточки (сверху)
- **Сумма времени всех функций** (часы)
- **Количество функций** (всего)
- **Средняя длительность** одной функции (мин)
- **Самая долгая функция** (название + время)
### 5.1.bis. Стратегические KPI
Блок «Стратегические KPI» — расчёты второго уровня на основе функций + двух вводных полей пользователя:
- **Вакансий в месяц** (число, по умолчанию 15)
- **Ставка рекрутера, ₸/мес** (число, по умолчанию 500000, опционально)
Метрики:
| KPI | Формула | Смысл |
|-----|---------|-------|
| **Время на 1 цикл подбора** (часы) | Σ времени всех функций / 60 | Сколько часов нужно на полный цикл закрытия одной вакансии |
| **Загрузка FTE** (%) | (Вакансий × часов_на_цикл) / 168 × 100 | Какая доля 1 ставки рекрутера занята при текущем объёме |
| **Capacity** (вакансий/мес на 1 FTE) | 168 / часов_на_цикл | Сколько вакансий 1 рекрутер физически может закрыть |
| **Bottleneck-доля** (%) | Время топ-3 функций / Σ × 100 | Концентрация нагрузки. >40% = есть «узкое горлышко» |
| **Распределение по этапам** (%) | Σ времени группы / Σ всего × 100 | Куда уходит время: поиск / интервью / оффер / адаптация |
| **Стоимость 1 цикла** (₸) | ставка × (часов_на_цикл / 168) | Себестоимость закрытия одной вакансии |
| **Стратегический vs операционный** (%) | Кастомные теги функций (auto или ручные) | Сколько % уходит на рутину, сколько на ценностные действия |
Визуализация: 4–6 KPI-карточек отдельным блоком + горизонтальный stacked bar «распределение по этапам».
### 5.2. Топ-10 самых долгих функций
Горизонтальный бар-чарт: название функции (укороченное) × время в минутах. Сортировка по убыванию.
### 5.3. Распределение по группам
Пай-чарт или бар: суммарное время по 4–5 группам процесса.
### 5.4. Таблица всех функций
Полный список с колонками: №, Функция, Исходное время, Минут (норм.), Группа. С возможностью сортировки.
## 6. Интерактив
- **Кнопка «Загрузить .xlsx»** — пользователь загружает свой файл.
- **Фильтр по группе** — мультиселект, фильтрует все графики и таблицу.
- **Поиск по тексту функции** — поле ввода, фильтрует таблицу.
- **Параметры для стратегических KPI** (в сайдбаре или сверху):
- Поле «Вакансий в месяц» (число)
- Поле «Ставка рекрутера, ₸/мес» (число, опц.)
- Все стратегические KPI пересчитываются на лету при изменении.
## 7. Технический стек
**Целевой хостинг — Gitea Pages** (только статика). Поэтому стек полностью клиентский, без бэкенда.
- **Frontend:** чистый HTML + CSS + Vanilla JS (без билд-шага) ИЛИ Vite + ванильный JS (если нужен модуль-импорт).
- **Парсинг Excel в браузере:** [SheetJS / xlsx.js](https://cdn.sheetjs.com/) через CDN.
- **Графики:** [Plotly.js](https://cdn.plot.ly/) (интерактив, тёмная тема из коробки) или Chart.js.
- **Тёмная тема:** CSS-переменные + класс `data-theme="dark|light"` на `<html>`.
- **Зависимости:** только через CDN (никаких `npm install` на сервере).
**Структура репо (ветка `gitea-pages`):**
```
index.html
styles.css
app.js
assets/
logo.png ← брендинг telecom.quest
```
**Деплой:**
1. Разработать в ветке `main`.
2. Скопировать готовые файлы в ветку `gitea-pages` (или собирать туда из `main`).
3. Добавить топик репо `gitea-pages-allowall` (см. GITEA.md §4).
4. Push → автодеплой → `https://<логин>.telecom.quest/aa/`.
**Альтернатива (если нужен бэк / Python):** Streamlit в Docker + Traefik (см. GITEA.md §🐳). Отклонено для MVP — Pages проще и бесплатно.
## 8. Нефункциональные требования
- Запуск локально: открыть `index.html` в браузере (или `python3 -m http.server` для CORS).
- Прод: Gitea Pages, URL `https://<логин>.telecom.quest/aa/`.
- Загрузка файла ≤ 1 МБ обрабатывается ≤ 2 сек (парсинг в браузере).
- Данные **не покидают браузер** — всё парсится на клиенте, никаких загрузок на сервер.
- Работает в Chrome / Safari / Firefox.
- Кириллица в данных и интерфейсе.
- Без авторизации (MVP — личное использование).
- **Брендинг telecom.quest:** логотип в шапке дашборда, фирменные цвета (если есть guideline — использовать; иначе нейтральная палитра + место под логотип-плейсхолдер `assets/logo.png`).
- **Тёмная тема:** переключатель «светлая / тёмная» в сайдбаре. Тёмная — по умолчанию.
## 9. Out of scope (MVP)
- Авторизация / роли.
- Сохранение истории загрузок.
- Множество дашбордов / вкладок.
- Авто-подтягивание файла из облака.
- Редактирование данных через UI.
- Экспорт в PDF.
## 10. Критерии приёмки
1. Открытие `index.html` локально показывает дашборд.
2. Push в ветку `gitea-pages` → сайт доступен по `https://<логин>.telecom.quest/aa/`.
3. Загрузка `Функции и вес рекрутинг 2026 (2).xlsx` через кнопку UI отображает все 45 функций.
4. KPI-карточки показывают корректную сумму времени, кол-во функций, среднее, максимум.
5. Топ-10 бар-чарт отрисован, отсортирован по убыванию.
6. Пай-чарт распределения по группам отрисован.
7. Фильтр по группе работает (графики и таблица обновляются).
8. Текстовые значения времени («до 60», «от 3 до 7 дней») корректно приведены к минутам по правилам §4.
9. Переключатель тёмная/светлая тема работает; тёмная — по умолчанию.
10. Логотип telecom.quest виден в шапке.
11. Блок «Стратегические KPI» отображает: время на 1 цикл, FTE-загрузку, capacity, bottleneck-долю, распределение по этапам, стоимость цикла.
12. Изменение полей «Вакансий в месяц» и «Ставка ₸/мес» пересчитывает стратегические KPI без перезагрузки.
## 11. Открытые вопросы
Все решено:
- Группы — **авто** из заголовков Excel.
- Брендинг telecom.quest — **да** (логотип + цвета).
- Тёмный режим — **да** (по умолчанию, переключатель в сайдбаре).

696
TRD.md
View File

@ -0,0 +1,696 @@
# TRD — Веб-дашборд функций рекрутинга
Технический документ реализации. Основан на [PRD.md](PRD.md) и [GITEA.md](GITEA.md).
---
## 1. Архитектура
Полностью клиентское SPA. Без сервера, без сборки, без npm. Деплой на Gitea Pages (только статика).
```
┌─────────────────────────────────────────────┐
│ Браузер пользователя │
│ ┌─────────────────────────────────────────┐ │
│ │ index.html │ │
│ │ ├─ styles.css (темы, layout) │ │
│ │ ├─ app.js (логика, графики) │ │
│ │ └─ <script CDN> │ │
│ │ ├─ SheetJS (парсинг .xlsx) │ │
│ │ └─ Plotly.js (графики) │ │
│ └─────────────────────────────────────────┘ │
│ ↑ │
│ │ FileReader API │
│ Пользователь грузит .xlsx локально │
└─────────────────────────────────────────────┘
│
│ git push origin gitea-pages
↓
┌─────────────────────────────────────────────┐
│ Gitea Pages │
│ https://<логин>.telecom.quest/aa/ │
└─────────────────────────────────────────────┘
```
Данные **не уходят** на сервер — всё парсится в браузере через FileReader → SheetJS.
---
## 2. Структура репозитория
Ветка `main` — разработка. Ветка `gitea-pages` — деплой (та же структура, без README/PRD/TRD).
```
aa/
├── index.html # точка входа
├── styles.css # темы, layout, компоненты
├── app.js # вся логика приложения
├── assets/
│ └── logo.png # логотип telecom.quest (плейсхолдер на старте)
├── sample/
│ └── функции.xlsx # демо-файл для теста (опционально)
├── README.md
├── PRD.md
├── TRD.md
└── GITEA.md
```
В `gitea-pages` копируется только: `index.html`, `styles.css`, `app.js`, `assets/`.
---
## 3. Зависимости (CDN)
В `<head>` index.html:
```html
<!-- Парсинг Excel -->
<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>
```
Версии зафиксированы для воспроизводимости. Никаких билд-инструментов, package.json не нужен.
---
## 4. Парсер Excel → массив объектов
### 4.1. Чтение файла
```js
function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const wb = XLSX.read(e.target.result, { type: 'array' });
resolve(wb);
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
```
### 4.2. Извлечение строк листа `функции`
```js
function extractRows(wb) {
const ws = wb.Sheets['функции'] || wb.Sheets[wb.SheetNames[0]];
// header:1 → массив массивов (по колонкам A,B,C,D,E,F)
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: null });
}
```
Используем индексы колонок (0-based):
- `row[1]` → номер функции (B)
- `row[2]` → описание (C) — может быть заголовком группы (с эмодзи 🔍 и без номера)
- `row[3]` → время (D)
### 4.3. Группировка по заголовкам
Идём сверху вниз. Если `row[1]` пустой и `row[2]` непустой → это заголовок группы. Все последующие пронумерованные функции до следующего заголовка относятся к этой группе.
```js
function parseRows(rows) {
const result = [];
let currentGroup = 'Без группы';
for (const row of rows) {
const num = row[1];
const desc = row[2];
const time = row[3];
if (!desc) continue; // пустая строка
if (num == null || num === '') { // заголовок группы
currentGroup = String(desc).trim();
continue;
}
if (desc === 'Функции:') continue; // подзаголовок
result.push({
num: Number(num),
name: String(desc).trim(),
rawTime: time,
minutes: parseTime(time),
group: currentGroup,
});
}
return result;
}
```
---
## 5. Парсер времени → минуты
Поле `rawTime` смешанное: число или строка. Алгоритм:
```js
const WORD_TO_NUM = {
'один': 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 MIN_PER_WORKDAY = 8 * 60; // 480
function wordToNum(s) {
const k = s.toLowerCase().trim();
return WORD_TO_NUM[k] ?? null;
}
function parseTime(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'number') return raw; // уже минуты
let s = String(raw).toLowerCase().trim();
// Спец-кейсы
if (s.includes('два раза в год')) return null; // исключаем из суточной
if (s.includes('в течение дня') || s === 'в течение дня') return MIN_PER_WORKDAY;
// Заменяем числовые слова на цифры
for (const [w, n] of Object.entries(WORD_TO_NUM)) {
s = s.replace(new RegExp(`\\b${w}\\b`, 'g'), n);
}
const hasDays = /дн|сутк/.test(s);
const unitMul = hasDays ? MIN_PER_WORKDAY : 1;
// от N до M
let m = s.match(/от\s*(\d+)\s*до\s*(\d+)/);
if (m) return ((+m[1] + +m[2]) / 2) * unitMul;
// N-M (например "15-20")
m = s.match(/(\d+)\s*-\s*(\d+)/);
if (m) return ((+m[1] + +m[2]) / 2) * unitMul;
// до N
m = s.match(/до\s*(\d+)/);
if (m) return (+m[1] / 2) * unitMul;
// N и более
m = s.match(/(\d+)\s*и\s*более/);
if (m) return +m[1] * unitMul;
// в течение N (дней) / течение N
m = s.match(/течени[ие]\s*(\d+)/);
if (m) return +m[1] * MIN_PER_WORKDAY;
// просто число с днями: "3 дня"
m = s.match(/(\d+)/);
if (m) return +m[1] * unitMul;
return null;
}
```
Возврат `null` → запись попадает в таблицу, но **исключается** из агрегатов (сумма, среднее, топ).
---
## 6. Метрики (агрегаты)
### 6.1. Базовые метрики
```js
function computeMetrics(items) {
const valid = items.filter(i => i.minutes != null);
const totalMin = valid.reduce((a, b) => a + b.minutes, 0);
const count = items.length;
const avgMin = valid.length ? totalMin / valid.length : 0;
const longest = valid.reduce((a, b) => (a.minutes > b.minutes ? a : b), valid[0]);
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;
}
return { totalMin, totalHours: totalMin / 60, count, avgMin, longest, top10, byGroup };
}
```
### 6.2. Стратегические KPI
Зависят от пользовательских вводных:
- `vacanciesPerMonth` — план по вакансиям (число)
- `recruiterSalary` — ставка ₸/мес (число, опц.)
Константа: `WORK_HOURS_PER_MONTH = 168` (21 рабочий день × 8 ч).
```js
const WORK_HOURS_PER_MONTH = 168;
function computeStrategicKpi(metrics, inputs) {
const { totalMin, byGroup } = metrics;
const { vacanciesPerMonth, recruiterSalary } = inputs;
const hoursPerCycle = totalMin / 60;
// Загрузка FTE: какая доля 1 ставки занята при текущем плане
const fteLoad = vacanciesPerMonth > 0
? (vacanciesPerMonth * hoursPerCycle) / WORK_HOURS_PER_MONTH * 100
: 0;
// Capacity: сколько циклов 1 FTE физически осилит за месяц
const capacity = hoursPerCycle > 0 ? WORK_HOURS_PER_MONTH / hoursPerCycle : 0;
// Bottleneck: суммарная доля топ-3 функций
const sorted = [...metrics.top10].slice(0, 3);
const top3Min = sorted.reduce((a, b) => a + b.minutes, 0);
const bottleneckShare = totalMin > 0 ? (top3Min / totalMin) * 100 : 0;
// Распределение по этапам (группам), %
const stageShare = {};
for (const [grp, min] of Object.entries(byGroup)) {
stageShare[grp] = totalMin > 0 ? (min / totalMin) * 100 : 0;
}
// Стоимость 1 цикла (₸)
const costPerCycle = recruiterSalary > 0
? recruiterSalary * (hoursPerCycle / WORK_HOURS_PER_MONTH)
: null;
return {
hoursPerCycle,
fteLoad,
capacity,
bottleneckShare,
stageShare,
costPerCycle,
top3: sorted,
};
}
```
### 6.3. Формат вывода
| Поле | Единица | Округление |
|------|---------|------------|
| `hoursPerCycle` | часы | 1 знак (`42.3 ч`) |
| `fteLoad` | % | целое (`87%`) |
| `capacity` | вакансий/мес | 1 знак (`4.0`) |
| `bottleneckShare` | % | целое (`38%`) |
| `costPerCycle` | ₸ | целое, разделитель тысяч (`125 000 ₸`) |
---
## 7. UI / Layout
Один экран, секции сверху вниз:
```
┌───────────────────────────────────────────────────────┐
│ [logo] Дашборд функций рекрутинга [🌙/☀️ тема] │ ← Header
├───────────────────────────────────────────────────────┤
│ [📁 Загрузить .xlsx] Фильтр группы: [▼ multi] │ ← Toolbar
│ Поиск: [_____________] │
├───────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Σ часов │ │ К-во ф. │ │ Среднее │ │ Самая │ │ ← Базовые KPI
│ │ 142 ч │ │ 45 │ │ 47 мин │ │ долгая │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├───────────────────────────────────────────────────────┤
│ 📊 Стратегические KPI │
│ Вакансий/мес: [15] Ставка ₸/мес: [500000] │ ← Inputs
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Цикл, ч │ │ FTE, % │ │ Capacity │ │ Bottlen.│ │ ← Стратегич. KPI
│ │ 42.3 │ │ 127% │ │ 4.0/мес │ │ 38% │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Стоимость 1 цикла: 125 000 ₸ │ │
│ └─────────────────────────────────────────────────┘ │
│ Распределение по этапам: │
│ ▓▓▓▓▓▓ Поиск 35% ▓▓▓▓ Интервью 25% ▓▓ Оффер 15% … │ ← Stacked bar
├───────────────────────────────────────────────────────┤
│ Топ-10 функций по времени Группы (пай) │
│ ┌─────────────────────────┐ ┌───────────────────┐ │
│ │ ▓▓▓▓▓▓▓▓▓▓▓ Функция X │ │ ◐◑ │ │ ← Charts
│ │ ▓▓▓▓▓▓▓▓▓▓ Функция Y │ │ ◑ ◓ ◒ │ │
│ │ ... │ │ │ │
│ └─────────────────────────┘ └───────────────────┘ │
├───────────────────────────────────────────────────────┤
│ Все функции (таблица) │
│ │ № │ Функция │ Исх. время │ Мин │ Группа │ │
│ │... │ │
└───────────────────────────────────────────────────────┘
```
Сетка: CSS Grid. KPI — 4 колонки. Графики — 2 колонки (1fr 1fr), на мобайле — 1 колонка.
---
## 8. HTML-каркас (index.html)
```html
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Дашборд функций рекрутинга</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 class="header">
<img src="./assets/logo.png" alt="telecom.quest" class="logo">
<h1>Дашборд функций рекрутинга</h1>
<button id="theme-toggle" aria-label="Переключить тему">🌙</button>
</header>
<section class="toolbar">
<label class="upload">
📁 Загрузить .xlsx
<input type="file" id="file-input" accept=".xlsx" hidden>
</label>
<select id="group-filter" multiple></select>
<input type="search" id="search-input" placeholder="Поиск функции...">
</section>
<section id="kpi" class="kpi-grid"></section>
<section class="strategic">
<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>
</div>
<div id="strategic-kpi" class="kpi-grid"></div>
<div id="chart-stages"></div>
</section>
<section class="charts">
<div id="chart-top10"></div>
<div id="chart-groups"></div>
</section>
<section class="table-wrap">
<table id="table"></table>
</section>
<script src="./app.js"></script>
</body>
</html>
```
---
## 9. CSS / темы (styles.css, набросок)
```css
:root {
--bg: #ffffff;
--fg: #111827;
--card: #f3f4f6;
--accent: #2563eb; /* фирменный telecom.quest */
--border: #e5e7eb;
}
[data-theme="dark"] {
--bg: #0f172a;
--fg: #f1f5f9;
--card: #1e293b;
--accent: #60a5fa;
--border: #334155;
}
body { background: var(--bg); color: var(--fg); font-family: system-ui, sans-serif; margin: 0; }
.header { display: flex; align-items: center; gap: 1rem; padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
.logo { height: 32px; }
.header h1 { flex: 1; font-size: 1.25rem; margin: 0; }
.toolbar { display: flex; gap: 1rem; padding: 1rem 2rem; align-items: center; flex-wrap: wrap; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; padding: 0 2rem; }
.kpi-card { background: var(--card); border-radius: 8px; padding: 1rem; }
.kpi-card .value { font-size: 1.75rem; font-weight: 700; color: var(--accent); }
.kpi-card .label { font-size: 0.85rem; opacity: 0.7; }
.charts { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; padding: 1rem 2rem; }
.table-wrap { padding: 1rem 2rem; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.5rem; border-bottom: 1px solid var(--border); text-align: left; }
@media (max-width: 768px) {
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.charts { grid-template-columns: 1fr; }
}
```
---
## 10. Логика приложения (app.js, скелет)
```js
const state = {
items: [], // распарсенные функции
groupFilter: [], // выбранные группы (пусто = все)
searchQuery: '',
inputs: {
vacanciesPerMonth: 15,
recruiterSalary: 500000,
},
};
// === Init ===
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('file-input').addEventListener('change', onFileSelected);
document.getElementById('group-filter').addEventListener('change', onFilterChange);
document.getElementById('search-input').addEventListener('input', onSearchChange);
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('inp-vacancies').addEventListener('input', onInputChange);
document.getElementById('inp-salary').addEventListener('input', onInputChange);
// Тёмная тема по умолчанию уже стоит в HTML (data-theme="dark")
});
function onInputChange() {
state.inputs.vacanciesPerMonth = +document.getElementById('inp-vacancies').value || 0;
state.inputs.recruiterSalary = +document.getElementById('inp-salary').value || 0;
render();
}
// === File handling ===
async function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
const wb = await readFile(file);
const rows = extractRows(wb);
state.items = parseRows(rows);
populateGroupFilter(state.items);
render();
}
// === Filters ===
function onFilterChange(e) {
state.groupFilter = [...e.target.selectedOptions].map(o => o.value);
render();
}
function onSearchChange(e) {
state.searchQuery = e.target.value.toLowerCase();
render();
}
function getFiltered() {
return state.items.filter(it => {
if (state.groupFilter.length && !state.groupFilter.includes(it.group)) return false;
if (state.searchQuery && !it.name.toLowerCase().includes(state.searchQuery)) return false;
return true;
});
}
// === Render ===
function render() {
const items = getFiltered();
const m = computeMetrics(items);
const s = computeStrategicKpi(m, state.inputs);
renderKpi(m);
renderStrategicKpi(s);
renderStages(s.stageShare);
renderTop10(m.top10);
renderGroups(m.byGroup);
renderTable(items);
}
function renderKpi(m) { /* заполнить #kpi 4 карточками */ }
function renderStrategicKpi(s) {
const fmt = new Intl.NumberFormat('ru-RU');
const cards = [
{ label: 'Время на 1 цикл', value: s.hoursPerCycle.toFixed(1) + ' ч' },
{ label: 'Загрузка FTE', value: Math.round(s.fteLoad) + '%' },
{ label: 'Capacity', value: s.capacity.toFixed(1) + ' вак/мес' },
{ label: 'Bottleneck (топ-3)', value: Math.round(s.bottleneckShare) + '%' },
{ label: 'Стоимость 1 цикла', value: s.costPerCycle != null ? fmt.format(Math.round(s.costPerCycle)) + ' ₸' : '—' },
];
document.getElementById('strategic-kpi').innerHTML = cards.map(c =>
`<div class="kpi-card"><div class="value">${c.value}</div><div class="label">${c.label}</div></div>`
).join('');
}
function renderStages(stageShare) {
const labels = Object.keys(stageShare);
const values = labels.map(l => stageShare[l]);
Plotly.newPlot('chart-stages', [{
type: 'bar', orientation: 'h',
x: values, y: ['Этапы'].concat(labels.length ? [] : []),
// Используем stacked bar через отдельные трейсы
}], {}, { displayModeBar: false, responsive: true });
// Реализация stacked bar: один трейс на каждый этап
const traces = labels.map(lbl => ({
type: 'bar', orientation: 'h',
name: truncate(lbl, 30),
x: [stageShare[lbl]],
y: ['Этапы'],
text: [Math.round(stageShare[lbl]) + '%'],
textposition: 'inside',
}));
Plotly.newPlot('chart-stages', traces, {
barmode: 'stack',
title: 'Распределение времени по этапам, %',
showlegend: true,
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: getCssVar('--fg') },
margin: { l: 60, r: 20, t: 50, b: 40 },
xaxis: { range: [0, 100] },
}, { displayModeBar: false, responsive: true });
}
function renderTop10(top10) {
Plotly.newPlot('chart-top10', [{
type: 'bar',
orientation: 'h',
y: top10.map(i => truncate(i.name, 60)).reverse(),
x: top10.map(i => i.minutes).reverse(),
marker: { color: getCssVar('--accent') },
}], {
title: 'Топ-10 самых долгих функций (мин)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: getCssVar('--fg') },
margin: { l: 250, r: 20, t: 50, b: 40 },
}, { displayModeBar: false, responsive: true });
}
function renderGroups(byGroup) {
const labels = Object.keys(byGroup);
const values = labels.map(l => byGroup[l]);
Plotly.newPlot('chart-groups', [{
type: 'pie',
labels, values,
textinfo: 'label+percent',
}], {
title: 'Распределение по группам',
paper_bgcolor: 'transparent',
font: { color: getCssVar('--fg') },
}, { displayModeBar: false, responsive: true });
}
function renderTable(items) { /* построить thead+tbody */ }
// === Theme ===
function toggleTheme() {
const html = document.documentElement;
const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
html.dataset.theme = next;
document.getElementById('theme-toggle').textContent = next === 'dark' ? '🌙' : '☀️';
render(); // перерисовать графики с новыми цветами
}
// === Helpers ===
function getCssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function truncate(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
function populateGroupFilter(items) {
const groups = [...new Set(items.map(i => i.group))];
const sel = document.getElementById('group-filter');
sel.innerHTML = groups.map(g => `<option value="${g}">${g}</option>`).join('');
}
```
---
## 11. Деплой на Gitea Pages
Шаги (см. также [GITEA.md](GITEA.md)):
```bash
# 1. Создать ветку gitea-pages из main
git checkout -b gitea-pages
# 2. (Опционально) удалить из этой ветки документы
git rm PRD.md TRD.md GITEA.md README.md # если хочется чистый прод
git commit -m "deploy: prepare gitea-pages branch"
# 3. Пушим
git push -u origin gitea-pages
# 4. В UI Gitea: репо → Темы (Topics) → добавить gitea-pages-allowall
```
Дальше каждый деплой:
```bash
git checkout gitea-pages
git merge main # или копировать руками index.html, styles.css, app.js, assets/
git push origin gitea-pages
```
URL: `https://<логин>.telecom.quest/aa/`
⚠️ Пути к ассетам — **относительные** (`./assets/logo.png`, не `/assets/logo.png`), иначе 404 на Pages.
---
## 12. План работ (порядок реализации)
| # | Шаг | Файлы | Проверка |
|---|-----|-------|----------|
| 1 | Каркас HTML + CSS | `index.html`, `styles.css` | Открыть в браузере — видно header, toolbar, пустые секции |
| 2 | Загрузка xlsx → console.log массива | `app.js` (readFile, extractRows) | Загрузить файл, в DevTools видны строки |
| 3 | Парсер групп + времени | `parseRows`, `parseTime` | console.table(items) — все 45 функций с group и minutes |
| 4 | KPI-карточки | `renderKpi` | 4 карточки с числами |
| 5 | Топ-10 бар-чарт | `renderTop10` | Горизонтальный бар на странице |
| 6 | Пай по группам | `renderGroups` | Пай-чарт |
| 7 | Таблица | `renderTable` | Все строки + сортировка по клику на заголовок |
| 8 | Фильтр по группе + поиск | `getFiltered` | Меняешь — графики и таблица обновляются |
| 9 | Тема light/dark | `toggleTheme` + CSS vars | Графики перерисовываются под цвета темы |
| 10 | Логотип telecom.quest | `assets/logo.png` | Виден в шапке |
| 11 | **Стратегические KPI** + inputs | `computeStrategicKpi`, `renderStrategicKpi` | 5 карточек, меняются при вводе значений |
| 12 | **Stacked bar этапов** (%) | `renderStages` | Полоса 100% с долями этапов |
| 13 | Деплой на Gitea Pages | ветка `gitea-pages` + топик | URL открывается, дашборд работает |
---
## 13. Тестовые данные
Эталонный файл: `Функции и вес рекрутинг 2026 (2).xlsx` уже лежит в репо.
Ожидаемый результат после загрузки:
- 45 функций распознано
- Сумма минут ≈ суммы всех numeric + усреднённых текстовых (точное число вычислится при реализации)
- Топ-1 будет одна из функций с «течение трех дней» (3 × 480 = 1440 мин) или «от 3 до 7 дней» (5 × 480 = 2400 мин)
- Группа одна: «🔍 Работа с вакансиями и источниками. Конкурсные процедуры. Подготовка - offer. Адаптация.»
---
## 14. Известные ограничения / TODO
- Логотип `assets/logo.png` — нужен реальный файл (сейчас плейсхолдер). Запросить у telecom.quest или сгенерировать.
- Парсер времени покрывает паттерны из текущего файла. Новые форматы (например «1.5 часа») → расширить `parseTime`.
- Plotly.js весит ~3 МБ. Если важна скорость загрузки — переключиться на `plotly-basic-2.35.2.min.js` (~1 МБ, есть bar + pie).
- Кеш браузера: при апдейте `app.js` пользователю может потребоваться `Cmd+Shift+R`.

233
app.js Normal file
View File

@ -0,0 +1,233 @@
// Дашборд функций рекрутинга — клиентское SPA (Excel в браузере)
const HOURS_MONTH = 168; // 21 рабочий день × 8 часов
const MIN_DAY = 480; // 8 × 60
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 state = {
items: [],
groupFilter: [],
search: '',
vacancies: 15,
salary: 500000,
};
// === Init ===
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; render(); };
$('inp-salary').oninput = e => { state.salary = +e.target.value || 0; render(); };
});
// === Excel ===
async function onFile(e) {
const file = e.target.files[0];
if (!file) return;
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: 'array' });
const sheet = wb.SheetNames.find(n => n.toLowerCase().includes('функ')) || wb.SheetNames[0];
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null });
state.items = parseRows(rows);
if (!state.items.length) return alert('В файле не найдено функций. Проверьте формат (лист «функции»).');
populateGroups();
showDashboard();
render();
} catch (err) {
alert(`Ошибка чтения файла: ${err.message}`);
}
}
function parseRows(rows) {
const out = [];
let group = 'Без группы';
for (const row of rows) {
if (!row) continue;
const [_, num, desc, time] = row;
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;
out.push({ num: n, name: String(desc).trim(), rawTime: time, minutes: parseTime(time), group });
}
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;
return {
total, hours, count: items.length, avg: valid.length ? total / valid.length : 0,
longest, top10, byGroup,
fte, capacity, bottleneck, cost,
};
}
// === 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 цикла' },
]);
chartTop10(m.top10);
chartGroups(m.byGroup);
renderTable(items);
}
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 renderTable(items) {
document.querySelector('#table tbody').innerHTML = items.map(i => `
<tr>
<td>${i.num}</td>
<td>${esc(i.name)}</td>
<td>${esc(String(i.rawTime ?? '—'))}</td>
<td>${i.minutes != null ? Math.round(i.minutes) : '—'}</td>
<td>${esc(trunc(i.group, 60))}</td>
</tr>`).join('');
}
// === Helpers ===
function filtered() {
return state.items.filter(i =>
(!state.groupFilter.length || state.groupFilter.includes(i.group)) &&
(!state.search || i.name.toLowerCase().includes(state.search))
);
}
function populateGroups() {
const groups = [...new Set(state.items.map(i => i.group))];
const sel = $('group-filter');
sel.innerHTML = groups.map(g => `<option value="${esc(g)}">${esc(trunc(g, 50))}</option>`).join('');
sel.size = Math.min(Math.max(groups.length, 2), 5);
}
function showDashboard() {
$('placeholder').hidden = true;
$('dashboard').hidden = false;
$('group-filter').hidden = false;
$('search-input').hidden = false;
}
function toggleTheme() {
const html = document.documentElement;
const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
html.dataset.theme = next;
$('theme-toggle').textContent = next === 'dark' ? '🌙' : '☀️';
if (state.items.length) render();
}
function layout(extra) {
return Object.assign({
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: cssVar('--fg'), family: '-apple-system, sans-serif' },
colorway: ['#60a5fa','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c','#22d3ee','#f472b6'],
}, extra);
}
function cfg() { return { displayModeBar: false, responsive: true }; }
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function $(id) { return document.getElementById(id); }
function trunc(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function fmt(n) { return new Intl.NumberFormat('ru-RU').format(n); }
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

12
assets/logo.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 36" width="160" height="36">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<circle cx="18" cy="18" r="14" fill="url(#g)"/>
<path d="M11 18 L16 23 L25 13" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="40" y="16" font-family="-apple-system, system-ui, sans-serif" font-size="13" font-weight="700" fill="currentColor">telecom</text>
<text x="40" y="30" font-family="-apple-system, system-ui, sans-serif" font-size="11" font-weight="500" fill="#60a5fa">.quest</text>
</svg>

After

Width:  |  Height:  |  Size: 740 B

66
index.html Normal file
View File

@ -0,0 +1,66 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<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>
<img src="./assets/logo.svg" alt="telecom.quest" class="logo">
<h1>Дашборд функций рекрутинга</h1>
<button id="theme-toggle" aria-label="Тема">🌙</button>
</header>
<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>
<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>
</div>
<div id="strategic-kpi" class="kpi-grid"></div>
</section>
<section class="charts-row">
<div class="block">
<h2>Топ-10 самых долгих функций</h2>
<div id="chart-top10"></div>
</div>
<div class="block">
<h2>Распределение по группам</h2>
<div id="chart-groups"></div>
</div>
</section>
<section class="block">
<h2>Все функции</h2>
<table id="table">
<thead><tr><th>№</th><th>Функция</th><th>Исх. время</th><th>Мин</th><th>Группа</th></tr></thead>
<tbody></tbody>
</table>
</section>
</main>
<p id="placeholder">Загрузите Excel-файл — дашборд построится автоматически.</p>
<script src="./app.js"></script>
</body>
</html>

63
styles.css Normal file
View File

@ -0,0 +1,63 @@
: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; }
[hidden] { display: none !important; }
header { display: flex; gap: 1rem; align-items: center; padding: 1rem 2rem; border-bottom: 1px solid var(--border); background: var(--surface); }
.logo { height: 36px; }
header h1 { flex: 1; font-size: 1.15rem; margin: 0; }
#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: 10px; padding: 1.25rem 1.5rem; }
.block h2 { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; }
.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: 8px; padding: .9rem 1rem; }
.kpi-card .value { font-size: 1.6rem; font-weight: 700; color: var(--accent); line-height: 1.15; }
.kpi-card .label { font-size: .75rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin-top: .3rem; }
.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 { min-height: 380px; }
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: 4rem 2rem; }
@media (max-width: 900px) {
.charts-row { grid-template-columns: 1fr; }
header, .toolbar, main { padding-left: 1rem; padding-right: 1rem; }
}