Compare commits
No commits in common. "main" and "gh-pages" have entirely different histories.
35
.gitignore
vendored
35
.gitignore
vendored
@ -1,35 +0,0 @@
|
||||
# secrets
|
||||
.env
|
||||
.env.local
|
||||
.jwt-secret
|
||||
credentials*.json
|
||||
secrets.json
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
|
||||
# build artifacts
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
target/
|
||||
|
||||
# data
|
||||
*.sqlite*
|
||||
*.db
|
||||
*.log
|
||||
*.xlsx
|
||||
*.xls
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
165
PRD.md
165
PRD.md
@ -1,165 +0,0 @@
|
||||
# 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 — **да** (логотип + цвета).
|
||||
- Тёмный режим — **да** (по умолчанию, переключатель в сайдбаре).
|
||||
26
README.md
26
README.md
@ -1,26 +0,0 @@
|
||||
# aa
|
||||
|
||||
Привет, Askarova A!
|
||||
|
||||
Это твоя личная папка в среде telecom.quest.
|
||||
- VS Code в браузере: https://code.telecom.quest/?folder=/Users/telecom/dev/_hack22may/aa/
|
||||
- Бэкап на Gitea: https://git.telecom.quest/kt-innovators/aa
|
||||
|
||||
## Action Plan (как пользоваться)
|
||||
|
||||
В этой папке уже лежат три стартовых файла:
|
||||
- `PRD.md` — здесь ты простыми словами описываешь, что хочешь автоматизировать
|
||||
- `GITEA.md` — справочник по системе Gitea (для Claude, не для тебя)
|
||||
- `TRD.md` — пустой; Claude заполнит его после того, как ты напишешь PRD
|
||||
|
||||
**Шаг 1.** Открой `PRD.md` и напиши свою идею.
|
||||
**Шаг 2.** Открой Claude (Desktop или web). Дай ему `PRD.md` + `GITEA.md` и попроси: «Сделай TRD.md на основе PRD и GITEA».
|
||||
**Шаг 3.** Сохрани результат в `TRD.md`.
|
||||
**Шаг 4.** Запусти Claude Code в этой папке. Скажи: «Реализуй TRD.md». Получишь рабочий MVP.
|
||||
|
||||
## Как сохранять работу
|
||||
|
||||
В VS Code открой панель "Source Control" (слева), напиши краткое описание, нажми галочку → "Commit and Push".
|
||||
Изменения сохранятся в Gitea и будут доступны при следующем входе.
|
||||
|
||||
В этой папке уже инициализирован git и подключен remote `origin` → Gitea.
|
||||
696
TRD.md
696
TRD.md
@ -1,696 +0,0 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user