/** * ui.js — Компоненты интерфейса * Рендер файлов, таблиц, сортировка, вкладки, управление темой */ import { formatMoney } from './utils.js'; /* ========== Управление файлами ========== */ /** * Создать DOM-элемент для отображения загруженного файла * @param {File} file * @param {number} index * @param {string} type - 'vedomost' | 'transaction' * @returns {HTMLElement} */ export function renderFileItem(file, index, type) { const isTrans = type === 'transaction'; const el = document.createElement('div'); el.className = 'file-item'; el.dataset.index = index; el.innerHTML = `
${isTrans ? '📊' : '📋'}
${escapeHtml(file.name)}
${isTrans ? 'Выгрузка транзакций' : 'Ведомость'} · ${(file.size / 1024).toFixed(0)} КБ
`; return el; } /** * Создать DOM-элемент для сообщения об ошибке в файле * @param {string} fileName * @param {string} message * @returns {HTMLElement} */ export function renderFileError(fileName, message) { const el = document.createElement('div'); el.className = 'file-item file-error'; el.innerHTML = `
⚠️
${escapeHtml(fileName)}
${escapeHtml(message)}
`; return el; } /* ========== Сортировка таблиц ========== */ /** * Отсортировать массив данных по ключу * @param {Object[]} data * @param {string} key * @param {boolean} asc * @returns {Object[]} */ export function sortData(data, key, asc = true) { return [...data].sort((a, b) => { let av = a[key], bv = b[key]; if (av === null || av === undefined) av = ''; if (bv === null || bv === undefined) bv = ''; if (typeof av === 'number' && typeof bv === 'number') { return asc ? av - bv : bv - av; } // Числовые строки сравниваем как числа const na = parseFloat(String(av).replace(/\s/g, '').replace(',', '.')); const nb = parseFloat(String(bv).replace(/\s/g, '').replace(',', '.')); if (!isNaN(na) && !isNaN(nb)) { return asc ? na - nb : nb - na; } return asc ? String(av).localeCompare(String(bv), 'ru') : String(bv).localeCompare(String(av), 'ru'); }); } /** * Включить сортировку по клику на заголовки таблицы * @param {string} tbodyId - ID tbody * @param {Object[]} sourceData - ссылка на исходный массив * @param {Function} renderFn - функция перерисовки */ export function enableSorting(tbodyId, sourceData, renderFn) { const tbody = document.getElementById(tbodyId); if (!tbody) return; const table = tbody.closest('table'); if (!table) return; const ths = table.querySelectorAll('th[data-key]'); let sortState = { key: null, asc: true }; ths.forEach(th => { th.style.cursor = 'pointer'; th.addEventListener('click', () => { const key = th.dataset.key; if (sortState.key === key) { sortState.asc = !sortState.asc; } else { sortState.key = key; sortState.asc = true; } // Визуальный индикатор ths.forEach(t => t.classList.remove('sort-asc', 'sort-desc')); th.classList.add(sortState.asc ? 'sort-asc' : 'sort-desc'); const sorted = sortData(sourceData, key, sortState.asc); renderFn(sorted); }); }); } /* ========== Рендер таблиц ========== */ /** * Рендер тела таблицы из массива объектов * @param {HTMLElement} tbody * @param {Object[]} rows * @param {string[]} keys - порядок/набор ключей для отображения * @param {Function} formatter - функция форматирования ячейки (value, key, row) => string */ export function renderTableBody(tbody, rows, keys, formatter) { if (!rows || rows.length === 0) { tbody.innerHTML = `Нет данных`; return; } tbody.innerHTML = rows.map(row => { const cells = (keys || Object.keys(row)).map(key => { const val = row[key]; const formatted = formatter ? formatter(val, key, row) : formatCell(val); return `${formatted}`; }).join(''); return `${cells}`; }).join(''); } /** * Форматирование значения ячейки по умолчанию */ function formatCell(val) { if (val === null || val === undefined || val === '') return '—'; if (typeof val === 'number') return formatMoney(val); return escapeHtml(String(val)); } /** * Экранирование HTML */ function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /* ========== Управление вкладками ========== */ /** * Инициализировать переключатели вкладок */ export function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(x => x.classList.remove('active')); btn.classList.add('active'); const panel = document.getElementById('panel-' + btn.dataset.tab); if (panel) panel.classList.add('active'); }); }); } /* ========== Тема ========== */ let currentTheme = localStorage.getItem('theme') || 'light'; /** * Применить тему */ export function applyTheme() { document.documentElement.setAttribute('data-theme', currentTheme); const btn = document.getElementById('btnToggleTheme'); if (btn) { btn.textContent = currentTheme === 'light' ? '🌙 Тёмная тема' : '☀️ Светлая тема'; } } /** * Переключить тему */ export function toggleTheme() { currentTheme = currentTheme === 'light' ? 'dark' : 'light'; localStorage.setItem('theme', currentTheme); applyTheme(); } /** * Инициализировать тему */ export function initTheme(toggleBtnId) { applyTheme(); const btn = document.getElementById(toggleBtnId); if (btn) btn.addEventListener('click', toggleTheme); } /* ========== Прогресс-бар ========== */ /** * Показать прогресс-бар с анимацией * @param {number} duration - длительность анимации в мс * @returns {Promise} - разрешается по завершении */ export function showProgress(duration = 800) { const bar = document.getElementById('progressBar'); const fill = document.getElementById('progressFill'); if (!bar || !fill) return Promise.resolve(); bar.classList.add('active'); fill.style.width = '0%'; return new Promise(resolve => { // Плавная анимация до 95% requestAnimationFrame(() => { fill.style.transition = `width ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`; fill.style.width = '95%'; }); // Завершение setTimeout(() => { fill.style.width = '100%'; setTimeout(() => { bar.classList.remove('active'); fill.style.width = '0%'; fill.style.transition = 'none'; resolve(); }, 300); }, duration + 200); }); } /** * Сбросить прогресс-бар */ export function resetProgress() { const bar = document.getElementById('progressBar'); const fill = document.getElementById('progressFill'); if (bar) bar.classList.remove('active'); if (fill) { fill.style.width = '0%'; fill.style.transition = 'none'; } }