feat: полная AJAX-загрузка списка заявлений с infinite scroll

- Обновлен requestListHandler.js: рендеринг в табличном формате
- Сохранена структура таблицы req-table-header + req-row
- Шаблон: удален SSR-цикл, сохранена шапка таблицы
- Добавлены данные пользователя в dataset (userId, canProcess)
- Включен useAjaxList режим в initRequestListPage
- Удалены старые CSS для req-item (не используются)
- Структура совместима с существующими стилями request-list.css
This commit is contained in:
admin i 2025-12-05 15:00:36 +03:00
parent 5d1fd340fb
commit 19757d2f98
3 changed files with 293 additions and 254 deletions

View File

@ -285,3 +285,122 @@
}
}
/* ========================================
AJAX-режим: стили для req-item
======================================== */
.req-item {
border-bottom: 1px solid var(--bs-border-color);
padding: 1rem;
transition: background-color 0.2s;
}
.req-item:hover {
background-color: var(--bs-light);
}
.req-item:last-child {
border-bottom: none;
}
.req-header {
margin-bottom: 0.75rem;
}
.req-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.req-link {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--bs-body-color);
font-weight: 500;
flex: 1;
min-width: 0;
}
.req-link:hover {
color: var(--bs-primary);
}
.req-link strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.req-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: var(--bs-secondary-color);
}
.req-meta > span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.req-comment {
margin: 0.5rem 0;
padding: 0.5rem;
background-color: var(--bs-light);
border-radius: 0.375rem;
font-size: 0.875rem;
color: var(--bs-secondary-color);
}
.req-footer {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid var(--bs-border-color-translucent);
font-size: 0.875rem;
}
.req-approver,
.req-recipients {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--bs-secondary-color);
}
/* Badge стили для статусов */
.badge-warning {
background-color: #ffc107;
color: #000;
}
.badge-success {
background-color: #198754;
color: #fff;
}
.badge-danger {
background-color: #dc3545;
color: #fff;
}
.badge-secondary {
background-color: #6c757d;
color: #fff;
}
/* Спиннер загрузки */
.load-more-trigger {
visibility: hidden;
}

View File

