feat: добавлена бесконечная прокрутка для заявлений

- Создан компонент requestListHandler.js для AJAX загрузки
- Упрощен view my_requests - данные загружаются через API
- Обновлен шаблон my_requests.html на AJAX загрузку
- Добавлен поиск по типу заявления и комментарию
- Intersection Observer для автоматической подгрузки
- Фильтрация по статусу заявления
- Удалена серверная пагинация
- Добавлены спиннеры загрузки
This commit is contained in:
admin i 2025-12-05 13:31:55 +03:00
parent a473808b10
commit 37e2b67c2f
3 changed files with 464 additions and 91 deletions

View File

@ -242,13 +242,9 @@ def _get_request_for_comment_access(user, pk: int) -> Request:
# ---------- Пользователь ----------
@login_required
def my_requests(request):
qs = BASE_REQUEST_QS.filter(employee=request.user)
qs, filters_ctx, errors = _apply_filters(qs, request, for_all=False)
if errors:
messages.error(request, " ".join(errors))
page = _paginate(qs, PAGINATE_MY, request.GET.get("page"))
# Упрощенный view - данные загружаются через AJAX
return render(
request, "requests_app/my_requests.html", {"page": page, "filters": filters_ctx}
request, "requests_app/my_requests.html", {"requests": []}
)

View File

