refactor: использование attachment_url из API + AJAX компонент для заявлений
- Шаблоны используют obj.attachment_url напрямую из API - Удален templatetag absolute_url (больше не нужен) - Упрощена проверка наличия вложения - Создан requestListHandler.js для AJAX загрузки с infinite scroll - Обновлен requestList.js для поддержки AJAX режима - Подход аналогичен приложению документов
This commit is contained in:
parent
ff41eb62b0
commit
589b6fabcf
@ -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(
|
||||
|
||||
@ -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()]
|
||||
|
||||
492
backend/static/js/components/requestListHandler.js
Normal file
492
backend/static/js/components/requestListHandler.js
Normal file
@ -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<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 (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 = '<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 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 = '<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 => 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 `
|
||||
<article class="req-item" data-request-id="${req.id}">
|
||||
<header class="req-header">
|
||||
<div class="req-title-row">
|
||||
<a href="${detailUrl}" class="req-link">
|
||||
<i class="bi-file-earmark-text"></i>
|
||||
<strong>${escapeHtml(req.display_title || req.title || 'Заявление')}</strong>
|
||||
</a>
|
||||
<span class="badge badge-${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
|
||||
<div class="req-meta">
|
||||
<span class="req-employee">
|
||||
<i class="bi-person"></i> ${escapeHtml(req.employee?.full_name || 'Неизвестно')}
|
||||
</span>
|
||||
${req.date_from ? `
|
||||
<span class="req-date">
|
||||
<i class="bi-calendar3"></i>
|
||||
${formatDate(req.date_from)}${req.date_to ? ' — ' + formatDate(req.date_to) : ''}
|
||||
</span>
|
||||
` : ''}
|
||||
<span class="req-created">
|
||||
<i class="bi-clock"></i> ${formatDate(req.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
${req.comment ? `
|
||||
<div class="req-comment">${escapeHtml(req.comment)}</div>
|
||||
` : ''}
|
||||
|
||||
<footer class="req-footer">
|
||||
${req.approver ? `
|
||||
<div class="req-approver">
|
||||
<i class="bi-person-check"></i>
|
||||
<span>Согласующий: ${escapeHtml(req.approver.full_name)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${req.recipient_count > 0 ? `
|
||||
<div class="req-recipients">
|
||||
<i class="bi-people"></i>
|
||||
<span>Получатели: ${req.recipient_count}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${req.attachment_url ? `
|
||||
<a href="${req.attachment_url}" class="btn btn-ghost btn-sm d-inline-flex align-items-center gap-1" target="_blank">
|
||||
<i class="bi-paperclip"></i><span>Вложение</span>
|
||||
</a>
|
||||
` : ''}
|
||||
</footer>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -78,9 +78,9 @@
|
||||
<div>{{ request_obj.comment|default:"—" }}</div>
|
||||
</div>
|
||||
|
||||
{% if request_obj.attachment %}
|
||||
{% if request_obj.attachment_url %}
|
||||
<a class="btn btn-ghost d-inline-flex align-items-center gap-1"
|
||||
href="{% absolute_url request_obj.attachment.url %}" target="_blank" rel="noopener">
|
||||
href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener">
|
||||
<i class="bi-paperclip"></i><span>Вложение</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@ -78,15 +78,15 @@
|
||||
|
||||
<div class="card-body pt-0">
|
||||
{# Комментарий сотрудника (если есть) #}
|
||||
<div class="small text-muted mb-2">
|
||||
<div class="mb-2">
|
||||
<span class="text-secondary">Комментарий сотрудника:</span>
|
||||
{{ request_obj.comment|default:"—" }}
|
||||
</div>
|
||||
|
||||
{# Вложение сотрудника (если есть) #}
|
||||
{% if request_obj.attachment %}
|
||||
{% if request_obj.attachment_url %}
|
||||
<a class="btn btn-ghost d-inline-flex align-items-center gap-1"
|
||||
href="{% absolute_url request_obj.attachment.url %}" target="_blank"
|
||||
href="{{ request_obj.attachment_url }}" target="_blank"
|
||||
rel="noopener noreferrer" download>
|
||||
<i class="bi-paperclip"></i><span>Вложение сотрудника</span>
|
||||
</a>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user