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