feat: добавлена бесконечная прокрутка для заявлений
- Создан компонент requestListHandler.js для AJAX загрузки - Упрощен view my_requests - данные загружаются через API - Обновлен шаблон my_requests.html на AJAX загрузку - Добавлен поиск по типу заявления и комментарию - Intersection Observer для автоматической подгрузки - Фильтрация по статусу заявления - Удалена серверная пагинация - Добавлены спиннеры загрузки
This commit is contained in:
parent
bc51d7c326
commit
4822eee65e
@ -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": []}
|
||||
)
|
||||
|
||||
|
||||
|
||||
420
backend/static/js/components/requestListHandler.js
Normal file
420
backend/static/js/components/requestListHandler.js
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user