Исправлена фильтрация новых сотрудников и центрирование карусели

- Добавлен фильтр created_at__gte в EmployeeViewSet API
- Исправлен параметр is_active -> active в запросе к API
- Добавлено центрирование карусели новых сотрудников (justify-content: center)
- Обновлена документация API endpoints
This commit is contained in:
admin i 2025-11-20 12:54:01 +03:00
parent 5cda78b49b
commit eb5fb926a1
16 changed files with 809 additions and 378 deletions

View File

@ -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=<iso_date> — зарегистрированные после указанной даты
Сортировка:
?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):

View File

@ -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,
},

View File

@ -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);
}

View File

@ -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;
}

View File

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

View File

@ -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;
}

View File

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

View File

@ -1,7 +1,7 @@
/**
* @module autoScrollerHandler
* @description Автоматический горизонтальный скроллер для галереи элементов.
* Поддерживает бесконечный цикл, паузу при наведении, центрирование элемента.
* Поддерживает плавную непрерывную прокрутку, паузу при наведении, центрирование элемента.
*
* Пример HTML:
* <div class="js-join-scroller overflow-hidden">
@ -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);
});

View File

@ -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(`<strong>${label}:</strong> ${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('<br>');
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 = `
<div class="d-flex">
<div class="toast-body">
<i class="bi-check-circle me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
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 = '<span class="spinner-border spinner-border-sm me-1"></span>Публикация...';
// Позволяем форме отправиться обычным способом
return true;
}
// Обработчик отправки формы
form.addEventListener('submit', submitForm);
// Очистка ошибок при открытии модала
modal.addEventListener('show.bs.modal', () => {
clearFormErrors();
form.reset();
});
}

View File

@ -8,7 +8,7 @@
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/department-list.css' %}">
<link rel="stylesheet" href="{% static 'css/components/list-view.css' %}">
{% 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 %}
<div class="feed-card">
<div id="deptList" class="py-1">
{# поддержка обоих форматов: dict(results) или list #}
{% with rows=departments.results|default:departments %}
{% for dept in rows %}
<div id="deptList" class="list-view feed-stream">
{# поддержка обоих форматов: 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 %}
<div class="dept-row d-flex align-items-start gap-2"
<article class="dept-row list-row"
data-haystack="{{ dept.name }}{% if h %} {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %}{% endif %}"
data-emp="{{ emp }}">
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if dept_url %}
<a href="{{ dept_url }}" class="feed-author text-decoration-none stretched-link">
{{ dept.name }}
</a>
{% else %}
<span class="feed-author">{{ dept.name }}</span>
{% endif %}
<header class="feed-hd">
<div class="feed-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if dept_url %}
<a href="{{ dept_url }}" class="feed-author text-decoration-none">
{{ dept.name }}
</a>
{% else %}
<span class="feed-author">{{ dept.name }}</span>
{% endif %}
</div>
<div class="feed-sub mt-1">
{% if h %}
Руководитель: {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %}
{% endif %}
{% if h and emp %} · {% endif %}
{% if emp %}{{ emp }} сотрудников{% endif %}
</div>
</div>
<div class="feed-sub mt-1">
{% if h %}
Руководитель: {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %}
{% endif %}
{% if h and emp %} · {% endif %}
{% if emp %}{{ emp }} сотрудников{% endif %}
</div>
</div>
{% if dept_url %}
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
{% endif %}
</div>
{% if dept_url %}
<i class="bi-chevron-right text-secondary"></i>
{% endif %}
</header>
</article>
{% endwith %}
{% empty %}
<div class="p-3 text-secondary">Отделов пока нет.</div>
<div class="alert alert-info shadow-sm">Отделов пока нет.</div>
{% endfor %}
{% endwith %}
</div>
</div>
<div class="mt-3 text-secondary small">
Всего сотрудников: <span id="empTotal">0</span>

View File

@ -9,7 +9,7 @@
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/employee-list.css' %}">
<link rel="stylesheet" href="{% static 'css/components/list-view.css' %}">
{% endblock %}
@ -19,12 +19,12 @@
<div class="col-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="Создать профиль" %}
<div id="empList" class="feed-stream">
<div id="empList" class="list-view feed-stream">
{% if employees %}
{% for emp in employees %}
<article class="emp-row"
<article class="emp-row list-row"
data-name="{{ emp.last_name }} {{ emp.first_name }} {{ emp.patronymic }} {% firstof emp.position.name emp.position_label '' %}"
data-depts="{% if emp.departments %}{% for d in emp.departments %}{% if d.name %}{{ d.name }}{% if not forloop.last %}, {% endif %}{% endif %}{% endfor %}{% elif is_filtered_by_department and current_department %}{{ current_department.name }}{% endif %}">
<header class="feed-hd">

View File

@ -1,7 +1,12 @@
{% load static %}
{% for post in items %}
{% firstof post.id post.pk as post_id %}
<article data-post-card id="post-{{ post_id }}">
<article class="feed-post"
id="post-{{ post_id }}"
data-post-card
data-searchtitle="{{ post.title|default:'Без названия'|escape }}"
data-searchauthor="{% with a=post.author %}{% if a %}{% firstof a.full_name a.get_full_name a.first_name a.last_name a.username a.email 'Автор' %}{% else %}Автор{% endif %}{% endwith %}"
data-searchbody="{{ post.body|striptags|truncatewords:50|default:'Без содержания'|escape }}">
<header class="feed-hd">
{# --- Аватар автора --- #}

View File

@ -0,0 +1,56 @@
{% comment %}
Модальное окно для создания публикации
Использование: {% include "feed/components/modal_create_post.html" %}
{% endcomment %}
<div class="modal fade" id="createPostModal" tabindex="-1" aria-labelledby="createPostModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createPostModalLabel">
<i class="bi-megaphone me-2"></i>Создать публикацию
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<form id="createPostForm" method="POST" action="{% url 'feed:post_create' %}?type=company" novalidate enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body">
<div id="createPostErrors" class="alert alert-danger d-none mb-3"></div>
<input type="hidden" name="type" id="post_type" value="company">
<div class="mb-3">
<label for="post_title" class="form-label">Заголовок <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="post_title" name="title" required maxlength="255">
</div>
<div class="mb-3">
<label for="post_body" class="form-label">Содержание <span class="text-danger">*</span></label>
<textarea class="form-control" id="post_body" name="body" rows="6" required></textarea>
</div>
<div class="mb-3">
<label for="post_image" class="form-label">Изображение</label>
<input type="file" class="form-control" id="post_image" name="image" accept="image/*">
<small class="form-text text-muted">Поддерживаются форматы: JPG, PNG, GIF</small>
</div>
<div class="mb-3">
<label for="post_attachment" class="form-label">Вложение</label>
<input type="file" class="form-control" id="post_attachment" name="attachment">
<small class="form-text text-muted">Любой файл до 10 МБ</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary" id="createPostSubmit">
<i class="bi-check-lg me-1"></i>Опубликовать
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -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 %}
<div class="feed-new mb-3">
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary">Присоединились недавно</h6>
<a href="{% url 'employees:employees_list' %}" class="small text-primary text-decoration-none">Все сотрудники</a>
<h6 class="mb-0 text-secondary"></h6>
</div>
<div class="join-scroller js-join-scroller" aria-label="Новые сотрудники">
@ -68,21 +64,21 @@
{% endif %}
<div class="text-center">
<a class="link" href="{% url 'employees:employee_detail' emp_id %}">Профиль →</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary"></h6>
</div>
</div>
{% endif %}
<section class="feed-card mb-3">
<div class="feed-hd">
</div>
<div>
<div id="feedList">
{% if page %}
{% include "feed/_feed_cards.html" with items=page.object_list %}
{% elif posts %}
@ -102,15 +98,35 @@
</div>
</div>
</div>
{% endblock %}
{# Модальное окно для создания публикации #}
{% include "feed/components/modal_create_post.html" %}
<script type="module">
import { initAutoScroller } from "{% static 'js/components/autoScrollerHandler.js' %}";
import { initCreatePostModal } from "{% static 'js/components/createPostModal.js' %}";
import { ListFilter, createDataAttrMatcher } from '{% static "js/components/listFilter.js" %}';
document.addEventListener('DOMContentLoaded', () => {
initAutoScroller({
selector: '.js-join-scroller',
stepMs: 3000
speed: 0.5 // пикселей за кадр (примерно 30px/сек)
});
// Инициализация модала создания публикации
initCreatePostModal({
submitUrl: '{% url "feed:post_create" %}?type=company'
});
// Инициализация поиска по публикациям
new ListFilter({
listSelector: '#feedList',
itemSelector: '.feed-post',
searchInputSelector: '#feedFilter',
clearButtonSelector: '.ios-search-clear',
matchFn: createDataAttrMatcher(['searchtitle', 'searchauthor', 'searchbody']),
debounceMs: 300
});
});
</script>
{% endblock %}

View File

@ -1,131 +1,133 @@
{# Универсальный компонент шапки страницы списка. См. документацию в PAGE_HEADER_SUMMARY.md #}
{# ===== ШАПКА СТРАНИЦЫ ===== #}
<div class="page-header d-flex align-items-center gap-2">
{# Иконка #}
{% if icon %}
<i class="{{ icon }} fs-2 text-primary flex-shrink-0"></i>
{% endif %}
{# Заголовок #}
<h2 class="page-title mb-0">{{ title }}</h2>
{# Счётчик (если есть) #}
{% if count is not None %}
<span class="page-count text-secondary">
{{ count_label|default:"Всего:" }} <span class="count-value">{{ count }}</span>
</span>
{% endif %}
{# Правая часть - кнопки и поиск #}
<div class="ms-auto d-flex align-items-center gap-2">
{# Переключатель области (Мои/Все) #}
{% if scope_urls %}
<div class="btn-group page-scope-switch" role="group" aria-label="Область">
<a href="{{ scope_urls.mine }}"
class="btn btn-outline-secondary btn-sm {% if scope == 'mine' %}active{% endif %}">
Мои
</a>
<a href="{{ scope_urls.all }}"
class="btn btn-outline-secondary btn-sm {% if scope == 'all' %}active{% endif %}">
Все
</a>
</div>
<div class="page-header">
<div class="d-flex align-items-center gap-2">
{# Иконка #}
{% if icon %}
<i class="{{ icon }} fs-2 text-primary flex-shrink-0"></i>
{% endif %}
{# Дополнительные кнопки (slot) #}
{% if extra_buttons %}
{{ extra_buttons }}
{# Заголовок #}
<h2 class="page-title mb-0">{{ title }}</h2>
{# Счётчик (если есть) #}
{% if count is not None %}
<span class="page-count text-secondary">
{{ count_label|default:"Всего:" }} <span class="count-value">{{ count }}</span>
</span>
{% endif %}
{# Поиск iOS-стиль #}
{% if not no_search and search_id %}
<div class="page-search ios-search">
<i class="bi-search"></i>
<input type="search"
id="{{ search_id }}"
class="ios-search-input"
placeholder="{{ search_placeholder|default:'Поиск...' }}"
autocomplete="off">
<button type="button" class="ios-search-clear" aria-label="Очистить">
<i class="bi-x-lg"></i>
</button>
</div>
{% endif %}
{# Кнопка раскрытия фильтров #}
{% if has_filters and filters_target %}
<button class="btn btn-outline-secondary btn-circle page-filter-toggle"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ filters_target }}"
aria-expanded="false"
aria-controls="{{ filters_target }}"
aria-label="Показать фильтры">
<i class="bi-funnel"></i>
</button>
{% endif %}
{# Кнопка создания (только если create_as_card не установлен) #}
{% if not no_create and not create_as_card %}
{% if create_modal %}
<button type="button"
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn"
data-bs-toggle="modal"
data-bs-target="#{{ create_modal }}">
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
</button>
{% elif create_url %}
<a href="{{ create_url }}"
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn">
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
</a>
{# Правая часть - кнопки и поиск #}
<div class="ms-auto d-flex align-items-center gap-2">
{# Переключатель области (Мои/Все) #}
{% if scope_urls %}
<div class="btn-group page-scope-switch" role="group" aria-label="Область">
<a href="{{ scope_urls.mine }}"
class="btn btn-outline-secondary btn-sm {% if scope == 'mine' %}active{% endif %}">
Мои
</a>
<a href="{{ scope_urls.all }}"
class="btn btn-outline-secondary btn-sm {% if scope == 'all' %}active{% endif %}">
Все
</a>
</div>
{% endif %}
{% endif %}
{# Дополнительные кнопки (slot) #}
{% if extra_buttons %}
{{ extra_buttons }}
{% endif %}
{# Поиск iOS-стиль #}
{% if not no_search and search_id %}
<div class="page-search ios-search">
<i class="bi-search"></i>
<input type="search"
id="{{ search_id }}"
class="ios-search-input"
placeholder="{{ search_placeholder|default:'Поиск...' }}"
autocomplete="off">
<button type="button" class="ios-search-clear" aria-label="Очистить">
<i class="bi-x-lg"></i>
</button>
</div>
{% endif %}
{# Кнопка раскрытия фильтров #}
{% if has_filters and filters_target %}
<button class="btn btn-outline-secondary btn-circle page-filter-toggle"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ filters_target }}"
aria-expanded="false"
aria-controls="{{ filters_target }}"
aria-label="Показать фильтры">
<i class="bi-funnel"></i>
</button>
{% endif %}
{# Кнопка создания (только если create_as_card не установлен) #}
{% if not no_create and not create_as_card %}
{% if create_modal %}
<button type="button"
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn"
data-bs-toggle="modal"
data-bs-target="#{{ create_modal }}">
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
</button>
{% elif create_url %}
<a href="{{ create_url }}"
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn">
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
</a>
{% endif %}
{% endif %}
</div>
</div>
{# ===== БЛОК ФИЛЬТРОВ (скрываемый) ===== #}
{% if has_filters and filters_target %}
<div class="collapse page-filters-collapse" id="{{ filters_target }}">
<div class="page-filters">
{% if filters_template %}
{# Включаем внешний шаблон фильтров #}
{% include filters_template %}
{% else %}
{# Или используем переданный HTML #}
{{ filters_html|safe }}
{% endif %}
</div>
</div>
{% endif %}
{# ===== КНОПКА СОЗДАНИЯ В ВИДЕ КАРТОЧКИ ===== #}
{% if not no_create and create_as_card %}
{% if create_modal %}
<div class="feed-card mb-2">
<button type="button"
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
data-bs-toggle="modal"
data-bs-target="#{{ create_modal }}"
role="button"
aria-label="{{ create_text|default:'Создать' }}">
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
</button>
</div>
{% elif create_url %}
<div class="feed-card mb-2">
<a href="{{ create_url }}"
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
role="button"
aria-label="{{ create_text|default:'Создать' }}">
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
</a>
</div>
{% endif %}
{% endif %}
</div>
{# ===== БЛОК ФИЛЬТРОВ (скрываемый) ===== #}
{% if has_filters and filters_target %}
<div class="collapse page-filters-collapse" id="{{ filters_target }}">
<div class="page-filters">
{% if filters_template %}
{# Включаем внешний шаблон фильтров #}
{% include filters_template %}
{% else %}
{# Или используем переданный HTML #}
{{ filters_html|safe }}
{% endif %}
</div>
</div>
{% endif %}
{# ===== КНОПКА СОЗДАНИЯ В ВИДЕ КАРТОЧКИ ===== #}
{% if not no_create and create_as_card %}
{% if create_modal %}
<div class="feed-card mb-2">
<button type="button"
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
data-bs-toggle="modal"
data-bs-target="#{{ create_modal }}"
role="button"
aria-label="{{ create_text|default:'Создать' }}">
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
</button>
</div>
{% elif create_url %}
<div class="feed-card mb-2">
<a href="{{ create_url }}"
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
role="button"
aria-label="{{ create_text|default:'Создать' }}">
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
</a>
</div>
{% endif %}
{% endif %}