feat: add infinite scroll to employees list

- Added intersection observer for automatic loading
- Track nextUrl and hasMore from API response
- Append new employees instead of replacing
- Removed old pagination controls from template
- Show loading spinner while fetching more data
This commit is contained in:
admin i 2025-12-05 12:39:35 +03:00
parent 9ef3c53a46
commit 338b9cf065
2 changed files with 111 additions and 23 deletions

View File

@ -15,11 +15,13 @@ export class EmployeesList {
this.apiUrl = '/api/v1/employees/';
this.employees = [];
this.loading = false;
this.currentPage = 1;
this.hasMore = true;
this.nextUrl = null;
// Параметры из URL
const urlParams = new URLSearchParams(window.location.search);
this.params = {
page: urlParams.get('page') || 1,
ordering: urlParams.get('o') || 'last_name',
search: urlParams.get('q') || '',
department: urlParams.get('department') || '',
@ -37,6 +39,7 @@ export class EmployeesList {
try {
await this.loadEmployees();
this.render();
this.setupInfiniteScroll();
} catch (error) {
console.error('Failed to load employees:', error);
this.renderError();
@ -46,11 +49,17 @@ export class EmployeesList {
/**
* Загрузка сотрудников через API
*/
async loadEmployees() {
if (this.loading) return;
async loadEmployees(append = false) {
if (this.loading || (!this.hasMore && append)) return;
this.loading = true;
// Показываем loader
if (append) {
this.showLoadMoreSpinner();
}
const params = new URLSearchParams();
params.append('page', this.currentPage);
Object.entries(this.params).forEach(([key, value]) => {
if (value) params.append(key, value);
});
@ -61,8 +70,106 @@ export class EmployeesList {
}
const data = await response.json();
this.employees = data.results || data;
const newEmployees = data.results || data;
if (append) {
this.employees = [...this.employees, ...newEmployees];
} else {
this.employees = newEmployees;
}
// Обновляем информацию о пагинации
this.nextUrl = data.next;
this.hasMore = !!data.next;
this.loading = false;
// Убираем loader
if (append) {
this.hideLoadMoreSpinner();
}
}
/**
* Настройка бесконечной прокрутки
*/
setupInfiniteScroll() {
const observer = new IntersectionObserver(
(entries) => {
const lastEntry = entries[0];
if (lastEntry.isIntersecting && this.hasMore && !this.loading) {
this.loadMore();
}
},
{
root: null,
rootMargin: '100px',
threshold: 0.1
}
);
// Создаем маркер для отслеживания
const sentinel = document.createElement('div');
sentinel.id = 'empListSentinel';
sentinel.style.height = '1px';
this.container.parentElement.appendChild(sentinel);
observer.observe(sentinel);
this.infiniteScrollObserver = observer;
}
/**
* Загрузка следующей страницы
*/
async loadMore() {
if (!this.hasMore || this.loading) return;
this.currentPage++;
await this.loadEmployees(true);
this.appendNewEmployees();
}
/**
* Добавление новых сотрудников в конец списка
*/
appendNewEmployees() {
// Находим индекс последнего отрендеренного сотрудника
const currentCount = this.container.querySelectorAll('.emp-row').length;
const newEmployees = this.employees.slice(currentCount);
if (newEmployees.length === 0) return;
const newItemsHtml = newEmployees.map(emp => this.createEmployeeCard(emp)).join('');
this.container.insertAdjacentHTML('beforeend', newItemsHtml);
}
/**
* Показать spinner загрузки
*/
showLoadMoreSpinner() {
let spinner = document.getElementById('empListSpinner');
if (!spinner) {
spinner = document.createElement('div');
spinner.id = 'empListSpinner';
spinner.className = 'text-center py-3';
spinner.innerHTML = `
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
`;
this.container.parentElement.appendChild(spinner);
}
spinner.style.display = 'block';
}
/**
* Скрыть spinner загрузки
*/
hideLoadMoreSpinner() {
const spinner = document.getElementById('empListSpinner');
if (spinner) {
spinner.style.display = 'none';
}
}
/**

View File

@ -31,25 +31,6 @@
</div>
</div>
{# Пагинация #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}"
href="{{ prev_url|default:'#' }}"
{% if not prev_url %}aria-disabled="true"{% endif %}>
<i class="bi-arrow-left"></i> Назад
</a>
{% if count %}
<span class="text-body-secondary small">Всего: {{ count }}</span>
{% endif %}
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}"
href="{{ next_url|default:'#' }}"
{% if not next_url %}aria-disabled="true"{% endif %}>
Вперёд <i class="bi-arrow-right"></i>
</a>
</div>
{% endif %}
</div>
</div>
</div>