feat: переделана лента новых сотрудников на AJAX загрузку

- Создан новый JS модуль newEmployeesScroller.js
- Новые сотрудники теперь загружаются через API с клиента
- Аватары используют глобальные URL (как в модале)
- Убран серверный рендеринг из feed_list view
- Шаблон использует пустой контейнер для JS инициализации
- Добавлен полный collapse блок с последним комментарием
- Добавлена форма быстрого комментария с emoji picker
- Исправлена проблема с localhost URL в аватарах
This commit is contained in:
admin i 2025-12-05 10:56:23 +03:00
parent 3090d0eba7
commit c9a440c3d3
4 changed files with 294 additions and 58 deletions

View File

@ -133,18 +133,11 @@ def _post_back_url(post: dict, user) -> str:
def feed_list(request):
"""
Лента компании - посты загружаются через API на клиенте.
Блок «новые сотрудники» загружается с сервера.
Лента компании - посты и новые сотрудники загружаются через API на клиенте.
"""
# Загружаем только новых сотрудников с сервера
new_employees = _api_list_new_employees(request, limit=10)
return render(
request,
"feed/feed_list.html",
{
"new_employees": new_employees,
},
)

View File

@ -365,10 +365,11 @@ export class FeedList {
collapseDiv.id = `ccoll-${post.id}`;
collapseDiv.className = 'collapse';
// Заглушка - показываем кнопку "Показать все комментарии"
// Контейнер с padding
const contentDiv = document.createElement('div');
contentDiv.className = 'px-3 pt-2';
// Кнопка "Показать все комментарии"
const linkContainer = document.createElement('div');
linkContainer.className = 'd-flex justify-content-between align-items-center mb-2';
@ -381,11 +382,141 @@ export class FeedList {
linkContainer.appendChild(showAllLink);
contentDiv.appendChild(linkContainer);
// Последний комментарий (если есть)
if (post.last_comment) {
const commentDiv = this.createLastComment(post.last_comment);
contentDiv.appendChild(commentDiv);
}
collapseDiv.appendChild(contentDiv);
// Форма быстрого комментария
const form = this.createQuickCommentForm(post.id);
collapseDiv.appendChild(form);
return collapseDiv;
}
/**
* Создание последнего комментария
*/
createLastComment(comment) {
const commentDiv = document.createElement('div');
commentDiv.className = 'comment-item';
// Header с автором и датой
const headerDiv = document.createElement('div');
headerDiv.className = 'comment-header';
// Автор
if (comment.author?.id) {
const authorLink = document.createElement('a');
authorLink.href = `/employees/${comment.author.id}/`;
authorLink.className = 'comment-author';
authorLink.textContent = comment.author.full_name || 'Сотрудник';
headerDiv.appendChild(authorLink);
} else {
const authorSpan = document.createElement('span');
authorSpan.className = 'comment-author';
authorSpan.textContent = comment.author?.full_name || 'Сотрудник';
headerDiv.appendChild(authorSpan);
}
// Разделитель
const separator = document.createElement('span');
separator.className = 'comment-separator';
separator.textContent = '·';
headerDiv.appendChild(separator);
// Дата
const timeEl = document.createElement('time');
timeEl.className = 'comment-date';
timeEl.textContent = comment.created_at_display || comment.created_at || '';
headerDiv.appendChild(timeEl);
commentDiv.appendChild(headerDiv);
// Тело комментария (обрезанное до 220 символов)
const bodyDiv = document.createElement('div');
bodyDiv.className = 'comment-body';
let text = comment.text || '';
if (text.length > 220) {
text = text.substring(0, 217) + '...';
}
bodyDiv.textContent = text;
commentDiv.appendChild(bodyDiv);
return commentDiv;
}
/**
* Создание формы быстрого комментария
*/
createQuickCommentForm(postId) {
const form = document.createElement('form');
form.className = 'px-3 pb-3 pt-2 comment-form-quick';
form.dataset.postId = postId;
// Message field container
const messageField = document.createElement('div');
messageField.className = 'message-field message-field--compact';
// Emoji picker dropdown
const emojiDropdown = document.createElement('div');
emojiDropdown.className = 'dropdown message-emoji';
const emojiBtn = document.createElement('button');
emojiBtn.type = 'button';
emojiBtn.className = 'btn btn-ghost btn-emoji message-icon-btn';
emojiBtn.dataset.bsToggle = 'dropdown';
emojiBtn.setAttribute('aria-expanded', 'false');
emojiBtn.title = 'Вставить смайлик';
const emojiIcon = document.createElement('i');
emojiIcon.className = 'bi-emoji-smile';
emojiBtn.appendChild(emojiIcon);
const emojiMenu = document.createElement('div');
emojiMenu.className = 'dropdown-menu dropdown-menu-start message-emoji-menu';
const emojiPicker = document.createElement('emoji-picker');
emojiPicker.dataset.emojiPicker = '';
emojiPicker.className = 'chat-emoji-picker';
emojiMenu.appendChild(emojiPicker);
emojiDropdown.appendChild(emojiBtn);
emojiDropdown.appendChild(emojiMenu);
messageField.appendChild(emojiDropdown);
// Textarea
const textarea = document.createElement('textarea');
textarea.name = 'text';
textarea.className = 'form-control message-input';
textarea.rows = 1;
textarea.placeholder = 'Оставить комментарий…';
textarea.required = true;
messageField.appendChild(textarea);
// Send button
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary message-send';
sendBtn.type = 'submit';
sendBtn.title = 'Отправить';
const sendIcon = document.createElement('i');
sendIcon.className = 'bi-send';
sendBtn.appendChild(sendIcon);
messageField.appendChild(sendBtn);
form.appendChild(messageField);
return form;
}
/**
* Показать лоадер
*/

View File

@ -0,0 +1,153 @@
/**
* Модуль для загрузки и отображения новых сотрудников
* Использует API для получения данных с корректными глобальными URL
*/
export class NewEmployeesScroller {
constructor(containerId = 'newEmployeesContainer') {
this.container = document.getElementById(containerId);
if (!this.container) {
console.warn(`Container #${containerId} not found`);
return;
}
this.apiUrl = '/api/v1/employees/';
}
/**
* Инициализация - загрузка и рендеринг
*/
async init() {
try {
const employees = await this.fetchNewEmployees();
if (employees && employees.length > 0) {
this.render(employees);
} else {
this.container.style.display = 'none';
}
} catch (error) {
console.error('Failed to load new employees:', error);
this.container.style.display = 'none';
}
}
/**
* Загрузка новых сотрудников через API
*/
async fetchNewEmployees() {
// Дата 14 дней назад
const since = new Date();
since.setDate(since.getDate() - 14);
const sinceISO = since.toISOString();
const params = new URLSearchParams({
active: 'true',
created_at__gte: sinceISO,
ordering: '-created_at',
page_size: '10'
});
const response = await fetch(`${this.apiUrl}?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data.results || data;
}
/**
* Рендеринг списка сотрудников
*/
render(employees) {
const scrollerHtml = this.createScrollerHtml(employees);
this.container.innerHTML = scrollerHtml;
this.container.style.display = 'block';
}
/**
* Создание HTML разметки скроллера
*/
createScrollerHtml(employees) {
const items = employees.map(emp => this.createEmployeeItem(emp)).join('');
return `
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary"></h6>
</div>
<div class="join-scroller js-join-scroller" aria-label="Новые сотрудники">
<div class="join-rail">
${items}
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary"></h6>
</div>
</div>
`;
}
/**
* Создание элемента сотрудника
*/
createEmployeeItem(emp) {
const empId = emp.id || emp.pk;
const empName = emp.short_name || emp.full_name || emp.email || 'Сотрудник';
const deptName = emp.department?.name || emp.department_name || '';
const avatar = emp.avatar;
// Аватар или иконка
let avatarHtml;
if (avatar) {
avatarHtml = `<img src="${avatar}" alt="" loading="lazy">`;
} else {
avatarHtml = `
<i class="bi-person" aria-hidden="true"></i>
<span class="visually-hidden">${this.escapeHtml(empName)}</span>
`;
}
// Навыки
let skillsHtml = '';
if (emp.skills && emp.skills.length > 0) {
const skillsList = emp.skills.map(s => this.escapeHtml(s.name)).join(', ');
skillsHtml = `<div class="skills-line"><b>Навыки:</b> ${skillsList}</div>`;
}
return `
<div class="join-item">
<a href="/employees/${empId}/"
class="join-ava text-decoration-none"
title="${this.escapeHtml(empName)}">
${avatarHtml}
</a>
<div class="join-info">
<div class="name">${this.escapeHtml(empName)}</div>
${deptName ? `<div class="sub">${this.escapeHtml(deptName)}</div>` : ''}
${skillsHtml}
</div>
</div>
`;
}
/**
* Экранирование HTML
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
/**
* Инициализация при загрузке DOM
*/
export function initNewEmployeesScroller(containerId) {
const scroller = new NewEmployeesScroller(containerId);
scroller.init();
return scroller;
}

View File

@ -21,55 +21,10 @@
{% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" search_id="feedFilter" search_placeholder="Поиск по названию, автору или содержанию" %}
{% endif %}
{% if new_employees %}
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary"></h6>
</div>
<div class="join-scroller js-join-scroller" aria-label="Новые сотрудники">
<div class="join-rail">
{% for emp in new_employees %}
{% firstof emp.id emp.pk as emp_id %}
{% firstof emp.avatar emp.avatar as ava %}
{% firstof emp.short_name emp.get_short_name emp.full_name emp.get_full_name emp.email "Сотрудник" as emp_name %}
{% firstof emp.department.name emp.department_name as dept_name %}
<div class="join-item">
<a href="{% url 'employees:employee_detail' emp_id %}"
class="join-ava text-decoration-none"
title="{{ emp_name }}">
{% if ava %}
<img src="{{ ava.url|default:ava }}" alt="" loading="lazy">
{% else %}
<i class="bi-person" aria-hidden="true"></i>
<span class="visually-hidden">{{ emp_name }}</span>
{% endif %}
</a>
<div class="join-info">
<div class="name">{{ emp_name }}</div>
{% if dept_name %}<div class="sub">{{ dept_name }}</div>{% endif %}
{# НАВЫКИ — печать всех, без итерации по строкам #}
{% if emp.skills and emp.skills.0.name %}
<div class="skills-line"><b>Навыки:</b>
{% for s in emp.skills %}{% if not forloop.first %}, {% endif %}{{ s.name }}{% endfor %}
</div>
{% endif %}
</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 %}
{# Контейнер для новых сотрудников - загружается через JS #}
<div id="newEmployeesContainer" style="display: none;">
{# Загружается через newEmployeesScroller.js #}
</div>
<section class="card mb-3">
<div class="card-header">
@ -98,6 +53,7 @@
import { initCreatePostModal } from "{% static 'js/components/createPostModal.js' %}";
import { FeedList } from '{% static "js/components/feedList.js" %}';
import { ListFilter, createDataAttrMatcher } from '{% static "js/components/listFilter.js" %}';
import { initNewEmployeesScroller } from '{% static "js/components/newEmployeesScroller.js" %}';
document.addEventListener('DOMContentLoaded', () => {
initAutoScroller({
@ -117,6 +73,9 @@
params: { type: 'company' }
});
// 🆕 Загрузка новых сотрудников через AJAX
initNewEmployeesScroller('newEmployeesContainer');
// Обновляем ленту после создания поста
if (createModal) {
document.getElementById('createPostForm')?.addEventListener('submit', async (e) => {