/** * @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 ? `` : '
    — никого —
    '; // Отображение списка не ознакомившихся noElement.innerHTML = noSlice.length ? `` : '
    — никого —
    '; // Кнопки "Показать ещё" 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; }