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:
parent
d668b75595
commit
f97af2b749
2
.gitignore
vendored
2
.gitignore
vendored
@ -25,6 +25,8 @@ target/
|
|||||||
*.sqlite*
|
*.sqlite*
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
|
||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
165
PRD.md
165
PRD.md
@ -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
696
TRD.md
@ -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
233
app.js
Normal 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
12
assets/logo.svg
Normal file
12
assets/logo.svg
Normal 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
66
index.html
Normal 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
63
styles.css
Normal 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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user