diff --git a/src/ui.js b/src/ui.js new file mode 100644 index 0000000..f80d1e5 --- /dev/null +++ b/src/ui.js @@ -0,0 +1,217 @@ +// ui.js +// Основные функции UI: загрузка файлов, отображение списка, запуск сверки, сортировка, фильтрация, экспорт CSV, переключатель темы. +import {readExcel, validateFile, normalizePlate, normalizeName, similarity} from './utils.js'; + +const state = { + files: [], // загруженные файлы (File objects) + vedomosti: [], // массив объектов {driver, plate, cash} + transactions: [], // массив объектов {driver, date, sum} + results: [] // массив {driver, plate, cash, collected, diff, status} +}; + +// ---------- Элементы UI ---------- +const dropzone = document.getElementById('dropzone'); +const fileInput = document.getElementById('fileInput'); +const fileList = document.getElementById('fileList'); +const btnReconcile = document.getElementById('btnReconcile'); +const btnClear = document.getElementById('btnClear'); +const progressBar = document.getElementById('progressBar'); +const progressFill = document.getElementById('progressFill'); +const emptyState = document.getElementById('emptyState'); +const resultActions = document.getElementById('resultActions'); +const tabsContainer = document.getElementById('tabsContainer'); +const themeToggle = document.getElementById('themeToggle'); + +// ---------- Загрузка файлов ---------- +function initDropzone() { + dropzone.addEventListener('click', () => fileInput.click()); + dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dragover'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover')); + dropzone.addEventListener('drop', e => { + e.preventDefault(); dropzone.classList.remove('dragover'); + handleFiles(e.dataTransfer.files); + }); + fileInput.addEventListener('change', e => handleFiles(e.target.files)); +} + +function handleFiles(files) { + for (const f of files) { + if (validateFile(f)) state.files.push(f); + } + renderFileList(); + updateButtons(); +} + +function renderFileList() { + fileList.innerHTML = ''; + state.files.forEach((file, i) => { + const isTrans = /transaction/i.test(file.name); + const el = document.createElement('div'); + el.className = 'file-item'; + el.innerHTML = `
${isTrans ? 'TX' : 'VD'}
+
${file.name}
+
${isTrans ? 'Выгрузка транзакций' : 'Ведомость'} • ${(file.size / 1024).toFixed(0)} КБ
+ `; + fileList.appendChild(el); + }); + fileList.querySelectorAll('.file-remove').forEach(b => { + b.addEventListener('click', e => { + const idx = +e.currentTarget.dataset.i; + state.files.splice(idx, 1); + renderFileList(); + updateButtons(); + }); + }); +} + +function updateButtons() { + const enabled = state.files.length > 0; + btnReconcile.disabled = !enabled; + btnClear.disabled = !enabled; +} + +btnClear.addEventListener('click', () => { + state.files = []; + fileInput.value = ''; + renderFileList(); + updateButtons(); + emptyState.style.display = ''; + resultActions.style.display = 'none'; + tabsContainer.style.display = 'none'; +}); + +// ---------- Тема ---------- +themeToggle.addEventListener('click', () => { + const html = document.documentElement; + const dark = html.dataset.theme === 'dark'; + html.dataset.theme = dark ? '' : 'dark'; + themeToggle.textContent = dark ? 'Светлая тема' : 'Тёмная тема'; +}); + +// ---------- Сверка ---------- +btnReconcile.addEventListener('click', async () => { + progressBar.classList.add('active'); + progressFill.style.width = '0%'; + let progress = 0; + const step = inc => { + progress = Math.min(100, progress + inc); + progressFill.style.width = `${progress}%`; + }; + + // 1. Чтение файлов + const sheetsArr = await Promise.all(state.files.map(f => readExcel(f))); + state.vedomosti = []; + state.transactions = []; + sheetsArr.forEach((sheets, idx) => { + const file = state.files[idx]; + const isTrans = /transaction/i.test(file.name); + const sheet = sheets[0]; // берем первый лист + const header = sheet.data[0].map(h => String(h).toUpperCase().trim()); + if (isTrans) { + const iDriver = header.findIndex(h => h.includes('CONDUCTOR')); + const iDate = header.findIndex(h => h.includes('DATE')); + const iSum = header.findIndex(h => h.includes('TARIF_SUM')); + if (iDriver === -1 || iDate === -1 || iSum === -1) return; // пропускаем некорректные + for (let r = 1; r < sheet.data.length; r++) { + const row = sheet.data[r]; + const driver = String(row[iDriver] || '').trim(); + const date = String(row[iDate] || '').trim(); + const sum = Number(row[iSum] || 0) / 100; // копейки -> рубли + if (driver) state.transactions.push({ driver, date, sum }); + } + } else { + const iDriver = header.findIndex(h => h.includes('ВОДИТЕЛЬ') || h.includes('DRIVER')); + const iPlate = header.findIndex(h => h.includes('ГОС. НОМЕР') || h.includes('PLATE')); + const iCash = header.findIndex(h => h.includes('СДАНО') || h.includes('CASH')); + if (iDriver === -1) return; + for (let r = 1; r < sheet.data.length; r++) { + const row = sheet.data[r]; + const driver = String(row[iDriver] || '').trim(); + const plate = iPlate !== -1 ? String(row[iPlate] || '').trim() : ''; + const cash = iCash !== -1 ? Number(row[iCash] || 0) : 0; + state.vedomosti.push({ driver, plate, cash }); + } + } + }); + step(20); + + // 2. Сопоставление и расчёт + const transMap = {}; + state.transactions.forEach(t => { + const key = `${normalizeName(t.driver)}|${normalizePlate(t.driver)}`; + transMap[key] = (transMap[key] || 0) + t.sum; + }); + state.results = state.vedomosti.map(v => { + const key = `${normalizeName(v.driver)}|${normalizePlate(v.plate)}`; + const collected = transMap[key] || 0; + const diff = collected - v.cash; + let status = 'ok'; + if (diff === 0) status = 'ok'; + else if (Math.abs(diff) <= 100) status = 'warn'; + else status = 'err'; + return { driver: v.driver, plate: v.plate, cash: v.cash, collected, diff, status }; + }); + step(40); + + // 3. Отображение результатов + renderResults(); + step(40); + progressBar.classList.remove('active'); +}); + +function renderResults() { + emptyState.style.display = 'none'; + resultActions.style.display = ''; + tabsContainer.style.display = ''; + // Таблица расхождений + const tbody = document.getElementById('tableDiscrepancies'); + tbody.innerHTML = state.results.map(r => { + const diffColor = r.diff === 0 ? '#10b981' : '#ef4444'; + const badgeClass = r.status === 'ok' ? 'badge-ok' : (r.status === 'warn' ? 'badge-warn' : 'badge-err'); + const badgeText = r.status === 'ok' ? '✓ Совпадает' : (r.status === 'warn' ? `⚠ ${r.diff} ₽` : `✕ ${r.diff} ₽`); + const diffSign = r.diff > 0 ? '+' : ''; + return ` + ${r.driver} + ${r.plate} + ${r.cash.toLocaleString('ru-RU')} + ${r.collected.toLocaleString('ru-RU')} + ${diffSign}${r.diff.toLocaleString('ru-RU')} + ${badgeText} + `; + }).join(''); + document.getElementById('countDisc').textContent = state.results.filter(r => r.status !== 'ok').length; + document.getElementById('countAll').textContent = state.results.length; + // Таблица "Все водители" + const allBody = document.getElementById('tableAll'); + allBody.innerHTML = state.results.map(r => `-${r.driver}-${r.cash}${r.collected}${r.diff}`).join(''); + // CSV экспорт + document.getElementById('btnDownload').onclick = exportCSV; +} + +function exportCSV() { + const header = ['Водитель', 'Гос. номер', 'Сдано', 'Собрано', 'Расхождение', 'Статус']; + const rows = state.results.map(r => [r.driver, r.plate, r.cash, r.collected, r.diff, r.status]); + const csvContent = [header, ...rows] + .map(row => row.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'reconciliation.csv'; + a.click(); + URL.revokeObjectURL(url); +} + +// ---------- Таб переключения ---------- +document.querySelectorAll('.tab-btn').forEach(b => { + b.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(x => x.classList.remove('active')); + b.classList.add('active'); + document.getElementById('panel-' + b.dataset.tab).classList.add('active'); + }); +}); + +// ---------- Инициализация ---------- +initDropzone();