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