Compare commits

...

No commits in common. "main" and "gh-pages" have entirely different histories.

5 changed files with 0 additions and 923 deletions

35
.gitignore vendored
View File

@ -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/

View File

@ -1 +0,0 @@
/Users/telecom/Dev/_hack22may/GITEA.md

165
PRD.md
View File

@ -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 — **да** (логотип + цвета).
- Тёмный режим — **да** (по умолчанию, переключатель в сайдбаре).

View File

@ -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
View File

@ -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`.