/**
* @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 ? ` ${esc(user.email)}` : '';
const when = user.acknowledged_at
? ` (${esc(user.acknowledged_at)})`
: '';
return `
${name}${email}${when}`;
}
/**
* Применяет фильтр поиска к списку пользователей.
* @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
? `${yesSlice.map(buildItem).join('')}
`
: '— никого —
';
// Отображение списка не ознакомившихся
noElement.innerHTML = noSlice.length
? `${noSlice.map(buildItem).join('')}
`
: '— никого —
';
// Кнопки "Показать ещё"
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 = `${errorMsg}
`;
noElement.innerHTML = `${errorMsg}
`;
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 = 'Загружается…
';
noElement.innerHTML = 'Загружается…
';
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;
}