diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index fe0bf000..367193f8 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -1338,6 +1338,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): ?active=true|false ?actually_active=true|false — реально активные: email_verified=True и не DISMISSED (или нет действий, но is_active=True) + ?created_at__gte= — зарегистрированные после указанной даты Сортировка: ?ordering=last_name|first_name|created_at|id (+ '-' для DESC) @@ -1459,6 +1460,11 @@ class EmployeeViewSet(viewsets.ModelViewSet): ) ) + # фильтр по дате создания + created_at_gte = qp.get("created_at__gte") + if created_at_gte: + qs = qs.filter(created_at__gte=created_at_gte) + return qs def get_serializer_class(self): diff --git a/backend/feed/views_front.py b/backend/feed/views_front.py index 771e4ae6..dbbd3752 100644 --- a/backend/feed/views_front.py +++ b/backend/feed/views_front.py @@ -130,8 +130,8 @@ def _api_list_new_employees(request, limit: int = 10) -> list[dict]: resp = _api(request).get( API_EMPLOYEES, params={ - "is_active": "true", - "created_at__gte": since, # или "created_at_after": since — см. ниже + "active": "true", + "created_at__gte": since, "ordering": "-created_at", "page_size": limit, }, diff --git a/backend/static/css/components/department-list.css b/backend/static/css/components/department-list.css deleted file mode 100644 index 924b9d04..00000000 --- a/backend/static/css/components/department-list.css +++ /dev/null @@ -1,26 +0,0 @@ -/** - * department-list.css - * Стили списка отделов - * - * Использует: - * - common.css (.feed-author, .feed-sub) - * - feed-cards.css (.feed-header) - */ - -/* ───────────────────────────────────────────────────────────── - SCROLLABLE LIST -────────────────────────────────────────────────────────────── */ -/* список — единственный прокручиваемый блок */ -#deptList { - max-height: calc(100dvh - var(--navbar-h) - 160px); - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} - -#deptList .dept-row { - padding: var(--space-1) var(--space-2); -} - -#deptList .dept-row + .dept-row { - border-top: 1px solid var(--bs-border-color); -} diff --git a/backend/static/css/components/employee-list.css b/backend/static/css/components/employee-list.css deleted file mode 100644 index fb74bf0a..00000000 --- a/backend/static/css/components/employee-list.css +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Employee List Component - * - * Стили для списка сотрудников с аватарами, должностями и действиями - * Оптимизирован для вертикальной прокрутки и адаптивного отображения - * - * Использует: - * - common.css (.feed-author, .feed-sub) - * - feed-cards.css (.feed-hd, .feed-ava) - */ - -/* Токены списка сотрудников */ -#empList{ - --emp-row-py: var(--space-1); /* вертикальные отступы строки: 8px */ - --emp-row-px: var(--space-2); /* горизонтальные отступы строки: 16px */ - --emp-gap: var(--space-2); /* расстояние внутри строки: 16px */ - --emp-avatar: 56px; /* размер аватара */ -} - -/* Контейнер списка (скролл в колонке) */ -#empList{ - border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%); - border-radius: var(--feed-radius, 16px); - background: var(--bs-body-bg); - padding: 0; - max-height: calc(100dvh - var(--navbar-h, 0px) - 160px); - overflow: auto; - -webkit-overflow-scrolling: touch; -} - -/* Важно: убираем межстрочный gap от .feed-stream */ -#empList.feed-stream{ - display: block; - gap: 0 !important; -} - -/* Строка сотрудника */ -#empList .emp-row{ - padding: var(--emp-row-py) var(--emp-row-px) !important; - margin: 0 !important; - border-bottom: 1px solid var(--bs-border-color); -} - -#empList .emp-row:last-child{ - border-bottom: 0; -} - -/* Шапка строки: [аватар] [контент] [действия] */ -#empList .emp-row .feed-hd{ - display: grid; - grid-template-columns: var(--emp-avatar) 1fr auto; - align-items: center; - gap: var(--emp-gap); - margin: 0; - padding: 0 !important; /* без внутренних паддингов — ими управляет .emp-row */ -} - -/* Аватар */ -#empList .emp-row .feed-ava{ - width: var(--emp-avatar); - height: var(--emp-avatar); - border-radius: 50%; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%); - flex: none; -} - -#empList .emp-row .feed-ava img{ - display: block; - width: 100%; - height: 100%; - object-fit: cover; - object-position: center; -} - -#empList .emp-row .feed-ava i{ - font-size: 1.5rem; - line-height: 1; - opacity: .6; -} - -/* Контент */ -#empList .emp-row .feed-meta{ - margin: 0; - padding: 0; - min-width: 0; -} - -/* Специфичные стили для employee list */ -#empList .feed-author{ - line-height: 1.25; -} - -.emp-pos{ - color: var(--bs-secondary-color); -} - -#empList .feed-sub{ - margin-top: var(--space-1); -} - -/* Действия справа */ -.emp-actions{ - justify-self: end; -} - -/* Хвост карточки в списке не используем — убираем отступы */ -#empList .emp-row .feed-actions{ - padding: 0 !important; -} - -#empList .emp-row .feed-body:empty{ - display: none !important; -} - -#empList .emp-row .feed-body{ - padding: 0 !important; -} diff --git a/backend/static/css/components/join-scroller.css b/backend/static/css/components/join-scroller.css index e821b3ac..19764a41 100644 --- a/backend/static/css/components/join-scroller.css +++ b/backend/static/css/components/join-scroller.css @@ -21,6 +21,22 @@ overflow-y: visible; /* нужно для инфо-блока */ scrollbar-width: none; /* Firefox скрыть полосы */ -ms-overflow-style: none; /* IE/старый Edge */ + + /* Градиентная маска по краям - фиксированная на контейнере */ + mask-image: linear-gradient( + to right, + transparent 0%, + black 40px, + black calc(100% - 40px), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 40px, + black calc(100% - 40px), + transparent 100% + ); } .join-scroller::-webkit-scrollbar{ @@ -29,31 +45,12 @@ .join-rail{ display: flex; + flex-wrap: nowrap; /* не переносить элементы */ align-items: flex-start; + justify-content: center; /* центрировать элементы */ gap: .9rem; /* start: инфо-блоки снизу */ padding: .25rem .25rem; -} - -/* мягкие «маски» по краям */ -.join-scroller::before, -.join-scroller::after{ - content: ""; - position: absolute; - top: 0; - bottom: 0; - width: 32px; - pointer-events: none; - background: linear-gradient(to right, var(--bs-body-bg), transparent); -} - -.join-scroller::after{ - right: 0; - left: auto; - transform: scaleX(-1); -} - -.join-scroller::before{ - left: 0; + min-width: 100%; /* занять всю ширину контейнера */ } @media (prefers-reduced-motion: reduce){ @@ -82,6 +79,13 @@ z-index: 2; /* поверх соседей */ } +/* Отключение hover при перетаскивании */ +.join-item.no-hover:hover, +.join-item.no-hover:focus-within{ + --size: var(--base); + z-index: auto; +} + /* круг с фото */ .join-ava{ width: var(--size); @@ -132,6 +136,13 @@ transform: translateY(0); } +/* Скрытие инфо при перетаскивании */ +.join-item.no-hover .join-info{ + max-height: 0; + opacity: 0; + transform: translateY(-4px); +} + .join-info .name{ font-weight: 800; line-height: 1.2; diff --git a/backend/static/css/components/list-view.css b/backend/static/css/components/list-view.css new file mode 100644 index 00000000..7ec7f6ea --- /dev/null +++ b/backend/static/css/components/list-view.css @@ -0,0 +1,169 @@ +/** + * List View Component + * + * Универсальные стили для списочных представлений (сотрудники, отделы и др.) + * Оптимизированы для вертикальной прокрутки и адаптивного отображения + * + * Использует: + * - common.css (.feed-author, .feed-sub) + * - feed-cards.css (.feed-hd, .feed-ava) + */ + +/* Токены списочных представлений */ +.list-view{ + --list-row-py: var(--space-1); /* вертикальные отступы строки: 8px */ + --list-row-px: var(--space-2); /* горизонтальные отступы строки: 16px */ + --list-gap: var(--space-2); /* расстояние внутри строки: 16px */ + --list-avatar: 56px; /* размер аватара */ +} + +/* Контейнер списков (скролл в колонке) */ +.list-view{ + border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%); + border-radius: var(--feed-radius, 16px); + background: var(--bs-body-bg); + padding: 0; + max-height: calc(100dvh - var(--navbar-h, 0px) - 160px); + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Важно: убираем межстрочный gap от .feed-stream */ +.list-view.feed-stream{ + display: block; + gap: 0 !important; +} + +/* Кастомный скроллбар - тонкий и незаметный */ +.list-view::-webkit-scrollbar { + width: 8px; +} + +.list-view::-webkit-scrollbar-track { + background: transparent; + margin: 16px 4px; /* большой отступ сверху/снизу чтобы не попадать на скругленные углы */ +} + +.list-view::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--bs-border-color) 40%, transparent); + border-radius: 4px; + border: 2px solid var(--bs-body-bg); /* визуальный отступ от края */ + background-clip: padding-box; +} + +.list-view::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--bs-border-color) 60%, transparent); + border: 2px solid var(--bs-body-bg); + background-clip: padding-box; +} + +/* Убираем стрелки полностью */ +.list-view::-webkit-scrollbar-button { + display: none; + height: 0; + width: 0; +} + +/* Для Firefox */ +.list-view { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--bs-border-color) 40%, transparent) transparent; +} + +/* Важно: убираем межстрочный gap от .feed-stream */ +.list-view.feed-stream{ + display: block; + gap: 0 !important; +} + +/* Строки списков */ +.list-view .list-row{ + padding: var(--list-row-py) var(--list-row-px) !important; + margin: 0 !important; + border-bottom: 1px solid var(--bs-border-color); +} + +.list-view .list-row:last-of-type{ + border-bottom: 0; +} + +/* Шапка строки: базовый flex */ +.list-view .list-row .feed-hd{ + display: flex; + align-items: center; + gap: var(--list-gap); + min-height: var(--list-avatar); /* минимальная высота как у аватара для единообразия */ + margin: 0; + padding: 0 !important; /* без внутренних паддингов — ими управляет строка */ +} + +/* Шапка с аватаром: переключаем на grid */ +.list-view .list-row:has(.feed-ava) .feed-hd{ + display: grid; + grid-template-columns: var(--list-avatar) 1fr auto; +} + +/* Аватар */ +.list-view .list-row .feed-ava{ + width: var(--list-avatar); + height: var(--list-avatar); + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%); + flex: none; +} + +.list-view .list-row .feed-ava img{ + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +.list-view .list-row .feed-ava i{ + font-size: 1.5rem; + line-height: 1; + opacity: .6; +} + +/* Контент */ +.list-view .list-row .feed-meta{ + margin: 0; + padding: 0; + min-width: 0; +} + +/* Специфичные стили для списков */ +.list-view .feed-author{ + line-height: 1.25; +} + +.emp-pos{ + color: var(--bs-secondary-color); +} + +.list-view .feed-sub{ + margin-top: var(--space-1); +} + +/* Действия справа */ +.emp-actions{ + justify-self: end; +} + +/* Хвост карточки в списке не используем — убираем отступы */ +.list-view .list-row .feed-actions{ + padding: 0 !important; +} + +.list-view .list-row .feed-body:empty{ + display: none !important; +} + +.list-view .list-row .feed-body{ + padding: 0 !important; +} diff --git a/backend/static/css/components/page-header.css b/backend/static/css/components/page-header.css index 6581c259..4c020ac2 100644 --- a/backend/static/css/components/page-header.css +++ b/backend/static/css/components/page-header.css @@ -22,15 +22,16 @@ z-index: 10; padding: var(--space-2) 0; /* 8px сверху/снизу */ margin-bottom: var(--space-3); /* 12px отступ снизу */ - display: flex; - align-items: center; - gap: var(--space-2); - flex-wrap: wrap; - /* Полупрозрачный фон с размытием */ - backdrop-filter: saturate(180%) blur(12px); - -webkit-backdrop-filter: saturate(180%) blur(12px); - background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent); + /* Полупрозрачный фон с градиентом */ + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--bs-body-bg) 90%, transparent) 0%, + color-mix(in srgb, var(--bs-body-bg) 85%, transparent) 30%, + color-mix(in srgb, var(--bs-body-bg) 70%, transparent) 50%, + color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%, + transparent 100% + ); /* Убрана border-bottom - линия не нужна */ @@ -38,9 +39,52 @@ transition: background 0.2s ease, box-shadow 0.2s ease; } +/* Псевдоэлемент для градиентного размытия */ +.page-header::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + mask-image: linear-gradient( + to bottom, + black 0%, + black 30%, + rgba(0, 0, 0, 0.7) 50%, + rgba(0, 0, 0, 0.4) 70%, + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to bottom, + black 0%, + black 30%, + rgba(0, 0, 0, 0.7) 50%, + rgba(0, 0, 0, 0.4) 70%, + transparent 100% + ); + pointer-events: none; + z-index: -1; +} + +/* Контент должен быть выше псевдоэлемента */ +.page-header > * { + position: relative; + z-index: 1; +} + /* При скролле - более выраженный фон */ .page-header.scrolled { - background: color-mix(in srgb, var(--bs-body-bg) 92%, transparent); + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--bs-body-bg) 95%, transparent) 0%, + color-mix(in srgb, var(--bs-body-bg) 90%, transparent) 30%, + color-mix(in srgb, var(--bs-body-bg) 75%, transparent) 50%, + color-mix(in srgb, var(--bs-body-bg) 45%, transparent) 70%, + transparent 100% + ); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } @@ -151,7 +195,8 @@ /* ===== БЛОК ФИЛЬТРОВ ===== */ .page-filters-collapse { - margin-bottom: var(--space-3); + margin-top: var(--space-2); + width: 100%; } .page-filters { @@ -222,7 +267,13 @@ /* ===== КНОПКА СОЗДАНИЯ В ВИДЕ КАРТОЧКИ (для модала) ===== */ /* Кнопка должна выглядеть как ссылка в feed-card */ -.feed-card button.dept-row { +.page-header .feed-card { + margin-top: var(--space-2); + width: 100%; +} + +.page-header .feed-card button.dept-row, +.page-header .feed-card a.dept-row { cursor: pointer; transition: background-color 0.2s ease; width: 100%; @@ -231,22 +282,26 @@ } /* Текст кнопки должен быть как feed-author */ -.feed-card button.dept-row .feed-author { +.page-header .feed-card button.dept-row .feed-author, +.page-header .feed-card a.dept-row .feed-author { font-weight: 700; color: var(--bs-body-color); } /* Иконка должна быть синей как в оригинале */ -.feed-card button.dept-row i { +.page-header .feed-card button.dept-row i, +.page-header .feed-card a.dept-row i { color: var(--bs-primary); } -.feed-card button.dept-row:hover { +.page-header .feed-card button.dept-row:hover, +.page-header .feed-card a.dept-row:hover { background-color: var(--bs-light); } @media (prefers-color-scheme: dark) { - .feed-card button.dept-row:hover { + .page-header .feed-card button.dept-row:hover, + .page-header .feed-card a.dept-row:hover { background-color: color-mix(in srgb, var(--bs-body-bg) 95%, white); } } @@ -254,12 +309,26 @@ /* ===== ТЕМНАЯ ТЕМА ===== */ @media (prefers-color-scheme: dark) { .page-header { - background: color-mix(in srgb, var(--bs-body-bg) 80%, transparent); + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--bs-body-bg) 85%, transparent) 0%, + color-mix(in srgb, var(--bs-body-bg) 80%, transparent) 45%, + color-mix(in srgb, var(--bs-body-bg) 65%, transparent) 50%, + color-mix(in srgb, var(--bs-body-bg) 35%, transparent) 70%, + transparent 100% + ); border-bottom-color: color-mix(in srgb, var(--bs-border-color), transparent 60%); } .page-header.scrolled { - background: color-mix(in srgb, var(--bs-body-bg) 90%, transparent); + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--bs-body-bg) 90%, transparent) 0%, + color-mix(in srgb, var(--bs-body-bg) 80%, transparent) 30%, + color-mix(in srgb, var(--bs-body-bg) 72%, transparent) 50%, + color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%, + transparent 100% + ); } .page-filters { diff --git a/backend/static/js/components/autoScrollerHandler.js b/backend/static/js/components/autoScrollerHandler.js index 07e5eae2..756198da 100644 --- a/backend/static/js/components/autoScrollerHandler.js +++ b/backend/static/js/components/autoScrollerHandler.js @@ -1,7 +1,7 @@ /** * @module autoScrollerHandler * @description Автоматический горизонтальный скроллер для галереи элементов. - * Поддерживает бесконечный цикл, паузу при наведении, центрирование элемента. + * Поддерживает плавную непрерывную прокрутку, паузу при наведении, центрирование элемента. * * Пример HTML: *
@@ -13,10 +13,10 @@ * * Использование: * import { initAutoScroller } from './autoScrollerHandler.js'; - * initAutoScroller({ selector: '.js-join-scroller' }); + * initAutoScroller({ selector: '.js-join-scroller', speed: 0.5 }); */ -const DEFAULT_STEP_MS = 3000; +const DEFAULT_SPEED = 0.5; // пикселей за кадр (примерно 30px/сек при 60fps) const SCROLL_BEHAVIOR = 'smooth'; /** @@ -57,11 +57,13 @@ function computeScrollStep(rail) { * Инициализирует автоскроллер для одного контейнера. * @param {HTMLElement} wrapper - Обёртка со скроллом * @param {Object} options - Опции - * @param {number} [options.stepMs=3000] - Интервал между прокрутками в миллисекундах + * @param {number} [options.speed=0.5] - Скорость прокрутки в пикселях за кадр * @returns {Object} API с методами start, stop, destroy */ function initSingleScroller(wrapper, options = {}) { - const { stepMs = DEFAULT_STEP_MS } = options; + const { speed = DEFAULT_SPEED } = options; + + console.log('initSingleScroller: starting, speed =', speed); const rail = wrapper.querySelector('.join-rail'); if (!rail) { @@ -71,7 +73,10 @@ function initSingleScroller(wrapper, options = {}) { // Проверяем, нужен ли скролл (контент переполняет контейнер) const hasOverflow = rail.scrollWidth > wrapper.clientWidth + 4; + console.log('initSingleScroller: hasOverflow =', hasOverflow, 'scrollWidth =', rail.scrollWidth, 'clientWidth =', wrapper.clientWidth); + if (!hasOverflow) { + console.log('initSingleScroller: no overflow, skipping'); return { start: () => {}, stop: () => {}, destroy: () => {} }; } @@ -79,39 +84,80 @@ function initSingleScroller(wrapper, options = {}) { const originalChildren = Array.from(rail.children); const clones = originalChildren.map(node => node.cloneNode(true)); rail.append(...clones); + + console.log('initSingleScroller: cloned', originalChildren.length, 'items'); + + // Пересчитываем overflow после клонирования + const hasOverflowAfterClone = rail.scrollWidth > wrapper.clientWidth + 4; + console.log('initSingleScroller: hasOverflowAfterClone =', hasOverflowAfterClone); + + if (!hasOverflowAfterClone) { + // Удаляем клоны если даже с ними нет переполнения + clones.forEach(clone => clone.remove()); + console.log('initSingleScroller: no overflow after clone, removing clones'); + return { start: () => {}, stop: () => {}, destroy: () => {} }; + } // Состояние let scrollStep = computeScrollStep(rail); const originalCount = originalChildren.length; - let timer = null; + let animationFrame = null; let paused = false; let resizeFrame = null; + + console.log('initSingleScroller: scrollStep =', scrollStep, 'originalCount =', originalCount); /** - * Один шаг скролла. + * Один кадр анимации - плавный скролл. */ - function tick() { - if (paused) return; + let frameCount = 0; + let accumulatedScroll = 0; // Накопленные дробные пиксели + + function animate() { + if (paused) { + animationFrame = requestAnimationFrame(animate); + return; + } + + frameCount++; const maxScroll = scrollStep * originalCount; // Если достигли конца первой копии - прыгаем в начало if (wrapper.scrollLeft >= maxScroll - 2) { wrapper.scrollLeft = 0; + accumulatedScroll = 0; + console.log('animate: reset to start, maxScroll =', maxScroll); } - wrapper.scrollBy({ - left: scrollStep, - behavior: SCROLL_BEHAVIOR - }); + // Накапливаем дробные пиксели + accumulatedScroll += speed; + + // Скроллим только когда накопился хотя бы 1 пиксель + if (accumulatedScroll >= 1) { + const scrollAmount = Math.floor(accumulatedScroll); + accumulatedScroll -= scrollAmount; // Оставляем дробную часть + + wrapper.scrollBy({ + left: scrollAmount, + behavior: 'auto' + }); + + if (frameCount % 60 === 0) { + console.log('animate: frame', frameCount, 'scrollLeft =', wrapper.scrollLeft, 'scrollAmount =', scrollAmount); + } + } + + animationFrame = requestAnimationFrame(animate); } /** * Запускает автоматическую прокрутку. */ function start() { - if (!timer) { - timer = setInterval(tick, stepMs); + console.log('start: starting animation'); + if (!animationFrame) { + animationFrame = requestAnimationFrame(animate); } } @@ -119,9 +165,9 @@ function initSingleScroller(wrapper, options = {}) { * Останавливает автоматическую прокрутку. */ function stop() { - if (timer) { - clearInterval(timer); - timer = null; + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; } } @@ -140,6 +186,29 @@ function initSingleScroller(wrapper, options = {}) { paused = false; } + /** + * Обработчик нажатия кнопки мыши на элементе. + */ + function handleItemMouseDown(event) { + // Отменяем поведение по умолчанию чтобы не начиналось перетаскивание + event.preventDefault(); + + // Сразу снимаем паузу и убираем увеличение + const item = event.currentTarget; + paused = false; + + // Добавляем класс для отключения hover + item.classList.add('no-hover'); + + // Добавляем глобальный обработчик отпускания кнопки + function handleGlobalMouseUp() { + item.classList.remove('no-hover'); + document.removeEventListener('mouseup', handleGlobalMouseUp); + } + + document.addEventListener('mouseup', handleGlobalMouseUp); + } + /** * Обработчик получения фокуса элементом. */ @@ -181,6 +250,7 @@ function initSingleScroller(wrapper, options = {}) { items.forEach(item => { item.addEventListener('mouseenter', handleItemMouseEnter); item.addEventListener('mouseleave', handleItemMouseLeave); + item.addEventListener('mousedown', handleItemMouseDown); item.addEventListener('focusin', handleItemFocusIn); item.addEventListener('focusout', handleItemFocusOut); }); @@ -191,8 +261,12 @@ function initSingleScroller(wrapper, options = {}) { // Проверяем prefers-reduced-motion const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + console.log('initSingleScroller: prefersReducedMotion =', prefersReducedMotion); + if (!prefersReducedMotion) { start(); + } else { + console.log('initSingleScroller: skipping animation due to prefers-reduced-motion'); } /** @@ -204,6 +278,7 @@ function initSingleScroller(wrapper, options = {}) { items.forEach(item => { item.removeEventListener('mouseenter', handleItemMouseEnter); item.removeEventListener('mouseleave', handleItemMouseLeave); + item.removeEventListener('mousedown', handleItemMouseDown); item.removeEventListener('focusin', handleItemFocusIn); item.removeEventListener('focusout', handleItemFocusOut); }); @@ -223,16 +298,16 @@ function initSingleScroller(wrapper, options = {}) { * Инициализирует автоскроллеры для всех элементов по селектору. * @param {Object} options - Опции * @param {string} [options.selector='.js-join-scroller'] - CSS-селектор контейнеров - * @param {number} [options.stepMs=3000] - Интервал между прокрутками в миллисекундах + * @param {number} [options.speed=0.5] - Скорость прокрутки в пикселях за кадр (примерно 30px/сек при 60fps) * @returns {Object} API с методом destroy */ export function initAutoScroller(options = {}) { - const { selector = '.js-join-scroller', stepMs = DEFAULT_STEP_MS } = options; + const { selector = '.js-join-scroller', speed = DEFAULT_SPEED } = options; const scrollers = []; document.querySelectorAll(selector).forEach(wrapper => { - const scroller = initSingleScroller(wrapper, { stepMs }); + const scroller = initSingleScroller(wrapper, { speed }); scrollers.push(scroller); }); diff --git a/backend/static/js/components/createPostModal.js b/backend/static/js/components/createPostModal.js new file mode 100644 index 00000000..f10cf11a --- /dev/null +++ b/backend/static/js/components/createPostModal.js @@ -0,0 +1,169 @@ +/** + * createPostModal.js + * Обработчик модального окна создания публикации + */ + +export function initCreatePostModal(options = {}) { + const { + modalId = 'createPostModal', + formId = 'createPostForm', + errorsId = 'createPostErrors', + submitBtnId = 'createPostSubmit', + submitUrl = '/feed/post/new/' + } = options; + + const modal = document.getElementById(modalId); + const form = document.getElementById(formId); + const errorsDiv = document.getElementById(errorsId); + const submitBtn = document.getElementById(submitBtnId); + + if (!modal || !form) { + console.error('Modal or form not found!', { modalId, formId }); + return; + } + + console.log('Create post modal initialized'); + + // Маппинг полей для человекочитаемых названий + const fieldLabels = { + 'title': 'Заголовок', + 'body': 'Содержание', + 'image': 'Изображение', + 'attachment': 'Вложение', + 'type': 'Тип публикации' + }; + + /** + * Очистка формы и ошибок + */ + function clearFormErrors() { + errorsDiv.classList.add('d-none'); + errorsDiv.innerHTML = ''; + form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid')); + form.querySelectorAll('.invalid-feedback').forEach(el => el.remove()); + } + + /** + * Валидация обязательных полей + */ + function validateRequiredFields() { + const title = form.querySelector('[name="title"]'); + const body = form.querySelector('[name="body"]'); + let hasErrors = false; + + if (!title.value.trim()) { + title.classList.add('is-invalid'); + const feedback = document.createElement('div'); + feedback.className = 'invalid-feedback'; + feedback.textContent = 'Заголовок обязателен для заполнения'; + title.parentElement.appendChild(feedback); + hasErrors = true; + } + + if (!body.value.trim()) { + body.classList.add('is-invalid'); + const feedback = document.createElement('div'); + feedback.className = 'invalid-feedback'; + feedback.textContent = 'Содержание обязательно для заполнения'; + body.parentElement.appendChild(feedback); + hasErrors = true; + } + + return !hasErrors; + } + + /** + * Отображение ошибок по полям + */ + function displayFieldErrors(errors) { + const errorMessages = []; + + for (const [fieldName, messages] of Object.entries(errors)) { + const field = form.querySelector(`[name="${fieldName}"]`); + const errorText = Array.isArray(messages) ? messages.join(', ') : messages; + const label = fieldLabels[fieldName] || fieldName; + + errorMessages.push(`${label}: ${errorText}`); + + if (field) { + field.classList.add('is-invalid'); + const feedback = document.createElement('div'); + feedback.className = 'invalid-feedback'; + feedback.textContent = errorText; + field.parentElement.appendChild(feedback); + } + } + + if (errorMessages.length > 0) { + errorsDiv.innerHTML = errorMessages.join('
'); + errorsDiv.classList.remove('d-none'); + } + } + + /** + * Показать общее сообщение об ошибке + */ + function displayGeneralError(message) { + errorsDiv.textContent = message; + errorsDiv.classList.remove('d-none'); + } + + /** + * Показать уведомление об успехе + */ + function showSuccessNotification(message) { + const toast = document.createElement('div'); + toast.className = 'toast align-items-center text-bg-success border-0 position-fixed top-0 start-50 translate-middle-x mt-3'; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + toast.style.zIndex = '9999'; + + toast.innerHTML = ` +
+
+ ${message} +
+ +
+ `; + + document.body.appendChild(toast); + const bsToast = new bootstrap.Toast(toast, { delay: 3000 }); + bsToast.show(); + + toast.addEventListener('hidden.bs.toast', () => { + toast.remove(); + }); + } + + /** + * Отправка формы через обычный submit (не AJAX) + * Проще использовать стандартную отправку формы Django + */ + function submitForm(e) { + clearFormErrors(); + + if (!validateRequiredFields()) { + e.preventDefault(); + return false; + } + + // Отключаем кнопку и показываем загрузку + submitBtn.disabled = true; + const originalText = submitBtn.innerHTML; + submitBtn.innerHTML = 'Публикация...'; + + // Позволяем форме отправиться обычным способом + return true; + } + + // Обработчик отправки формы + form.addEventListener('submit', submitForm); + + // Очистка ошибок при открытии модала + modal.addEventListener('show.bs.modal', () => { + clearFormErrors(); + form.reset(); + }); +} diff --git a/backend/static/js/components/customScrollbar.js b/backend/static/js/components/customScrollbar.js new file mode 100644 index 00000000..e69de29b diff --git a/backend/templates/employees/department_list.html b/backend/templates/employees/department_list.html index 22e6a0fd..6c2159fa 100644 --- a/backend/templates/employees/department_list.html +++ b/backend/templates/employees/department_list.html @@ -8,7 +8,7 @@ - + {% endblock %} {% block content %} @@ -23,45 +23,45 @@ {% include "includes/components/page_header.html" with icon="bi-diagram-3" title="Отделы" count=departments|length count_label="Всего:" search_id="deptFilter" search_placeholder="Поиск по названию или руководителю" %} {% endif %} -
-
- {# поддержка обоих форматов: dict(results) или list #} - {% with rows=departments.results|default:departments %} - {% for dept in rows %} +
+ {# поддержка обоих форматов: dict(results) или list #} + {% with rows=departments.results|default:departments %} + {% for dept in rows %} {% with h=dept.head emp=dept.employees_count|default:0 %} {% url 'employees:department_detail' dept.id as dept_url %} -
-
-
- {% if dept_url %} - - {{ dept.name }} - - {% else %} - {{ dept.name }} - {% endif %} +
+
+
+ {% if dept_url %} + + {{ dept.name }} + + {% else %} + {{ dept.name }} + {% endif %} +
+
+ {% if h %} + Руководитель: {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %} + {% endif %} + {% if h and emp %} · {% endif %} + {% if emp %}{{ emp }} сотрудников{% endif %} +
-
- {% if h %} - Руководитель: {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %} - {% endif %} - {% if h and emp %} · {% endif %} - {% if emp %}{{ emp }} сотрудников{% endif %} -
-
- {% if dept_url %} - - {% endif %} -
+ {% if dept_url %} + + {% endif %} + + {% endwith %} {% empty %} -
Отделов пока нет.
+
Отделов пока нет.
{% endfor %} {% endwith %}
-
Всего сотрудников: 0 diff --git a/backend/templates/employees/employees_list.html b/backend/templates/employees/employees_list.html index 3ac2a1b1..41d22222 100644 --- a/backend/templates/employees/employees_list.html +++ b/backend/templates/employees/employees_list.html @@ -9,7 +9,7 @@ - + {% endblock %} @@ -19,12 +19,12 @@
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #} - {% include "includes/components/page_header.html" with icon="bi-people" title="Сотрудники" count=employees|length count_label=count_label search_id="empFilter" search_placeholder="Поиск по ФИО, должности или отделу" has_filters=True filters_target="empFilters" filters_template="employees/_filters.html" create_as_card=True create_modal="#createEmployeeModal" create_text="Создать профиль" %} + {% include "includes/components/page_header.html" with icon="bi-people" title="Сотрудники" count=employees|length count_label=count_label search_id="empFilter" search_placeholder="Поиск по ФИО, должности или отделу" has_filters=True filters_target="empFilters" filters_template="employees/_filters.html" create_as_card=True create_modal="createEmployeeModal" create_text="Создать профиль" %} -
+
{% if employees %} {% for emp in employees %} -
diff --git a/backend/templates/feed/_feed_cards.html b/backend/templates/feed/_feed_cards.html index 2a5833cc..90a3af09 100644 --- a/backend/templates/feed/_feed_cards.html +++ b/backend/templates/feed/_feed_cards.html @@ -1,7 +1,12 @@ {% load static %} {% for post in items %} {% firstof post.id post.pk as post_id %} -
+
{# --- Аватар автора --- #} diff --git a/backend/templates/feed/components/modal_create_post.html b/backend/templates/feed/components/modal_create_post.html new file mode 100644 index 00000000..f5a41447 --- /dev/null +++ b/backend/templates/feed/components/modal_create_post.html @@ -0,0 +1,56 @@ +{% comment %} +Модальное окно для создания публикации +Использование: {% include "feed/components/modal_create_post.html" %} +{% endcomment %} + + diff --git a/backend/templates/feed/feed_list.html b/backend/templates/feed/feed_list.html index d9c8ef5e..78f4193e 100644 --- a/backend/templates/feed/feed_list.html +++ b/backend/templates/feed/feed_list.html @@ -16,19 +16,15 @@ {# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #} {% if user.is_authenticated and user.is_staff %} - {% url 'feed:post_create' as post_create_url_base %} - {% with post_create_url=post_create_url_base|add:"?type=company" %} - {% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" no_search=True create_url=post_create_url create_text="Новая публикация" create_icon="bi-plus-circle" %} - {% endwith %} + {% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" search_id="feedFilter" search_placeholder="Поиск по названию, автору или содержанию" create_as_card=True create_modal="createPostModal" create_text="Создать публикацию" %} {% else %} - {% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" no_search=True create_url='' create_text="Новая публикация" create_icon="bi-plus-circle" %} + {% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" search_id="feedFilter" search_placeholder="Поиск по названию, автору или содержанию" %} {% endif %} {% if new_employees %} -
+
-
Присоединились недавно
- Все сотрудники +
@@ -68,21 +64,21 @@ {% endif %} -
{% endfor %}
+
+
+
{% endif %}
-
+
{% if page %} {% include "feed/_feed_cards.html" with items=page.object_list %} {% elif posts %} @@ -102,15 +98,35 @@
-{% endblock %} + +{# Модальное окно для создания публикации #} +{% include "feed/components/modal_create_post.html" %} +{% endblock %} diff --git a/backend/templates/includes/components/page_header.html b/backend/templates/includes/components/page_header.html index 530826ea..9b7ba049 100644 --- a/backend/templates/includes/components/page_header.html +++ b/backend/templates/includes/components/page_header.html @@ -1,131 +1,133 @@ {# Универсальный компонент шапки страницы списка. См. документацию в PAGE_HEADER_SUMMARY.md #} {# ===== ШАПКА СТРАНИЦЫ ===== #} -