@ -0,0 +1,420 @@
/**
* @module requestListHandler
* @description Загружает и отображает список заявлений через API с бесконечной прокруткой
*/
/**
* Инициализирует обработчик списка заявлений
* @param {Object} options - Опции инициализации
* @param {string} options.apiListUrl - URL API для списка заявлений
* @param {string} options.scope - Область ('my' | 'all')
* @param {number} options.userId - ID текущего пользователя
* @param {Object} options.headers - HTTP заголовки для запросов
* @returns {Object} API с методами load, destroy
*/
export function initRequestListHandler(options) {
const {
apiListUrl,
scope = 'my',
userId,
headers = {}
} = options;
const listElement = document.getElementById('requestList');
const searchInput = document.getElementById('requestFilter');
if (!listElement) {
console.warn('initRequestListHandler: #requestList not found');
return { load: () => {}, destroy: () => {} };
}
// Текущее состояние
const urlParams = new URLSearchParams(window.location.search);
let currentScope = scope;
let currentStatus = urlParams.get('status') || ''; // 'pending', 'approved', 'rejected', 'cancelled', 'draft'
let searchQuery = ''; // Поисковый запрос
let allRequests = []; // Все загруженные заявления
let loading = false;
let hasMore = true;
let nextUrl = null;
let totalCount = 0;
console.log('requestListHandler: init with scope =', currentScope, 'status =', currentStatus);
// Intersection Observer для бесконечной прокрутки
let observerTarget = null;
let observer = null;
/**
* Загружает заявления с API
* @param {boolean} append - Добавить к существующим или заменить
* @returns {Promise<Array>} Массив новых заявлений
*/
async function loadRequests(append = false) {
if (loading || (append && !hasMore)) {
console.log('loadRequests: skipped', { loading, append, hasMore });
return [];
}
loading = true;
showLoadingSpinner();
try {
let url;
if (append && nextUrl) {
url = nextUrl;
console.log('loadRequests: using nextUrl', url);
} else if (append && !nextUrl) {
console.warn('loadRequests: append=true but no nextUrl, stopping');
loading = false;
hideLoadingSpinner();
return [];
} else {
// Формируем URL с параметрами
const params = new URLSearchParams();
if (currentScope === 'my') {
params.append('scope', 'my');
}
if (currentStatus) {
params.append('status', currentStatus);
}
url = `${apiListUrl}?${params}`;
console.log('loadRequests: initial load', url, 'scope:', currentScope, 'status:', currentStatus);
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const data = await response.json();
const newRequests = data.results || [];
console.log('loadRequests: received', {
newCount: newRequests.length,
totalCount: data.count,
hasNext: !!data.next,
currentRequests: allRequests.length
});
// Сохраняем метаданные
totalCount = data.count || newRequests.length;
nextUrl = data.next || null;
hasMore = !!nextUrl;
if (append) {
allRequests = [...allRequests, ...newRequests];
console.log('loadRequests: appended, total now:', allRequests.length);
} else {
allRequests = newRequests;
console.log('loadRequests: replaced, total now:', allRequests.length);
}
loading = false;
hideLoadingSpinner();
return newRequests;
} catch (error) {
console.error('Ошибка загрузки заявлений:', error);
loading = false;
hideLoadingSpinner();
listElement.innerHTML = '<div class="p-4 text-center text-danger">Не удалось загрузить заявления</div>';
return [];
}
}
/**
* Показать спиннер загрузки
*/
function showLoadingSpinner() {
const existing = listElement.querySelector('.loading-spinner');
if (existing) return;
const spinner = document.createElement('div');
spinner.className = 'loading-spinner text-center py-4';
spinner.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
`;
listElement.appendChild(spinner);
}
/**
* Скрыть спиннер загрузки
*/
function hideLoadingSpinner() {
const spinner = listElement.querySelector('.loading-spinner');
if (spinner) {
spinner.remove();
}
}
/**
* Загрузка следующей порции заявлений
*/
async function loadMore() {
console.log('loadMore: triggered', {
hasMore,
loading,
nextUrl,
currentCount: allRequests.length
});
try {
const newRequests = await loadRequests(true); // append = true
renderRequests(newRequests, true); // append = true
} catch (error) {
console.error('Failed to load more requests:', error);
}
}
/**
* Настройка Intersection Observer для бесконечной прокрутки
*/
function setupInfiniteScroll() {
if (!observerTarget) return;
// Создаем observer
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
});
},
{
root: null,
rootMargin: '100px',
threshold: 0.01
}
);
// Наблюдаем за observer target
observer.observe(observerTarget);
}
/**
* Фильтрует по поисковому запросу
* @param {Array} items - Список заявлений
* @returns {Array} Отфильтрованный список
*/
function filterBySearch(items) {
if (!searchQuery.trim()) return items;
const query = searchQuery.toLowerCase().trim();
return items.filter(req => {
const type = (req.type_display || req.type || '').toLowerCase();
const comment = (req.comment || '').toLowerCase();
return type.includes(query) || comment.includes(query);
});
}
/**
* Отображает список заявлений
* @param {Array} requests - Заявления для рендеринга
* @param {boolean} append - Добавить к существующим или заменить
*/
function renderRequests(requests, append = false) {
if (!requests || requests.length === 0) {
if (!append) {
listElement.innerHTML = '<div class="p-4 text-center text-secondary">Нет заявлений.</div>';
}
return;
}
let items = filterBySearch(requests);
if (items.length === 0 && !append) {
listElement.innerHTML = '<div class="p-4 text-center text-secondary">Нет заявлений.</div>';
return;
}
console.log('renderRequests: rendering', {
count: items.length,
append,
totalInMemory: allRequests.length
});
const itemsHtml = items.map(req => renderRequestCard(req)).join('');
if (append) {
// Удаляем observer target перед добавлением
if (observerTarget && observerTarget.parentNode) {
observerTarget.remove();
}
// Добавляем новые карточки
listElement.insertAdjacentHTML('beforeend', itemsHtml);
console.log('renderRequests: appended to DOM');
// Возвращаем observer target обратно
if (observerTarget) {
listElement.appendChild(observerTarget);
}
} else {
listElement.innerHTML = itemsHtml;
console.log('renderRequests: replaced DOM');
// Создаем observer target при первой загрузке
if (hasMore && !observerTarget) {
observerTarget = document.createElement('div');
observerTarget.className = 'load-more-trigger';
observerTarget.style.height = '1px';
listElement.appendChild(observerTarget);
}
}
}
/**
* Рендерит карточку заявления
* @param {Object} req - Заявление
* @returns {string} HTML
*/
function renderRequestCard(req) {
const statusBadge = getStatusBadge(req.status);
const typeDisplay = req.type_display || req.type || 'Неизвестный тип';
const createdAt = formatDate(req.created_at);
const detailUrl = `/requests/${req.id}/`;
return `
<article class="card">
<header class="card-header">
<div class="card-icon" style="width:48px;height:48px;">
<i class="bi-file-earmark-text"></i>
</div>
<div class="card-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="card-title">${escapeHtml(typeDisplay)}</div>
${statusBadge}
</div>
<div class="card-subtitle mt-1">
<time datetime="${req.created_at}">${createdAt}</time>
${req.comment ? ` • ${escapeHtml(req.comment.substring(0, 100))}${req.comment.length > 100 ? '...' : ''}` : ''}
</div>
</div>
<div class="ms-auto">
<a href="${detailUrl}" class="btn btn-outline-primary btn-sm">
<i class="bi-eye"></i> Подробнее
</a>
</div>
</header>
</article>
`;
}
/**
* Возвращает HTML для бейджа статуса
* @param {string} status - Статус заявления
* @returns {string} HTML
*/
function getStatusBadge(status) {
const badges = {
'pending': '<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">На рассмотрении</span>',
'approved': '<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle">Одобрено</span>',
'rejected': '<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle">Отклонено</span>',
'cancelled': '<span class="badge bg-dark-subtle text-dark-emphasis border border-dark-subtle">Отменено</span>',
'draft': '<span class="badge bg-light text-body border">Черновик</span>'
};
return badges[status] || '<span class="badge bg-light text-body border">Неизвестно</span>';
}
/**
* Форматирует дату
* @param {string} dateStr - ISO дата
* @returns {string} Отформатированная дата
*/
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Экранирует HTML
* @param {string} str - Строка
* @returns {string} Экранированная строка
*/
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* Публичный API для изменения фильтров
*/
async function setStatus(status) {
currentStatus = status;
hasMore = true;
nextUrl = null;
allRequests = [];
const reqs = await loadRequests(false);
renderRequests(reqs, false);
setupInfiniteScroll();
}
/**
* Обработчик поиска (работает только с уже загруженными заявлениями)
*/
function handleSearch() {
// Применяем фильтрацию ко всем загруженным заявлениям
renderRequests(allRequests, false);
}
/**
* Настройка обработчика поиска
*/
function setupSearch() {
if (!searchInput) return;
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchQuery = e.target.value;
// Debounce: ждем 300ms после последнего ввода
searchTimeout = setTimeout(() => {
console.log('Search query:', searchQuery);
handleSearch();
}, 300);
});
}
/**
* Очистка обработчиков
*/
function destroy() {
if (observer) {
observer.disconnect();
observer = null;
}
}
// Начальная загрузка
(async () => {
hasMore = true;
const reqs = await loadRequests(false);
renderRequests(reqs, false);
setupInfiniteScroll();
setupSearch();
})();
return {
load: loadRequests,
setStatus,
destroy
};
}
// Экспорт для совместимости
if (typeof window !== 'undefined') {
window.initRequestListHandler = initRequestListHandler;
}

View File

@ -3,99 +3,56 @@
{% block title %}Мои заявления{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/common.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/list-view.css' %}">
{% endblock %}
{% block content %}
<div class="container">
<div class="row gx-0">
<div class="col-12">
<div class="col-12">
{# — Шапка страницы — #}
<div class="section-header">
<i class="bi-file-earmark-text fs-2 text-primary"></i>
<h2 class="title mb-0">Мои заявления</h2>
<div class="ms-auto">
<a href="{% url 'requests_app:request_create' %}" class="btn btn-primary">
<i class="bi-plus-circle me-1"></i> Подать новое заявление
</a>
{% include "includes/components/page_header.html" with icon="bi-file-earmark-text" title="Мои заявления" search_id="requestFilter" search_placeholder="Поиск по типу или комментарию" create_url="requests_app:request_create" create_text="Подать новое заявление" %}
<div class="card mb-3">
<div id="requestList" class="list-view card-list">
{# Список загружается через JavaScript из requestListHandler.js #}
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
{% if page.object_list %}
<div class="card-list">
{% for req in page.object_list %}
<article class="card">
<header class="card-header">
<div class="card-icon" style="width:48px;height:48px;">
<i class="bi-file-earmark-text"></i>
</div>
<div class="card-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="card-title">{{ req.get_type_display }}</div>
{# — Статус — #}
{% if req.status == 'pending' %}
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">На рассмотрении</span>
{% elif req.status == 'approved' %}
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle">Одобрено</span>
{% elif req.status == 'rejected' %}
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle">Отклонено</span>
{% elif req.status == 'cancelled' %}
<span class="badge bg-dark-subtle text-dark-emphasis border border-dark-subtle">Отменено</span>
{% elif req.status == 'draft' %}
<span class="badge bg-light text-body border">Черновик</span>
{% else %}
<span class="badge bg-light text-body border">Неизвестно</span>
{% endif %}
</div>
<div class="card-subtitle mt-1">
<time datetime="{{ req.created_at|date:'c' }}">{{ req.created_at|date:"d.m.Y H:i" }}</time>
{% if req.date_from or req.date_to %}
· Период:
{{ req.date_from|date:'d.m.Y' }}
{% if req.date_to %} — {{ req.date_to|date:'d.m.Y' }}{% endif %}
{% endif %}
</div>
</div>
</header>
<div class="card-body pt-0">
{% if req.comment %}
<div class="small text-muted mb-2">{{ req.comment|truncatechars:220 }}</div>
{% endif %}
{% if req.attachment %}
<a class="btn btn-ghost d-inline-flex align-items-center gap-1" href="{{ req.attachment.url }}" target="_blank">
<i class="bi-paperclip"></i><span>Вложение</span>
</a>
{% endif %}
</div>
<footer class="card-actions">
<a href="{% url 'requests_app:request_detail' req.pk %}"
class="btn btn-ghost d-inline-flex align-items-center gap-1">
<i class="bi-eye"></i><span>Подробнее</span>
</a>
<span class="ms-auto"></span>
{% if req.status == 'pending' and not req.is_final %}
<a href="{% url 'requests_app:request_cancel' req.pk %}"
class="btn btn-ghost d-inline-flex align-items-center gap-1">
<i class="bi-x-circle"></i><span>Отозвать</span>
</a>
{% endif %}
</footer>
</article>
{% endfor %}
</div>
{# Пагинация #}
<div class="mt-4">
{% bootstrap_pagination page %}
</div>
{% else %}
<div class="alert alert-info shadow-sm">У вас пока нет заявлений.</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="module">
import { initRequestListHandler } from "{% static 'js/components/requestListHandler.js' %}";
// Получаем токен доступа
const accessMeta = document.querySelector('meta[name="api-access"]');
const ACCESS = accessMeta ? accessMeta.content : '';
const headers = ACCESS ? { 'Authorization': 'Bearer ' + ACCESS } : {};
// Инициализация списка заявлений
initRequestListHandler({
apiListUrl: '/api/v1/requests/',
scope: 'my',
userId: {{ user.id }},
headers
});
</script>
{% endblock %}
</div>
</div>
</div>