diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index f1ca899a..7496ae4f 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -74,7 +74,7 @@ class RequestReadSerializer(serializers.ModelSerializer): is_final = serializers.BooleanField(read_only=True) # URL для вложенного файла (аналогично file_url в DocumentReadSerializer) - attachment_url = serializers.FileField(source="attachment", read_only=True, use_url=True) + attachment_url = serializers.FileField(source="attachment", read_only=True) # Новые поля для получателей departments = serializers.PrimaryKeyRelatedField( diff --git a/backend/requests_app/templatetags/req_tags.py b/backend/requests_app/templatetags/req_tags.py index 609075d5..f03ba856 100644 --- a/backend/requests_app/templatetags/req_tags.py +++ b/backend/requests_app/templatetags/req_tags.py @@ -4,33 +4,6 @@ from datetime import datetime register = template.Library() -@register.simple_tag(takes_context=True) -def absolute_url(context, relative_url): - """ - Преобразует относительный URL в абсолютный с доменом. - - Args: - context: контекст шаблона (автоматически передаётся) - relative_url: относительный путь (например, /media/file.pdf) - - Returns: - Полный URL с доменом (например, https://domain.com/media/file.pdf) - """ - if not relative_url: - return "" - - # Если уже абсолютный URL - возвращаем как есть - if relative_url.startswith(('http://', 'https://')): - return relative_url - - request = context.get('request') - if request: - return request.build_absolute_uri(relative_url) - - # Fallback - возвращаем относительный URL - return relative_url - - @register.filter def in_list(value, csv): items = [s.strip() for s in str(csv).split(",") if s.strip()] diff --git a/backend/static/js/components/requestListHandler.js b/backend/static/js/components/requestListHandler.js new file mode 100644 index 00000000..93e6df05 --- /dev/null +++ b/backend/static/js/components/requestListHandler.js @@ -0,0 +1,492 @@ +/** + * @module requestListHandler + * @description Загружает и отображает список заявлений через API + */ + +/** + * Инициализирует обработчик списка заявлений + * @param {Object} options - Опции инициализации + * @param {string} options.apiListUrl - URL API для списка заявлений + * @param {string} options.detailUrlTemplate - Шаблон URL для детального просмотра: "/requests/{id}/" + * @param {number} options.userId - ID текущего пользователя + * @param {boolean} options.canProcess - Может ли пользователь обрабатывать заявления + * @param {Object} options.headers - HTTP заголовки для запросов + * @returns {Object} API с методами load, destroy + */ +export function initRequestListHandler(options) { + const { + apiListUrl, + detailUrlTemplate, + userId, + canProcess, + headers = {} + } = options; + + const listElement = document.getElementById('requestList'); + const searchInput = document.getElementById('reqFilter'); + + if (!listElement) { + console.warn('initRequestListHandler: #requestList not found'); + return { load: () => {}, destroy: () => {} }; + } + + // Текущее состояние + const urlParams = new URLSearchParams(window.location.search); + let currentView = urlParams.get('view') || ''; // 'mine' | 'addressed' | '' + let currentType = urlParams.get('type') || ''; + let currentStatus = urlParams.get('status') || ''; + let searchQuery = ''; // Поисковый запрос + let allRequests = []; // Все загруженные заявления + let loading = false; + let hasMore = true; + let nextUrl = null; + let totalCount = 0; + + console.log('requestListHandler: init with view =', currentView, 'type =', currentType, '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 (currentView === 'mine') { + params.append('view', 'mine'); + } else if (currentView === 'addressed') { + params.append('addressed_to_me', 'true'); + } + if (currentType) { + params.append('type', currentType); + } + if (currentStatus) { + params.append('status', currentStatus); + } + + url = `${apiListUrl}?${params}`; + console.log('loadRequests: initial load', url); + } + + 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 title = (req.display_title || req.title || '').toLowerCase(); + const comment = (req.comment || '').toLowerCase(); + const employeeName = (req.employee?.full_name || '').toLowerCase(); + return title.includes(query) || comment.includes(query) || employeeName.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 => renderRequestRow(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 renderRequestRow(req) { + const statusClass = getStatusClass(req.status); + const statusText = getStatusText(req.status); + const detailUrl = detailUrlTemplate.replace('{id}', req.id); + + return ` +
+
+ + +
+ + ${escapeHtml(req.employee?.full_name || 'Неизвестно')} + + ${req.date_from ? ` + + + ${formatDate(req.date_from)}${req.date_to ? ' — ' + formatDate(req.date_to) : ''} + + ` : ''} + + ${formatDate(req.created_at)} + +
+
+ + ${req.comment ? ` +
${escapeHtml(req.comment)}
+ ` : ''} + +
+ ${req.approver ? ` +
+ + Согласующий: ${escapeHtml(req.approver.full_name)} +
+ ` : ''} + + ${req.recipient_count > 0 ? ` +
+ + Получатели: ${req.recipient_count} +
+ ` : ''} + + ${req.attachment_url ? ` + + Вложение + + ` : ''} +
+
+ `; + } + + /** + * Получает CSS класс для статуса + * @param {string} status - Статус заявления + * @returns {string} CSS класс + */ + function getStatusClass(status) { + const classes = { + 'pending': 'warning', + 'approved': 'success', + 'rejected': 'danger', + 'cancelled': 'secondary' + }; + return classes[status] || 'secondary'; + } + + /** + * Получает текст статуса + * @param {string} status - Статус заявления + * @returns {string} Текст статуса + */ + function getStatusText(status) { + const texts = { + 'pending': 'На рассмотрении', + 'approved': 'Одобрено', + 'rejected': 'Отклонено', + 'cancelled': 'Отменено' + }; + return texts[status] || status; + } + + /** + * Форматирует дату + * @param {string} dateStr - ISO дата + * @returns {string} Отформатированная дата + */ + function formatDate(dateStr) { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + } + + /** + * Экранирует HTML + * @param {string} str - Строка + * @returns {string} Экранированная строка + */ + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + /** + * Публичный API для изменения фильтров + */ + async function setView(view) { + currentView = view; + hasMore = true; + nextUrl = null; + allRequests = []; + const reqs = await loadRequests(false); + renderRequests(reqs, false); + setupInfiniteScroll(); + } + + async function setType(type) { + currentType = type; + hasMore = true; + nextUrl = null; + allRequests = []; + const reqs = await loadRequests(false); + renderRequests(reqs, false); + setupInfiniteScroll(); + } + + 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, + setView, + setType, + setStatus, + destroy + }; +} + +// Экспорт для совместимости +if (typeof window !== 'undefined') { + window.initRequestListHandler = initRequestListHandler; +} diff --git a/backend/static/js/pages/requestList.js b/backend/static/js/pages/requestList.js index a1b7cf99..4f82b77f 100644 --- a/backend/static/js/pages/requestList.js +++ b/backend/static/js/pages/requestList.js @@ -6,6 +6,7 @@ import { initRequestModalHandler } from '../components/requestModalHandler.js'; import { initRequestCommentsHandler } from '../components/requestCommentsHandler.js'; import { initRequestCrudHandler } from '../components/requestCrudHandler.js'; +import { initRequestListHandler } from '../components/requestListHandler.js'; /** * Инициализирует страницу списка заявлений @@ -15,6 +16,7 @@ import { initRequestCrudHandler } from '../components/requestCrudHandler.js'; * @param {string} config.commentsUrlTemplate - Шаблон URL для получения комментариев * @param {string} config.addCommentUrlTemplate - Шаблон URL для добавления комментария * @param {boolean} [config.autoShowComments=false] - Автоматически показать комментарии + * @param {boolean} [config.useAjaxList=false] - Использовать AJAX для загрузки списка */ export function initRequestListPage(config) { const { @@ -22,7 +24,8 @@ export function initRequestListPage(config) { apiDetailBase, commentsUrlTemplate, addCommentUrlTemplate, - autoShowComments = false + autoShowComments = false, + useAjaxList = false } = config; // Получаем токен доступа @@ -54,6 +57,43 @@ export function initRequestListPage(config) { headers['Authorization'] = 'Bearer ' + ACCESS; } + // Если включен AJAX режим - инициализируем обработчик списка + if (useAjaxList) { + const userId = parseInt(document.body.dataset.userId || '0', 10); + const canProcess = document.body.dataset.canProcess === 'true'; + + console.log('Request list AJAX mode:', { + apiListUrl, + userId, + canProcess + }); + + // Инициализация списка заявлений (AJAX загрузка с бесконечной прокруткой) + const requestListHandler = initRequestListHandler({ + apiListUrl, + detailUrlTemplate: '/requests/{id}/', + userId, + canProcess, + headers + }); + + // Обработка переключения фильтров через URL параметры + const urlParams = new URLSearchParams(window.location.search); + const currentView = urlParams.get('view') || ''; + const currentType = urlParams.get('type') || ''; + const currentStatus = urlParams.get('status') || ''; + + if (currentView && requestListHandler.setView) { + requestListHandler.setView(currentView); + } + if (currentType && requestListHandler.setType) { + requestListHandler.setType(currentType); + } + if (currentStatus && requestListHandler.setStatus) { + requestListHandler.setStatus(currentStatus); + } + } + // Инициализация обработчиков модальных окон initRequestModalHandler({ autoShowComments diff --git a/backend/templates/requests_app/request_detail.html b/backend/templates/requests_app/request_detail.html index 325acfcb..2e7850ba 100644 --- a/backend/templates/requests_app/request_detail.html +++ b/backend/templates/requests_app/request_detail.html @@ -78,9 +78,9 @@
{{ request_obj.comment|default:"—" }}
- {% if request_obj.attachment %} + {% if request_obj.attachment_url %} + href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener"> Вложение {% endif %} diff --git a/backend/templates/requests_app/request_process.html b/backend/templates/requests_app/request_process.html index 17c75f9d..69ebaf98 100644 --- a/backend/templates/requests_app/request_process.html +++ b/backend/templates/requests_app/request_process.html @@ -78,15 +78,15 @@
{# Комментарий сотрудника (если есть) #} -
+
Комментарий сотрудника: {{ request_obj.comment|default:"—" }}
{# Вложение сотрудника (если есть) #} - {% if request_obj.attachment %} + {% if request_obj.attachment_url %} Вложение сотрудника