From 62d14221cf61f1c108f5eb178163f899ad3d1c45 Mon Sep 17 00:00:00 2001 From: host Date: Thu, 30 Jul 2026 16:52:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20ui.js=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0:=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D1=8B,=20=D1=81=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0,=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8,=20=D1=82=D0=B5=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui.js | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 ui.js diff --git a/ui.js b/ui.js new file mode 100644 index 0000000..e4faf80 --- /dev/null +++ b/ui.js @@ -0,0 +1,248 @@ +/** + * 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'; } +} \ No newline at end of file