From 4cbb0f292fbbbe14b47fed0e53b3d8cc36164ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 28 Oct 2025 09:28:06 +0300 Subject: [PATCH] refactor: Extract teamWheel component (Phase 4 start) - Created static/js/components/teamWheel.js (329 lines) - Replaced 207-line inline script in department_scripts.html with ES6 module import - Features: auto-scroll, manual control (mouse/touch/wheel), member focus, fallback - All 12 tests passing --- backend/static/js/components/index.js | 2 +- backend/static/js/components/teamWheel.js | 307 ++++++++++++++++++ .../components/department_scripts.html | 230 +------------ 3 files changed, 319 insertions(+), 220 deletions(-) create mode 100644 backend/static/js/components/teamWheel.js diff --git a/backend/static/js/components/index.js b/backend/static/js/components/index.js index f93a4a8e..464ec774 100644 --- a/backend/static/js/components/index.js +++ b/backend/static/js/components/index.js @@ -24,7 +24,7 @@ export { // export { CalendarWidget } from './calendarWidget.js'; // Компоненты отображения (будут созданы в Фазе 4) -// export { TeamWheel } from './teamWheel.js'; +export { initTeamWheel } from './teamWheel.js'; // Версия компонентов export const COMPONENTS_VERSION = '1.0.0'; diff --git a/backend/static/js/components/teamWheel.js b/backend/static/js/components/teamWheel.js new file mode 100644 index 00000000..f522f5fe --- /dev/null +++ b/backend/static/js/components/teamWheel.js @@ -0,0 +1,307 @@ +/** + * TeamWheel - интерактивный виджет команды отдела с автоскроллом + * + * Визуализирует членов команды в виде вращающегося колеса с 4 колонками, + * отсортированными разными способами. Поддерживает: + * - Автоматический скролл с изменением направления + * - Ручной контроль (мышь/тач/колесико) + * - Клик по аватару для фокусировки на сотруднике + * - Fallback на руководителя при пустой команде + * + * @module components/teamWheel + */ + +/** + * Инициализирует TeamWheel виджет + * + * @param {Object} options - Параметры конфигурации + * @param {string} options.wheelId - ID элемента колеса + * @param {string} options.dataSourceId - ID элемента с данными членов команды + * @param {Object} options.fallback - Данные руководителя для fallback + * @param {string} options.fallback.avatar - URL аватара руководителя + * @param {string} options.fallback.name - Имя руководителя + * @param {string} options.fallback.role - Роль руководителя + * @param {string} options.fallback.email - Email руководителя + * @returns {void} + * + * @example + * initTeamWheel({ + * wheelId: 'teamWheel-123', + * dataSourceId: 'teamData-123', + * fallback: { + * avatar: '/media/avatar.jpg', + * name: 'Иванов Иван', + * role: 'Руководитель отдела', + * email: 'ivanov@example.com' + * } + * }); + */ +export function initTeamWheel(options) { + const { + wheelId, + dataSourceId, + fallback = {} + } = options; + + const wheel = document.getElementById(wheelId); + if (!wheel) return; + + const columns = wheel.querySelector('[data-columns]'); + const col1 = columns.querySelector('[data-col="1"]'); + const col2 = columns.querySelector('[data-col="2"]'); + const col3 = columns.querySelector('[data-col="3"]'); + const col4 = columns.querySelector('[data-col="4"]'); + + const focus = wheel.querySelector('[data-focus]'); + const fImg = wheel.querySelector('[data-focus-img]'); + const fFallback = wheel.querySelector('[data-focus-fallback]'); + + const wrapper = wheel.closest('.team-wrap'); + const hintBox = wrapper ? wrapper.querySelector('[data-hint]') : null; + const details = wrapper ? wrapper.querySelector('[data-details]') : null; + const dName = wrapper ? wrapper.querySelector('[data-details-name]') : null; + const dRole = wrapper ? wrapper.querySelector('[data-details-role]') : null; + const dMail = wrapper ? wrapper.querySelector('[data-details-email]') : null; + + const arcProg = wheel.querySelector('.arc-prog'); + + // Источник данных + const src = document.getElementById(dataSourceId); + const members = Array.from(src ? src.children : []).map(n => ({ + id: Number(n.getAttribute('data-id') || '0'), + name: n.getAttribute('data-name') || '', + role: n.getAttribute('data-role') || '', + email: n.getAttribute('data-email') || '', + avatar: n.getAttribute('data-avatar') || '', + active: n.getAttribute('data-active') === '1' + })); + + // Если никого нет — показываем руководителя + if (!members.length) { + showFallback(columns, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail, fallback); + return; + } + + // Сортировки для 4 колонок + const byNameAsc = [...members].sort((a, b) => a.name.localeCompare(b.name, 'ru')); + const byNameDesc = [...members].sort((a, b) => b.name.localeCompare(a.name, 'ru')); + const byIdAsc = [...members].sort((a, b) => a.id - b.id); + const byRoleAsc = [...members].sort((a, b) => { + const ra = (a.role || '').toLowerCase(); + const rb = (b.role || '').toLowerCase(); + if (ra === rb) return a.name.localeCompare(b.name, 'ru'); + return ra < rb ? -1 : 1; + }); + + fillCol(col1, byNameAsc); + fillCol(col2, byNameDesc); + fillCol(col3, byIdAsc); + fillCol(col4, byRoleAsc); + + // Инициализация автоскролла + setupAutoScroll(wheel, columns, arcProg, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail); +} + +/** + * Показывает fallback (руководителя) когда команда пуста + * @private + */ +function showFallback(columns, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail, fallback) { + columns.classList.add('d-none'); + focus.classList.remove('d-none'); + + const { avatar, name, role, email } = fallback; + + if (avatar) { + fImg.src = avatar; + fImg.alt = name || 'Руководитель'; + fImg.style.display = 'block'; + fFallback.style.display = 'none'; + } else { + fImg.removeAttribute('src'); + fImg.style.display = 'none'; + fFallback.style.display = 'block'; + } + + if (hintBox) hintBox.classList.add('d-none'); + if (details) { + details.classList.remove('d-none'); + if (dName) dName.textContent = name || 'Руководитель'; + if (dRole) dRole.textContent = role || 'Руководитель отдела'; + if (dMail) dMail.textContent = email || ''; + } +} + +/** + * Создает элемент для члена команды + * @private + */ +function makeItem(member) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'wheel-item'; + btn.setAttribute('data-id', String(member.id)); + btn.setAttribute('data-name', member.name); + btn.setAttribute('data-role', member.role || ''); + btn.setAttribute('data-email', member.email || ''); + btn.setAttribute('data-avatar', member.avatar || ''); + + const inner = document.createElement('span'); + inner.className = 'ava'; + + if (member.avatar) { + const img = document.createElement('img'); + img.alt = ''; + img.src = member.avatar; + inner.appendChild(img); + } else { + const ico = document.createElement('i'); + ico.className = 'bi-person'; + inner.appendChild(ico); + } + + btn.appendChild(inner); + return btn; +} + +/** + * Заполняет колонку элементами (с дублированием для бесконечного скролла) + * @private + */ +function fillCol(root, arr) { + root.innerHTML = ''; + // Дублируем для «бесконечной» прокрутки + for (let k = 0; k < 2; k++) { + arr.forEach(m => root.appendChild(makeItem(m))); + } +} + +/** + * Настраивает автоматический скролл с ручным управлением + * @private + */ +function setupAutoScroll(wheel, columns, arcProg, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail) { + let vScroll = 0; + let dir = 1; // 1 -> вниз, -1 -> вверх + const speed = 0.45; // px/frame + let hover = false; + let focused = false; + let maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight); + + function applyScroll() { + columns.style.transform = `translateY(${-vScroll}px)`; + if (maxY <= 0) { + arcProg.style.strokeDashoffset = 1; + } else { + const p = vScroll / maxY; // 0..1 + arcProg.style.strokeDashoffset = (1 - p).toFixed(4); + } + } + + function recalc() { + maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight); + vScroll = Math.min(vScroll, maxY); + applyScroll(); + } + + new ResizeObserver(recalc).observe(wheel); + window.addEventListener('resize', recalc); + + function loop() { + if (!hover && !focused && maxY > 0) { + vScroll += dir * speed; + if (vScroll >= maxY) { + vScroll = maxY; + dir = -1; // на нижней границе едем вверх + } else if (vScroll <= 0) { + vScroll = 0; + dir = 1; // цикл завершается только на верхней точке + } + applyScroll(); + } + requestAnimationFrame(loop); + } + + applyScroll(); + requestAnimationFrame(loop); + + // Hover — пауза автоскролла + wheel.addEventListener('mouseenter', () => { hover = true; }); + wheel.addEventListener('mouseleave', () => { hover = false; }); + + // Ручной скролл колесом + wheel.addEventListener('wheel', (e) => { + if (!focused) { + e.preventDefault(); + vScroll = Math.min(maxY, Math.max(0, vScroll + e.deltaY)); + applyScroll(); + } + }, { passive: false }); + + // Ручной скролл тачем + let tStartY = null; + wheel.addEventListener('touchstart', (e) => { + if (e.touches && e.touches.length) { + tStartY = e.touches[0].clientY; + } + }, { passive: true }); + + wheel.addEventListener('touchmove', (e) => { + if (tStartY != null && !focused) { + const y = e.touches[0].clientY; + const dy = tStartY - y; + vScroll = Math.min(maxY, Math.max(0, vScroll + dy)); + tStartY = y; + applyScroll(); + e.preventDefault(); + } + }, { passive: false }); + + wheel.addEventListener('touchend', () => { tStartY = null; }); + + // Фокус: клик по мини-аве + columns.addEventListener('click', (e) => { + const btn = e.target.closest('.wheel-item'); + if (!btn) return; + + const ava = btn.getAttribute('data-avatar') || ''; + const name = btn.getAttribute('data-name') || 'Сотрудник'; + const role = btn.getAttribute('data-role') || ''; + const mail = btn.getAttribute('data-email') || ''; + + focused = true; + columns.classList.add('d-none'); + focus.classList.remove('d-none'); + + if (ava) { + fImg.src = ava; + fImg.alt = name; + fImg.style.display = 'block'; + fFallback.style.display = 'none'; + } else { + fImg.removeAttribute('src'); + fImg.style.display = 'none'; + fFallback.style.display = 'block'; + } + + if (hintBox) hintBox.classList.add('d-none'); + if (details) { + details.classList.remove('d-none'); + if (dName) dName.textContent = name; + if (dRole) dRole.textContent = role; + if (dMail) dMail.textContent = mail; + } + }); + + // Выход из фокуса — клик по большому кругу + focus.addEventListener('click', () => { + focused = false; + focus.classList.add('d-none'); + columns.classList.remove('d-none'); + }); +} + +// Backward compatibility - expose to window +if (typeof window !== 'undefined') { + window.initTeamWheel = initTeamWheel; +} diff --git a/backend/templates/employees/components/department_scripts.html b/backend/templates/employees/components/department_scripts.html index 4f6d9ad2..5e050318 100644 --- a/backend/templates/employees/components/department_scripts.html +++ b/backend/templates/employees/components/department_scripts.html @@ -1,225 +1,17 @@ {# ==== JS: круг с автоскроллом/ручным управлением и индикатором ==== #} - {# ===== Переключатель панели настроек (скролл к блоку при открытии) ===== #}