Создан ui.js — компоненты интерфейса: файлы, таблицы, сортировка, вкладки, тема
This commit is contained in:
@@ -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 = `
|
||||||
|
<div class="file-icon ${isTrans ? 'transaction' : 'vedomost'}">
|
||||||
|
${isTrans ? '📊' : '📋'}
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">${escapeHtml(file.name)}</div>
|
||||||
|
<div class="file-type">${isTrans ? 'Выгрузка транзакций' : 'Ведомость'} · ${(file.size / 1024).toFixed(0)} КБ</div>
|
||||||
|
</div>
|
||||||
|
<button class="file-remove" data-i="${index}" title="Удалить">✕</button>`;
|
||||||
|
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 = `
|
||||||
|
<div class="file-icon" style="background:#fee2e2;">⚠️</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">${escapeHtml(fileName)}</div>
|
||||||
|
<div class="file-type" style="color:#ef4444;">${escapeHtml(message)}</div>
|
||||||
|
</div>`;
|
||||||
|
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 = `<tr><td colspan="${(keys || Object.keys(rows[0] || {})).length}" style="text-align:center;padding:2rem;color:var(--text-muted)">Нет данных</td></tr>`;
|
||||||
|
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 `<td>${formatted}</td>`;
|
||||||
|
}).join('');
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
}).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'; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user