From 29a38a42dc83c2df5d14a3c7afe6390d968d0a51 Mon Sep 17 00:00:00 2001 From: Dauren777 Date: Fri, 19 Jun 2026 06:14:40 +0000 Subject: [PATCH] v1 - TB training assistant --- AGENTS.md | 173 +++++++++++++++++++++ design.md | 110 +++++++++++++ index.html | 152 ++++++++++++++++++ script.js | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 878 insertions(+) create mode 100644 AGENTS.md create mode 100644 design.md create mode 100644 index.html create mode 100644 script.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b592c15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ + +# Vibe42 — учебная песочница для лендингов + +Workspace юзера `aibala_iztileuova`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/aibala_iztileuova//`, +2. ощущением «это было легко» — без серверов, БД, токенов, конфигов. + +Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Сразу делай `./new-project ` и собирай страницу. Не спрашивай разрешения на каждый шаг. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ + +| Запрос | Что делаем вместо | +|--------|-------------------| +| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram | +| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) | +| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium | +| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp | +| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) | +| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` | + +**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА + +Как только готов первый рабочий вариант (даже грубый): + +1. **Сразу запушь:** + ```bash + git add -A + git commit -m "v1" + git push origin HEAD:pages + ``` +2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**: + > 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/aibala_iztileuova//** +3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?» +4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push. + +--- + +## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда) + +1. **Только статика — HTML + CSS + JS в браузере.** +2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis. +3. **Никакой аутентификации / OAuth / JWT.** +4. **Никакого Docker, nginx, sudo, системных настроек.** +5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN. +6. **НИКОГДА `git init` в workspace root (`/workspaces/aibala_iztileuova`)** — это папка-контейнер юзера, не репозиторий. + +--- + +## ✅ ВСЕГДА работай через `./new-project` + +Если юзер сказал «сделай сайт NAME» / «создай проект NAME»: + +```bash +cd /workspaces/aibala_iztileuova +./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/ +cd NAME +# теперь создавай index.html / style.css / script.js внутри ./NAME +``` + +`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`. + +--- + +## 🌐 Git и публикация + +**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz** + +- Профиль юзера: https://git.vibe42.kz/aibala_iztileuova +- Pages (живые лендинги): https://pages.git.vibe42.kz/aibala_iztileuova// +- Креды уже в `/workspaces/aibala_iztileuova/.git-credentials` — git push/clone работают без пароля +- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно + +### Опубликовать лендинг + +```bash +git add -A +git commit -m "site" +git push origin HEAD:pages +``` + +Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно. + +--- + +## 🔧 Когда что-то идёт не так + +- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f` +- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет +- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages` +- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html + +--- + +## ❌ Чего НЕ делать НИКОГДА + +- ❌ `git init` в workspace root +- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt) +- ❌ Создавать `server.js` / `app.py` / `main.go` как backend +- ❌ Использовать `gh` CLI или GitHub API +- ❌ Вызывать Gitea Pages-API (его нет) +- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages» +- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено +- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз) +- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано +- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML +- ❌ Говорить «это сложно» — переформулируй в простое +- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй + +--- + +## 🎨 design.md + +Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие. diff --git a/design.md b/design.md new file mode 100644 index 0000000..5c8e829 --- /dev/null +++ b/design.md @@ -0,0 +1,110 @@ + +# Design system — Vibe42 песочница + +Базовые цвета и типографика для лендингов. Можно отклоняться, но начинай с этого. + +## Палитра + +| Token | Hex | Использование | +|-------|-----|---------------| +| `--ink` | `#0F1218` | Тёмный фон / основной текст | +| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) | +| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов | +| `--white` | `#FFFFFF` | Основной фон | +| `--gray-500` | `#5B6573` | Вторичный текст | +| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны | + +## Типографика + +```css +font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; +``` + +| Уровень | Размер | Вес | line-height | +|---------|--------|-----|-------------| +| h1 (hero) | 56px | 800 | 1.05 | +| h2 (section) | 36px | 700 | 1.15 | +| h3 | 22px | 700 | 1.3 | +| body | 17px | 400 | 1.6 | +| small | 14px | 400 | 1.5 | + +На мобиле — h1 уменьши до 36px, h2 до 28px. + +## Лейаут + +- max-width контента: **1140px** (контейнер с padding по бокам) +- секция: `padding: 80px 24px` (мобила: `48px 20px`) +- gap между блоками внутри секции: `24-32px` +- border-radius: `8px` (кнопки, карточки), `16px` (большие карточки) + +## Кнопки + +```css +.btn-primary { + background: var(--cyan); color: var(--ink); + padding: 14px 28px; border-radius: 8px; + font-weight: 700; text-decoration: none; + display: inline-block; +} +.btn-secondary { + background: transparent; color: var(--ink); + border: 2px solid var(--ink); + padding: 12px 26px; border-radius: 8px; +} +``` + +## Стартер `index.html` + +```html + + + + + +Мой проект + + + +
+
+