@ -22,11 +22,11 @@ export function initRequestListHandler(options) {
headers = {}
} = options;
const listElement = document.getElementById('requestList');
const listElement = document.getElementById('reqList');
const searchInput = document.getElementById('reqFilter');
if (!listElement) {
console.warn('initRequestListHandler: #requestList not found');
console.warn('initRequestListHandler: #reqList not found');
return { load: () => {}, destroy: () => {} };
}
@ -281,62 +281,177 @@ export function initRequestListHandler(options) {
* @returns {string} HTML
*/
function renderRequestRow(req) {
const statusClass = getStatusClass(req.status);
const statusText = getStatusText(req.status);
const detailUrl = detailUrlTemplate.replace('{id}', req.id);
const type = (req.type || '').toLowerCase();
const status = (req.status || '').toLowerCase();
// Иконка типа
let typeIcon = 'bi-clipboard-check text-secondary';
if (type === 'vacation') typeIcon = 'bi-calendar-check text-primary';
else if (type === 'sick_leave') typeIcon = 'bi-capsule text-danger';
else if (type === 'transfer') typeIcon = 'bi-box-arrow-right text-info';
else if (type === 'dismissal') typeIcon = 'bi-door-open text-warning';
// Текст типа
const typeText = {
'vacation': 'Отпуск',
'sick_leave': 'Больничный',
'transfer': 'Перевод',
'dismissal': 'Увольнение',
'other': 'Другое'
}[type] || req.type;
// Класс и текст статуса
let statusBadgeClass = 'text-bg-light';
let statusText = req.status;
if (status === 'draft') { statusBadgeClass = 'text-bg-info'; statusText = 'Черновик'; }
else if (status === 'pending') { statusBadgeClass = 'text-bg-warning'; statusText = 'На рассмотр.'; }
else if (status === 'approved') { statusBadgeClass = 'text-bg-success'; statusText = 'Одобрено'; }
else if (status === 'rejected') { statusBadgeClass = 'text-bg-danger'; statusText = 'Отклонено'; }
else if (status === 'cancelled') { statusBadgeClass = 'text-bg-secondary'; statusText = 'Отменено'; }
// URL для сотрудника
const employeeUrl = req.employee?.id === userId ? '/employees/profile/' : `/employees/${req.employee?.id}/`;
const employeeName = req.employee?.id === userId ? 'Вы' : (req.employee?.full_name || req.employee?.display_name || 'Сотрудник');
// Кнопки действий
let actionButtons = '';
if (req.employee?.id !== userId && canProcess) {
actionButtons = `
<button type="button" class="btn btn-ghost btn-sm text-success"
data-bs-toggle="modal" data-bs-target="#reqApproveModal"
data-id="${req.id}" title="Одобрить">
<i class="bi-hand-thumbs-up"></i>
</button>
<button type="button" class="btn btn-ghost btn-sm text-danger"
data-bs-toggle="modal" data-bs-target="#reqRejectModal"
data-id="${req.id}" title="Отклонить">
<i class="bi-hand-thumbs-down"></i>
</button>
`;
} else if (req.employee?.id === userId) {
actionButtons = `
<button type="button" class="btn btn-ghost btn-sm text-primary"
data-bs-toggle="modal" data-bs-target="#reqEditModal"
data-id="${req.id}"
data-title="${escapeHtml(req.title || '')}"
data-type="${req.type || ''}"
data-date_from="${req.date_from || ''}"
data-date_to="${req.date_to || ''}"
data-comment="${escapeHtml(req.comment || '')}"
data-status="${status}" title="Изменить">
<i class="bi-pencil"></i>
</button>
<button type="button" class="btn btn-ghost btn-sm text-secondary"
data-bs-toggle="modal" data-bs-target="#reqCancelModal"
data-id="${req.id}" title="Отменить">
<i class="bi-slash-circle"></i>
</button>
`;
}
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>
<article class="req-row" id="req-${req.id}">
<div class="req-table-row">
<!-- Иконка типа -->
<div class="req-cell req-cell-icon">
<div class="card-icon d-flex align-items-center justify-content-center">
<i class="${typeIcon}"></i>
</div>
</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 class="req-cell req-cell-title">
<div class="req-cell-content">
<strong>${escapeHtml(req.title || 'Без заголовка')}</strong>
${type ? `<span class="badge text-bg-light ms-1">${typeText}</span>` : ''}
${req.comment ? `<div class="small text-body-secondary mt-1">${escapeHtml(req.comment.substring(0, 100))}</div>` : ''}
</div>
</div>
</header>
${req.comment ? `
<div class="req-comment">${escapeHtml(req.comment)}</div>
` : ''}
<!-- Автор -->
<div class="req-cell req-cell-author">
<div class="req-cell-content">
${req.employee ? `<a href="${employeeUrl}" class="text-decoration-none">${employeeName}</a>` : ''}
</div>
</div>
<!-- Период -->
<div class="req-cell req-cell-dates">
<div class="req-cell-content text-mono">
${req.date_from || req.date_to ? `
${req.date_from ? formatDate(req.date_from) : '—'}<br>
${req.date_to ? formatDate(req.date_to) : '—'}
` : '<span class="text-body-secondary">—</span>'}
</div>
</div>
<!-- Статус -->
<div class="req-cell req-cell-status">
<div class="req-cell-content">
${req.status ? `<span class="badge ${statusBadgeClass}">${statusText}</span>` : ''}
</div>
</div>
<!-- Дата создания -->
<div class="req-cell req-cell-created">
<div class="req-cell-content small text-body-secondary">
${req.created_at ? formatDate(req.created_at) : '<span class="text-body-secondary">—</span>'}
</div>
</div>
<!-- Действия -->
<div class="req-cell req-cell-actions">
<div class="req-cell-content">
${actionButtons}
</div>
</div>
</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>
` : ''}
<button class="btn btn-ghost btn-sm d-inline-flex align-items-center gap-1 collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#rcoll-${req.id}"
aria-controls="rcoll-${req.id}"
aria-expanded="false">
<i class="bi-chat-dots"></i>
<span class="txt-open" data-role="count">${req.comments_count || 0}</span>
<span class="txt-close">Скрыть</span>
</button>
${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>
<div id="rcoll-${req.id}" class="collapse" data-request-id="${req.id}" data-comments-collapse>
<div class="comments-block p-3">
<div class="d-flex align-items-center gap-2 mb-3">
<strong class="small mb-0">Комментарии</strong>
<div class="spinner-border spinner-border-sm text-secondary" role="status" style="display:none;"></div>
</div>
<div class="comments-list" data-role="list"></div>
<form class="comment-new mt-3 request-comment-form" data-role="form" autocomplete="off">
<div class="message-field message-field--compact">
<div class="dropdown message-emoji">
<button type="button" class="btn btn-ghost btn-emoji message-icon-btn"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Вставить смайлик">
<i class="bi-emoji-smile"></i>
</button>
<div class="dropdown-menu dropdown-menu-start message-emoji-menu">
<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>
</div>
</div>
<textarea class="form-control message-input" rows="1" placeholder="Написать…" data-role="text"></textarea>
<button class="btn btn-primary message-send" type="submit" title="Отправить"><i class="bi-send"></i></button>
</div>
</form>
</div>
</div>
</article>
`;
}

View File

@ -49,214 +49,13 @@
</div>
</div>
{% if requests %}
{% for obj in requests %}
<article class="req-row" id="req-{{ obj.id }}">
<div class="req-table-row">
<!-- Иконка типа -->
<div class="req-cell req-cell-icon">
<div class="card-icon d-flex align-items-center justify-content-center">
{% with tp=obj.type|lower %}
{% if tp == 'vacation' %}<i class="bi-calendar-check text-primary"></i>
{% elif tp == 'sick_leave' %}<i class="bi-capsule text-danger"></i>
{% elif tp == 'transfer' %}<i class="bi-box-arrow-right text-info"></i>
{% elif tp == 'dismissal' %}<i class="bi-door-open text-warning"></i>
{% else %}<i class="bi-clipboard-check text-secondary"></i>
{% endif %}
{% endwith %}
</div>
</div>
<!-- Заголовок и тип -->
<div class="req-cell req-cell-title">
<div class="req-cell-content">
<strong>{{ obj.title|default:"Без заголовка" }}</strong>
{% if obj.type %}{% with tp=obj.type|lower %}
<span class="badge text-bg-light ms-1">
{% if tp == 'vacation' %}Отпуск
{% elif tp == 'sick_leave' %}Больничный
{% elif tp == 'transfer' %}Перевод
{% elif tp == 'dismissal' %}Увольнение
{% elif tp == 'other' %}Другое
{% else %}{{ obj.type }}{% endif %}
</span>
{% endwith %}{% endif %}
{% if obj.comment %}
<div class="small text-body-secondary mt-1">{{ obj.comment|truncatewords:15 }}</div>
{% endif %}
</div>
</div>
<!-- Автор -->
<div class="req-cell req-cell-author">
<div class="req-cell-content">
{% if obj.employee %}
{% if obj.employee.id == user.id %}
<a href="{% url 'employees:profile' %}" class="text-decoration-none">Вы</a>
{% else %}
<a href="{% url 'employees:employee_detail' obj.employee.id %}" class="text-decoration-none">
{{ obj.employee.full_name|default:obj.employee.display_name|default:"Сотрудник" }}
</a>
{% endif %}
{% endif %}
</div>
</div>
<!-- Период -->
<div class="req-cell req-cell-dates">
<div class="req-cell-content text-mono">
{% if obj.date_from or obj.date_to %}
{% if obj.date_from %}
{{ obj.date_from|format_iso_date:"d.m.Y" }}<br>
{% else %}
—<br>
{% endif %}
{% if obj.date_to %}
{{ obj.date_to|format_iso_date:"d.m.Y" }}
{% else %}
—
{% endif %}
{% else %}
<span class="text-body-secondary">—</span>
{% endif %}
</div>
</div>
<!-- Статус -->
<div class="req-cell req-cell-status">
<div class="req-cell-content">
{% if obj.status %}{% with st=obj.status|lower %}
<span class="badge
{% if st == 'approved' %}text-bg-success
{% elif st == 'rejected' %}text-bg-danger
{% elif st == 'pending' %}text-bg-warning
{% elif st == 'cancelled' %}text-bg-secondary
{% elif st == 'draft' %}text-bg-info
{% else %}text-bg-light{% endif %}">
{% if st == 'draft' %}Черновик
{% elif st == 'pending' %}На рассмотр.
{% elif st == 'approved' %}Одобрено
{% elif st == 'rejected' %}Отклонено
{% elif st == 'cancelled' %}Отменено
{% else %}{{ obj.status }}{% endif %}
</span>
{% endwith %}{% endif %}
</div>
</div>
<!-- Дата создания -->
<div class="req-cell req-cell-created">
<div class="req-cell-content small text-body-secondary">
{% if obj.created_at %}
{{ obj.created_at|format_iso_date:"d.m.Y" }}
{% else %}
<span class="text-body-secondary">—</span>
{% endif %}
</div>
</div>
<!-- Действия -->
<div class="req-cell req-cell-actions">
<div class="req-cell-content">
{% if obj.employee.id != user.id and can_process %}
<button type="button" class="btn btn-ghost btn-sm text-success"
data-bs-toggle="modal" data-bs-target="#reqApproveModal"
data-id="{{ obj.id }}" title="Одобрить">
<i class="bi-hand-thumbs-up"></i>
</button>
<button type="button" class="btn btn-ghost btn-sm text-danger"
data-bs-toggle="modal" data-bs-target="#reqRejectModal"
data-id="{{ obj.id }}" title="Отклонить">
<i class="bi-hand-thumbs-down"></i>
</button>
{% elif obj.employee.id == user.id %}
<button type="button" class="btn btn-ghost btn-sm text-primary"
data-bs-toggle="modal" data-bs-target="#reqEditModal"
data-id="{{ obj.id }}"
data-title="{{ obj.title|default_if_none:'' }}"
data-type="{{ obj.type|default_if_none:'' }}"
data-date_from="{{ obj.date_from|default_if_none:'' }}"
data-date_to="{{ obj.date_to|default_if_none:'' }}"
data-comment="{{ obj.comment|default_if_none:'' }}"
data-status="{{ obj.status|lower }}" title="Изменить">
<i class="bi-pencil"></i>
</button>
<button type="button" class="btn btn-ghost btn-sm text-secondary"
data-bs-toggle="modal" data-bs-target="#reqCancelModal"
data-id="{{ obj.id }}" title="Отменить">
<i class="bi-slash-circle"></i>
</button>
{% endif %}
</div>
</div>
</div>
<!-- Футер с комментариями -->
<footer class="req-footer">
<button class="btn btn-ghost btn-sm d-inline-flex align-items-center gap-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#rcoll-{{ obj.id }}"
aria-controls="rcoll-{{ obj.id }}"
aria-expanded="false">
<i class="bi-chat-dots"></i>
<span class="txt-open" data-role="count">{{ obj.comments_count|default:0 }}</span>
<span class="txt-close">Скрыть</span>
</button>
{% if obj.attachment %}
<a href="{% absolute_url obj.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>
{% endif %}
</footer>
<div id="rcoll-{{ obj.id }}" class="collapse" data-request-id="{{ obj.id }}" data-comments-collapse>
<div class="comments-block p-3">
<div class="d-flex align-items-center gap-2 mb-3">
<strong class="small mb-0">Комментарии</strong>
<div class="spinner-border spinner-border-sm text-secondary" role="status" style="display:none;"></div>
</div>
<div class="comments-list" data-role="list"></div>
<form class="comment-new mt-3 request-comment-form" data-role="form" autocomplete="off">
{% csrf_token %}
<div class="message-field message-field--compact">
<!-- Кнопка выбора эмодзи -->
<div class="dropdown message-emoji">
<button type="button" class="btn btn-ghost btn-emoji message-icon-btn"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Вставить смайлик">
<i class="bi-emoji-smile"></i>
</button>
<div class="dropdown-menu dropdown-menu-start message-emoji-menu">
<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>
</div>
</div>
<textarea class="form-control message-input" rows="1" placeholder="Написать…" data-role="text"></textarea>
<button class="btn btn-primary message-send" type="submit" title="Отправить"><i class="bi-send"></i></button>
</div>
</form>
</div>
</div>
</article>
{% endfor %}
{% else %}
<div class="p-4 text-center text-body-secondary">Нет заявлений.</div>
{% endif %}
</div>
{# Пагинация по next/prev ссылкам из API (если есть) #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}" href="{{ prev_url|default:'#' }}">
<i class="bi-arrow-left"></i> Назад
</a>
<span class="text-body-secondary small">Всего: {{ count|default:'—' }}</span>
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}" href="{{ next_url|default:'#' }}">
Вперёд <i class="bi-arrow-right"></i>
</a>
{# Список загружается через JavaScript из requestListHandler.js #}
<div class="text-center py-4" id="reqListLoading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
{% endif %}
</div>
{# ===== Modals ===== #}
@ -558,6 +357,11 @@
{% endblock %}
{% block extra_js %}
<script>
// Передаем данные для инициализации страницы
document.body.dataset.userId = '{{ user.id }}';
document.body.dataset.canProcess = '{{ can_process|yesno:"true,false" }}';
</script>
<script type="module">
import { initRequestListPage } from "{% static 'js/pages/requestList.js' %}";
@ -566,7 +370,8 @@
apiDetailBase: "{% url 'api:v1:request-list' %}",
commentsUrlTemplate: "/api/v1/requests/0/comments/",
addCommentUrlTemplate: "/api/v1/requests/0/comments/",
autoShowComments: {% if comments_for %}true{% else %}false{% endif %}
autoShowComments: {% if comments_for %}true{% else %}false{% endif %},
useAjaxList: true // Включаем AJAX режим для списка
});
</script>
<script src="{% static 'js/components/requestComments.js' %}"></script>