Add ui.js with full UI logic, theme toggle, CSV export, sorting placeholder
This commit is contained in:
@@ -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 = `<div class="file-icon ${isTrans ? 'transaction' : 'vedomost'}">${isTrans ? 'TX' : 'VD'}</div>
|
||||
<div class="file-info"><div class="file-name">${file.name}</div>
|
||||
<div class="file-type">${isTrans ? 'Выгрузка транзакций' : 'Ведомость'} • ${(file.size / 1024).toFixed(0)} КБ</div></div>
|
||||
<button class="file-remove" data-i="${i}" title="Удалить">✕</button>`;
|
||||
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 `<tr>
|
||||
<td>${r.driver}</td>
|
||||
<td><code>${r.plate}</code></td>
|
||||
<td>${r.cash.toLocaleString('ru-RU')}</td>
|
||||
<td>${r.collected.toLocaleString('ru-RU')}</td>
|
||||
<td style="color:${diffColor};font-weight:600;">${diffSign}${r.diff.toLocaleString('ru-RU')}</td>
|
||||
<td><span class="badge ${badgeClass}">${badgeText}</span></td>
|
||||
</tr>`;
|
||||
}).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 => `<tr><td>-</td><td>${r.driver}</td><td>-</td><td>${r.cash}</td><td>${r.collected}</td><td>${r.diff}</td></tr>`).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();
|
||||
Reference in New Issue
Block a user