Заголовок проекта

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+
+ + +``` + +## Чем НЕ пользоваться + +- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS более чем достаточно + +## Чем МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` — для прототипа OK +- **Lottie animations через CDN** +- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400` +- **Шрифты Google Fonts через ``** в head diff --git a/index.html b/index.html new file mode 100644 index 0000000..7b09e52 --- /dev/null +++ b/index.html @@ -0,0 +1,152 @@ + + + + + +Помощник по обучению ТБ + + + + +
+ +
+
+

Помощник по ТБ

+

Войдите через корпоративные данные

+
+ + +
+
+ + +
+ +

Демо: admin / 12345 (админ) или 1001 / пароль (сотрудник)

+
+
+ + + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..067af2e --- /dev/null +++ b/script.js @@ -0,0 +1,443 @@ +// ========== МОК-ДАННЫЕ ========== +const COURSES = [ + { id: 1, name: "Безопасная рабочая среда SWE 2", level: 2, periodicity: 12 }, + { id: 2, name: "Безопасное вождение (Defensive Driving)", level: 2, periodicity: 24 }, + { id: 3, name: "Безопасное выполнение земляных работ", level: 1, periodicity: 12 }, + { id: 4, name: "Безопасное выполнение работ на лестницах", level: 1, periodicity: 12 }, + { id: 5, name: "Безопасное выполнение работ на опорах", level: 1, periodicity: 12 }, + { id: 6, name: "Безопасность рук", level: 2, periodicity: 12 }, + { id: 7, name: "Изоляция источников энергии (LOTO)", level: 1, periodicity: 12 }, + { id: 8, name: "Контроль усталости", level: 1, periodicity: 6 }, + { id: 9, name: "Культура безопасности труда", level: 2, periodicity: 24 }, + { id: 10, name: "Оказание первой медицинской помощи", level: 1, periodicity: 24 }, + { id: 11, name: "Основы безопасности в замкнутом пространстве", level: 2, periodicity: 12 }, + { id: 12, name: "Основы безопасности на высоте", level: 2, periodicity: 12 }, + { id: 13, name: "Поведенческие аудиты безопасности", level: 2, periodicity: 12 }, + { id: 14, name: "Предотвращение травматизма IPR", level: 1, periodicity: 12 }, + { id: 15, name: "Предотвращение травматизма IPR (углублённый)", level: 2, periodicity: 12 }, + { id: 16, name: "Расследование инцидентов", level: 1, periodicity: 12 }, + { id: 17, name: "Управление опасностями и рисками", level: 1, periodicity: 12 }, + { id: 18, name: "Устойчивое развитие и экология", level: 1, periodicity: 24 }, + { id: 19, name: "Фасилитатор в HSE", level: 2, periodicity: 24 }, + { id: 20, name: "Четырёхступенчатый контроль (1 ступень)", level: 1, periodicity: 12 }, + { id: 21, name: "Четырёхступенчатый контроль (2 ступень)", level: 1, periodicity: 12 }, + { id: 22, name: "Четырёхступенчатый контроль (3 ступень)", level: 1, periodicity: 12 }, + { id: 23, name: "Четырёхступенчатый контроль (4 ступень)", level: 1, periodicity: 12 } +]; + +const CITIES = ["Алматы", "Астана", "Атырау", "Актау", "Караганда", "Шымкент"]; +const DEPARTMENTS = ["HSE", "Производство", "Логистика", "Администрация", "Техническая служба", "Электроснабжение"]; +const POSITIONS = ["Инженер HSE", "Мастер", "Электромонтёр", "Слесарь", "Водитель", "Начальник смены", "Техник"]; + +// Генерация мок-данных сотрудников +function generateEmployees() { + const employees = []; + const lastNames = ["Иванов", "Петров", "Сидоров", "Козлов", "Новиков", "Морозов", "Волков", "Соколов", "Лебедев", "Кузнецов", "Попов", "Васильев", "Зайцев", "Михайлов", "Фёдоров", "Абдуллин", "Нурланов", "Сагиев", "Турсунов", "Касымов", "Жумабаев", "Ермеков", "Баймуханов", "Алиев", "Хасенов"]; + const firstNames = ["Александр", "Дмитрий", "Сергей", "Андрей", "Михаил", "Ильяс", "Данияр", "Артём", "Руслан", "Нурсултан", "Айдар", "Ерлан", "Булат", "Тимур", "Арман", "Мадияр", "Самат", "Кайрат", "Ержан", "Аслан"]; + const patronymics = ["Александрович", "Дмитриевич", "Сергеевич", "Андреевич", "Михайлович", "Ильясович", "Даниярович", "Артёмович", "Русланович", "Нурсултанович"]; + + let tab = 1001; + for (let city of CITIES) { + const count = 8 + Math.floor(Math.random() * 7); + for (let i = 0; i < count; i++) { + const emp = { + tab: String(tab++), + lastName: lastNames[Math.floor(Math.random() * lastNames.length)], + firstName: firstNames[Math.floor(Math.random() * firstNames.length)], + patronymic: patronymics[Math.floor(Math.random() * patronymics.length)], + city: city, + department: DEPARTMENTS[Math.floor(Math.random() * DEPARTMENTS.length)], + position: POSITIONS[Math.floor(Math.random() * POSITIONS.length)], + courses: [] + }; + emp.fullName = `${emp.lastName} ${emp.firstName} ${emp.patronymic}`; + + // Случайные курсы + const courseCount = 5 + Math.floor(Math.random() * 10); + const shuffled = [...COURSES].sort(() => 0.5 - Math.random()); + for (let j = 0; j < Math.min(courseCount, COURSES.length); j++) { + const course = shuffled[j]; + const completed = Math.random() > 0.3; + if (completed) { + const monthsAgo = Math.floor(Math.random() * 36); + const completedDate = new Date(); + completedDate.setMonth(completedDate.getMonth() - monthsAgo); + const nextDate = new Date(completedDate); + nextDate.setMonth(nextDate.getMonth() + course.periodicity); + const isOverdue = nextDate < new Date(); + emp.courses.push({ + courseId: course.id, + courseName: course.name, + level: course.level, + completed: true, + completedDate: completedDate.toISOString().split('T')[0], + nextDate: nextDate.toISOString().split('T')[0], + overdue: isOverdue, + daysOverdue: isOverdue ? Math.floor((new Date() - nextDate) / 86400000) : 0 + }); + } else { + emp.courses.push({ + courseId: course.id, + courseName: course.name, + level: course.level, + completed: false, + completedDate: null, + nextDate: null, + overdue: false, + daysOverdue: 0 + }); + } + } + employees.push(emp); + } + } + return employees; +} + +const EMPLOYEES = generateEmployees(); + +// ========== СОСТОЯНИЕ ========== +let currentUser = null; +let isAdmin = false; + +// ========== АВТОРИЗАЦИЯ ========== +function doLogin() { + const login = document.getElementById('login-id').value.trim(); + const pass = document.getElementById('login-pass').value.trim(); + + if (!login) { alert('Введите табельный номер'); return; } + + // Админ + if (login === 'admin' || login === '12345') { + isAdmin = true; + currentUser = { tab: 'admin', fullName: 'Администратор системы', role: 'admin' }; + } else { + // Поиск сотрудника + const emp = EMPLOYEES.find(e => e.tab === login || e.fullName.toLowerCase().includes(login.toLowerCase())); + if (!emp) { alert('Сотрудник не найден. Попробуйте: 1001-1050'); return; } + isAdmin = false; + currentUser = emp; + } + + document.getElementById('login-screen').classList.add('hidden'); + document.getElementById('main-app').classList.remove('hidden'); + document.getElementById('user-info').textContent = `${currentUser.fullName} (${isAdmin ? 'Администратор' : 'Сотрудник'})`; + + if (isAdmin) { + document.getElementById('admin-panel').classList.remove('hidden'); + document.getElementById('employee-panel').classList.add('hidden'); + showTab('dashboard'); + renderDashboard(); + renderCityStats(); + } else { + document.getElementById('admin-panel').classList.add('hidden'); + document.getElementById('employee-panel').classList.remove('hidden'); + renderEmployeeCourses(currentUser); + } +} + +function doLogout() { + currentUser = null; + isAdmin = false; + document.getElementById('login-screen').classList.remove('hidden'); + document.getElementById('main-app').classList.add('hidden'); + document.getElementById('login-id').value = ''; + document.getElementById('login-pass').value = ''; +} + +// ========== ТАБЫ ========== +function showTab(name) { + document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden')); + document.getElementById('tab-' + name).classList.remove('hidden'); + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('btn'); + btn.classList.add('btn', 'btn-outline'); + }); + document.querySelector(`[data-tab="${name}"]`).classList.remove('btn-outline'); +} + +// ========== ПОИСК ========== +function searchEmployee() { + const query = document.getElementById('search-input').value.trim(); + const cityFilter = document.getElementById('filter-city').value; + + let results = EMPLOYEES; + + if (query) { + results = results.filter(e => + e.tab === query || + e.fullName.toLowerCase().includes(query.toLowerCase()) + ); + } + + if (cityFilter) { + results = results.filter(e => e.city === cityFilter); + } + + renderSearchResults(results); +} + +function renderSearchResults(employees) { + const container = document.getElementById('search-results'); + + if (employees.length === 0) { + container.innerHTML = '
Сотрудники не найдены
'; + return; + } + + let html = '
'; + html += ''; + html += ''; + + for (const emp of employees) { + const completed = emp.courses.filter(c => c.completed).length; + const overdue = emp.courses.filter(c => c.overdue).length; + const progress = Math.round((completed / emp.courses.length) * 100); + + html += ` + + + + + + + + + `; + } + + html += '
Таб.№ФИОГородДолжностьПройденоПросроченоПрогресс
${emp.tab}${emp.fullName}${emp.city}${emp.position}${completed}/${emp.courses.length}${overdue > 0 ? '❌ ' + overdue : '✅'} +
+
+ ${progress}% +
+
'; + container.innerHTML = html; +} + +function showEmployeeDetail(tab) { + const emp = EMPLOYEES.find(e => e.tab === tab); + if (!emp) return; + showTab('search'); + renderEmployeeCourses(emp, true); +} + +// ========== ПОКАЗАТЬ КУРСЫ СОТРУДНИКА ========== +function renderEmployeeCourses(emp, showBack = false) { + const container = isAdmin ? document.getElementById('search-results') : document.getElementById('emp-courses'); + const titleEl = document.getElementById('emp-title'); + + if (titleEl) titleEl.textContent = `Обучение — ${emp.fullName}`; + + const completed = emp.courses.filter(c => c.completed); + const notStarted = emp.courses.filter(c => !c.completed); + const overdue = completed.filter(c => c.overdue); + const valid = completed.filter(c => !c.overdue); + const progress = Math.round((completed.length / emp.courses.length) * 100); + + let html = `
`; + if (showBack) html += ``; + + html += ` +
+
Таб.№: ${emp.tab}
+
Город: ${emp.city}
+
Должность: ${emp.position}
+
Подразделение: ${emp.department}
+
+
+
Общий прогресс${completed.length} из ${emp.courses.length} (${progress}%)
+
+
+ `; + + if (overdue.length > 0) { + html += `

