Corp/backend/static/js/components/documentAcksHandler.js

261 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @module documentAcksHandler
* @description Обработчик модального окна ведомости ознакомлений с документом.
* Показывает списки ознакомившихся и не ознакомившихся сотрудников с порционной загрузкой.
*
* Использование:
* import { initDocumentAcks } from './documentAcksHandler.js';
*
* initDocumentAcks({
* apiDetailBase: '/api/v1/documents/',
* headers: { 'Authorization': 'Bearer TOKEN' }
* });
*/
import { esc } from '../utils/stringUtils.js';
/**
* Утилита для debounce (ограничение частоты вызовов функции).
* @param {Function} fn - Функция для вызова
* @param {number} ms - Задержка в миллисекундах
* @returns {Function} Обёрнутая функция
*/
function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
/**
* Инициализирует обработчик модального окна ознакомлений.
* @param {Object} options - Опции инициализации
* @param {string} options.apiDetailBase - Базовый URL API документов
* @param {Object} [options.headers={}] - HTTP заголовки для запросов
* @param {number} [options.pageSize=100] - Количество элементов на странице
* @returns {Object} API с методом destroy
*/
export function initDocumentAcks(options) {
const {
apiDetailBase,
headers = {},
pageSize = 100
} = options;
const modalElement = document.getElementById('acksModal');
const titleElement = document.getElementById('acksDocTitle');
const totalsElement = document.getElementById('acksTotals');
const yesElement = document.getElementById('acksYes');
const noElement = document.getElementById('acksNo');
const searchElement = document.getElementById('acksSearch');
if (!modalElement || !yesElement || !noElement || !searchElement) {
console.warn('initDocumentAcks: обязательные элементы не найдены');
return { destroy: () => {} };
}
// Состояние модалки
const state = {
docId: null,
docTitle: '',
rawYes: [],
rawNo: [],
filter: '',
pageSize: pageSize,
shownYes: 0,
shownNo: 0
};
/**
* Строит HTML элемент для одного сотрудника.
* @param {Object} user - Объект пользователя
* @returns {string} HTML строка
*/
function buildItem(user) {
const name = esc(user.display_name || user.full_name || user.email || ('#' + user.id));
const email = user.email ? ` <small class="text-secondary">${esc(user.email)}</small>` : '';
const when = user.acknowledged_at
? ` <small class="text-secondary">(${esc(user.acknowledged_at)})</small>`
: '';
return `<li>${name}${email}${when}</li>`;
}
/**
* Применяет фильтр поиска к списку пользователей.
* @param {Array} list - Массив пользователей
* @param {string} term - Поисковый запрос
* @returns {Array} Отфильтрованный массив
*/
function applyFilter(list, term) {
if (!term) return list;
const searchTerm = term.toLowerCase();
return list.filter(user => {
const name = (user.display_name || user.full_name || user.email || '').toLowerCase();
const email = (user.email || '').toLowerCase();
return name.includes(searchTerm) || email.includes(searchTerm);
});
}
/**
* Отображает списки ознакомившихся и не ознакомившихся.
*/
function renderLists() {
const yesFiltered = applyFilter(state.rawYes, state.filter);
const noFiltered = applyFilter(state.rawNo, state.filter);
totalsElement.textContent =
`Ознакомились: ${yesFiltered.length} • Не ознакомились: ${noFiltered.length} • Всего: ${yesFiltered.length + noFiltered.length}`;
// Порционное отображение
const yesSlice = yesFiltered.slice(0, state.shownYes || state.pageSize);
const noSlice = noFiltered.slice(0, state.shownNo || state.pageSize);
// Отображение списка ознакомившихся
yesElement.innerHTML = yesSlice.length
? `<ul class="acks-list">${yesSlice.map(buildItem).join('')}</ul>`
: '<div class="acks-empty">— никого —</div>';
// Отображение списка не ознакомившихся
noElement.innerHTML = noSlice.length
? `<ul class="acks-list">${noSlice.map(buildItem).join('')}</ul>`
: '<div class="acks-empty">— никого —</div>';
// Кнопки "Показать ещё"
const needMoreYes = yesSlice.length < yesFiltered.length;
const needMoreNo = noSlice.length < noFiltered.length;
if (needMoreYes) {
const remaining = Math.min(state.pageSize, yesFiltered.length - yesSlice.length);
const button = document.createElement('button');
button.className = 'btn btn-sm btn-outline-secondary acks-more';
button.textContent = `Показать ещё (${remaining})`;
button.addEventListener('click', () => {
state.shownYes = yesSlice.length + state.pageSize;
renderLists();
});
yesElement.appendChild(button);
}
if (needMoreNo) {
const remaining = Math.min(state.pageSize, noFiltered.length - noSlice.length);
const button = document.createElement('button');
button.className = 'btn btn-sm btn-outline-secondary acks-more';
button.textContent = `Показать ещё (${remaining})`;
button.addEventListener('click', () => {
state.shownNo = noSlice.length + state.pageSize;
renderLists();
});
noElement.appendChild(button);
}
}
/**
* Загружает данные ознакомлений с API.
* @param {number} docId - ID документа
*/
async function fetchAcknowledgements(docId) {
const url = new URL(apiDetailBase + docId + '/acknowledgements/', window.location.origin);
try {
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const data = await response.json();
// Поддержка двух форматов:
// 1) { acknowledged: [...], unacknowledged: [...] }
// 2) { acknowledged: {results: [...]}, unacknowledged: {results: [...]} }
const acknowledged = Array.isArray(data.acknowledged)
? data.acknowledged
: (data.acknowledged?.results || []);
const unacknowledged = Array.isArray(data.unacknowledged)
? data.unacknowledged
: (data.unacknowledged?.results || []);
state.rawYes = acknowledged;
state.rawNo = unacknowledged;
state.shownYes = state.pageSize;
state.shownNo = state.pageSize;
renderLists();
} catch (error) {
const errorMsg = `Не удалось загрузить: ${esc(error.message)}`;
yesElement.innerHTML = `<div class="text-danger small">${errorMsg}</div>`;
noElement.innerHTML = `<div class="text-danger small">${errorMsg}</div>`;
totalsElement.textContent = '—';
console.error('fetchAcknowledgements error:', error);
}
}
/**
* Открывает модальное окно с ознакомлениями.
* @param {number} docId - ID документа
* @param {string} title - Название документа
*/
function openAcknowledgements(docId, title) {
state.docId = docId;
state.docTitle = title || '';
state.filter = '';
state.shownYes = state.pageSize;
state.shownNo = state.pageSize;
if (titleElement) {
titleElement.textContent = title || '';
}
searchElement.value = '';
yesElement.innerHTML = '<div class="text-secondary">Загружается…</div>';
noElement.innerHTML = '<div class="text-secondary">Загружается…</div>';
totalsElement.textContent = '—';
bootstrap.Modal.getOrCreateInstance(modalElement).show();
fetchAcknowledgements(docId);
}
/**
* Делегированный обработчик кликов по кнопкам "Ознакомления".
*/
function handleShowAcksClick(e) {
const button = e.target.closest('button[data-action="show-acks"]');
if (!button) return;
const docId = button.getAttribute('data-doc-id');
const title = button.getAttribute('data-doc-title') || '';
openAcknowledgements(docId, title);
}
/**
* Обработчик поиска с debounce.
*/
const handleSearchInput = debounce(() => {
state.filter = searchElement.value.trim();
renderLists();
}, 250);
// Установка обработчиков
document.addEventListener('click', handleShowAcksClick);
searchElement.addEventListener('input', handleSearchInput);
/**
* Функция для удаления всех обработчиков.
*/
function destroy() {
document.removeEventListener('click', handleShowAcksClick);
searchElement.removeEventListener('input', handleSearchInput);
}
return { destroy };
}
// Экспорт для совместимости с неModular кодом
if (typeof window !== 'undefined') {
window.initDocumentAcks = initDocumentAcks;
}