From 37e2b67c2f33bef3a58f051c6c6471d378ea5f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 5 Dec 2025 13:31:55 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B1=D0=B5=D1=81=D0=BA=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан компонент requestListHandler.js для AJAX загрузки - Упрощен view my_requests - данные загружаются через API - Обновлен шаблон my_requests.html на AJAX загрузку - Добавлен поиск по типу заявления и комментарию - Intersection Observer для автоматической подгрузки - Фильтрация по статусу заявления - Удалена серверная пагинация - Добавлены спиннеры загрузки --- backend/requests_app/views.py | 8 +- .../js/components/requestListHandler.js | 420 ++++++++++++++++++ .../templates/requests_app/my_requests.html | 127 ++---- 3 files changed, 464 insertions(+), 91 deletions(-) create mode 100644 backend/static/js/components/requestListHandler.js diff --git a/backend/requests_app/views.py b/backend/requests_app/views.py index 1bd73ff2..c8921ab5 100644 --- a/backend/requests_app/views.py +++ b/backend/requests_app/views.py @@ -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": []} ) diff --git a/backend/static/js/components/requestListHandler.js b/backend/static/js/components/requestListHandler.js new file mode 100644 index 00000000..d148a8f4 --- /dev/null +++ b/backend/static/js/components/requestListHandler.js @@ -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} Массив новых заявлений + */ + 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 = '
Не удалось загрузить заявления
'; + 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 = ` +
+ Загрузка... +
+ `; + 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 = '
Нет заявлений.
'; + } + return; + } + + let items = filterBySearch(requests); + + if (items.length === 0 && !append) { + listElement.innerHTML = '
Нет заявлений.
'; + 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 ` +
+
+
+ +
+ +
+
+
${escapeHtml(typeDisplay)}
+ ${statusBadge} +
+
+ + ${req.comment ? ` • ${escapeHtml(req.comment.substring(0, 100))}${req.comment.length > 100 ? '...' : ''}` : ''} +
+
+ + +
+
+ `; + } + + /** + * Возвращает HTML для бейджа статуса + * @param {string} status - Статус заявления + * @returns {string} HTML + */ + function getStatusBadge(status) { + const badges = { + 'pending': 'На рассмотрении', + 'approved': 'Одобрено', + 'rejected': 'Отклонено', + 'cancelled': 'Отменено', + 'draft': 'Черновик' + }; + return badges[status] || 'Неизвестно'; + } + + /** + * Форматирует дату + * @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; +} diff --git a/backend/templates/requests_app/my_requests.html b/backend/templates/requests_app/my_requests.html index 33677727..e3dcad32 100644 --- a/backend/templates/requests_app/my_requests.html +++ b/backend/templates/requests_app/my_requests.html @@ -3,99 +3,56 @@ {% block title %}Мои заявления{% endblock %} +{% block extra_css %} + + + + +{% endblock %} + {% block content %}
-
+
{# — Шапка страницы — #} -
- -

Мои заявления

-
- - Подать новое заявление - + {% 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="Подать новое заявление" %} + +
+
+ {# Список загружается через JavaScript из requestListHandler.js #} +
+
+ Загрузка... +
+
- {% if page.object_list %} -
- {% for req in page.object_list %} -
-
-
- -
- -
-
-
{{ req.get_type_display }}
- {# — Статус — #} - {% if req.status == 'pending' %} - На рассмотрении - {% elif req.status == 'approved' %} - Одобрено - {% elif req.status == 'rejected' %} - Отклонено - {% elif req.status == 'cancelled' %} - Отменено - {% elif req.status == 'draft' %} - Черновик - {% else %} - Неизвестно - {% endif %} -
-
- - {% 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 %} -
-
-
- -
- {% if req.comment %} -
{{ req.comment|truncatechars:220 }}
- {% endif %} - - {% if req.attachment %} - - Вложение - - {% endif %} -
- - -
- {% endfor %} -
- - {# Пагинация #} -
- {% bootstrap_pagination page %} -
- {% else %} -
У вас пока нет заявлений.
- {% endif %} - +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} +