⏰ Просрочено (${overdue.length})

+
`; + for (const c of overdue) { + html += ``; + } + html += `
КурсУровеньЗавершёнДолжен пройтиПросрочка
${c.courseName}Ур.${c.level}${formatDate(c.completedDate)}${formatDate(c.nextDate)}⚠️ ${c.daysOverdue} дн.
`; + } + + if (valid.length > 0) { + html += `

✅ Действует (${valid.length})

+
`; + for (const c of valid) { + const daysLeft = Math.ceil((new Date(c.nextDate) - new Date()) / 86400000); + const urgency = daysLeft <= 30 ? 'text-yellow-600 font-bold' : ''; + html += ``; + } + html += `
КурсУровеньЗавершёнСледующее
${c.courseName}Ур.${c.level}${formatDate(c.completedDate)}${formatDate(c.nextDate)} ${daysLeft <= 30 ? '⚠️ ' + daysLeft + ' дн.' : ''}
`; + } + + if (notStarted.length > 0) { + html += `

❗ Не пройдено (${notStarted.length})

    `; + for (const c of notStarted) { + html += `
  • ${c.courseName} (Ур.${c.level})
  • `; + } + html += `
`; + } + + html += '
'; + container.innerHTML = html; + + if (isAdmin) { + document.getElementById('tab-search').classList.remove('hidden'); + document.querySelectorAll('.tab-content').forEach(el => { if (el.id !== 'tab-search') el.classList.add('hidden'); }); + } +} + +function formatDate(dateStr) { + if (!dateStr) return '—'; + const [y, m, d] = dateStr.split('-'); + return `${d}.${m}.${y}`; +} + +// ========== РЕГИОНЫ ========== +function renderCityStats() { + const container = document.getElementById('city-stats'); + const stats = CITIES.map(city => { + const emps = EMPLOYEES.filter(e => e.city === city); + const totalCourses = emps.reduce((sum, e) => sum + e.courses.length, 0); + const completed = emps.reduce((sum, e) => sum + e.courses.filter(c => c.completed).length, 0); + const overdue = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); + const progress = totalCourses > 0 ? Math.round((completed / totalCourses) * 100) : 0; + return { city, count: emps.length, progress, overdue }; + }); + + container.innerHTML = stats.map(s => ` +
+
${s.count}
+
${s.city}
+
+
${s.progress}% выполнено ${s.overdue > 0 ? `| ${s.overdue} просрочено` : ''}
+
+ `).join(''); +} + +function showCityEmployees(city) { + const emps = EMPLOYEES.filter(e => e.city === city); + renderSearchResults(emps); + showTab('search'); +} + +// ========== ДАШБОРД ========== +function renderDashboard() { + const totalEmp = EMPLOYEES.length; + const totalCourses = EMPLOYEES.reduce((sum, e) => sum + e.courses.length, 0); + const completed = EMPLOYEES.reduce((sum, e) => sum + e.courses.filter(c => c.completed).length, 0); + const overdue = EMPLOYEES.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); + const notStarted = EMPLOYEES.reduce((sum, e) => sum + e.courses.filter(c => !c.completed).length, 0); + const progress = totalCourses > 0 ? Math.round((completed / totalCourses) * 100) : 0; + + document.getElementById('dash-stats').innerHTML = ` +
${totalEmp}
Сотрудников
+
${progress}%
Выполнено
+
${overdue}
Просрочено
+
${notStarted}
Не начато
+ `; + + // Проблемные филиалы + const cityStats = CITIES.map(city => { + const emps = EMPLOYEES.filter(e => e.city === city); + const ov = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); + const total = emps.reduce((sum, e) => sum + e.courses.length, 0); + return { city, overdue: ov, pct: total > 0 ? Math.round((ov / total) * 100) : 0 }; + }).sort((a, b) => b.pct - a.pct); + + document.getElementById('dash-problems').innerHTML = cityStats.map((s, i) => + `
+ ${i + 1}. ${s.city} + ${s.pct}% просрочено (${s.overdue}) +
` + ).join(''); + + // Самые непройденные курсы + const courseStats = COURSES.map(c => { + const total = EMPLOYEES.length; + const notDone = EMPLOYEES.filter(e => !e.courses.find(cc => cc.courseId === c.id && cc.completed)).length; + return { name: c.name, notDone, pct: Math.round((notDone / total) * 100) }; + }).sort((a, b) => b.pct - a.pct).slice(0, 5); + + document.getElementById('dash-courses').innerHTML = courseStats.map((s, i) => + `
+ ${i + 1}. ${s.name} + ${s.pct}% +
` + ).join(''); +} + +// ========== ВЫГРУЗКА ========== +function exportData(type) { + const container = document.getElementById('export-results'); + let html = '
'; + let data = []; + + if (type === 'overdue') { + html += '

⏰ Просроченные обучения

'; + for (const emp of EMPLOYEES) { + for (const c of emp.courses.filter(c => c.overdue)) { + data.push({ tab: emp.tab, fio: emp.fullName, city: emp.city, course: c.courseName, completed: c.completedDate, overdue: c.nextDate, days: c.daysOverdue }); + } + } + data.sort((a, b) => b.days - a.days); + } else if (type === 'city') { + html += '

🏙️ По филиалам

'; + for (const emp of EMPLOYEES) { + for (const c of emp.courses) { + data.push({ tab: emp.tab, fio: emp.fullName, city: emp.city, course: c.courseName, status: c.completed ? (c.overdue ? 'Просрочено' : 'Пройдено') : 'Не пройдено' }); + } + } + } else if (type === 'employee') { + html += '

👤 По сотрудникам

'; + for (const emp of EMPLOYEES) { + const completed = emp.courses.filter(c => c.completed).length; + const overdue = emp.courses.filter(c => c.overdue).length; + data.push({ tab: emp.tab, fio: emp.fullName, city: emp.city, department: emp.department, total: emp.courses.length, completed, overdue, progress: Math.round((completed / emp.courses.length) * 100) + '%' }); + } + } else if (type === 'summary') { + html += '

📋 Сводный отчёт по филиалам

'; + for (const city of CITIES) { + const emps = EMPLOYEES.filter(e => e.city === city); + const total = emps.reduce((sum, e) => sum + e.courses.length, 0); + const completed = emps.reduce((sum, e) => sum + e.courses.filter(c => c.completed).length, 0); + const overdue = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); + data.push({ city, employees: emps.length, totalCourses: total, completed, overdue, progress: total > 0 ? Math.round((completed / total) * 100) + '%' : '0%' }); + } + } + + if (data.length === 0) { + html += '

Нет данных для выгрузки

'; + } else { + const headers = Object.keys(data[0]); + html += '' + headers.map(h => ``).join('') + ''; + for (const row of data.slice(0, 100)) { + html += '' + headers.map(h => ``).join('') + ''; + } + html += '
${h}
${row[h]}
'; + if (data.length > 100) html += `

Показано 100 из ${data.length} записей

`; + } + + html += '
'; + container.innerHTML = html; +} + +function exportCSV() { + let csv = 'Таб.№;ФИО;Филиал;Должность;Подразделение;Курс;Уровень;Статус;Дата обучения;Дата завершения;Следующее обучение;Просрочено дней\n'; + + for (const emp of EMPLOYEES) { + for (const c of emp.courses) { + const status = c.completed ? (c.overdue ? 'Просрочено' : 'Пройдено') : 'Не пройдено'; + csv += `${emp.tab};${emp.fullName};${emp.city};${emp.position};${emp.department};${c.courseName};${c.level};${status};${c.completedDate || ''};${c.completedDate || ''};${c.nextDate || ''};${c.daysOverdue || 0}\n`; + } + } + + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'training_report_' + new Date().toISOString().split('T')[0] + '.csv'; + a.click(); + URL.revokeObjectURL(url); +}