Создан main.js — главный модуль: инициализация, обработчики событий, рендер результатов

This commit is contained in:
2026-07-30 16:54:52 +03:00
parent 7512ab1ba7
commit 01326e93c6
+520
View File
@@ -0,0 +1,520 @@
/**
* main.js — Главный модуль приложения
*
* Инициализация, обработчики событий, координация модулей.
*/
import { readExcel, validateFile, arrayToCsv, downloadFile, downloadExcel, formatMoney } from './utils.js';
import {
renderFileItem, renderFileError, renderTableBody, enableSorting,
initTabs, initTheme, showProgress, resetProgress
} from './ui.js';
import { detectFileType, parseVedomost, parseTransactions, reconcile } from './parser.js';
/* ================================================================
СОСТОЯНИЕ ПРИЛОЖЕНИЯ
================================================================ */
const state = {
files: [], // загруженные File-объекты
fileTypes: {}, // { index: 'vedomost'|'transaction' }
parsedVedomosti: [], // распарсенные ведомости
parsedTransactions: [], // распарсенные транзакции
result: null, // результат сверки
isProcessing: false // флаг блокировки
};
/* ================================================================
DOM-ССЫЛКИ
================================================================ */
const $ = (id) => document.getElementById(id);
const dom = {
dropzone: $('dropzone'),
fileInput: $('fileInput'),
fileList: $('fileList'),
btnReconcile: $('btnReconcile'),
btnClear: $('btnClear'),
btnExportCsv: $('btnExportCsv'),
btnDownload: $('btnDownload'),
btnToggleTheme: $('btnToggleTheme'),
progressBar: $('progressBar'),
progressFill: $('progressFill'),
emptyState: $('emptyState'),
resultActions: $('resultActions'),
tabsContainer: $('tabsContainer'),
// Таблицы
tableDiscrepancies: $('tableDiscrepancies'),
tableAll: $('tableAll'),
tableCashiers: $('tableCashiers'),
tableRoutes: $('tableRoutes'),
// Счётчики
countDisc: $('countDisc'),
countAll: $('countAll'),
// Выводы
conclusionsContent: $('conclusionsContent'),
// Статус
statusBar: $('statusBar')
};
/* ================================================================
ИНИЦИАЛИЗАЦИЯ
================================================================ */
document.addEventListener('DOMContentLoaded', () => {
initTheme('btnToggleTheme');
initTabs();
bindEvents();
updateUI();
showStatus('Загрузите файлы ведомостей и выгрузку транзакций для начала работы', 'info');
});
/* ================================================================
СОБЫТИЯ
================================================================ */
function bindEvents() {
// Dropzone
dom.dropzone.addEventListener('click', () => dom.fileInput.click());
dom.dropzone.addEventListener('dragover', onDragOver);
dom.dropzone.addEventListener('dragleave', onDragLeave);
dom.dropzone.addEventListener('drop', onDrop);
dom.fileInput.addEventListener('change', onFileInputChange);
// Кнопки
dom.btnReconcile.addEventListener('click', onReconcile);
dom.btnClear.addEventListener('click', onClear);
dom.btnExportCsv.addEventListener('click', onExportCsv);
dom.btnDownload.addEventListener('click', onDownloadExcel);
}
/* ================================================================
ОБРАБОТЧИКИ ФАЙЛОВ
================================================================ */
function onDragOver(e) {
e.preventDefault();
dom.dropzone.classList.add('dragover');
}
function onDragLeave() {
dom.dropzone.classList.remove('dragover');
}
function onDrop(e) {
e.preventDefault();
dom.dropzone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
}
function onFileInputChange(e) {
handleFiles(e.target.files);
}
async function handleFiles(fileList) {
if (state.isProcessing) {
showStatus('Дождитесь завершения текущей операции', 'warning');
return;
}
let added = 0;
for (const file of fileList) {
if (!validateFile(file)) continue;
// Проверка на дубликат
if (state.files.some(f => f.name === file.name && f.size === file.size)) {
showStatus(`Файл "${file.name}" уже загружен`, 'warning');
continue;
}
state.files.push(file);
added++;
}
if (added === 0) return;
// Определяем типы файлов асинхронно
await detectTypes();
// Проверяем, что есть хотя бы одна ведомость и одна выгрузка
const hasVedomost = Object.values(state.fileTypes).includes('vedomost');
const hasTransaction = Object.values(state.fileTypes).includes('transaction');
renderFileList();
updateUI();
if (hasVedomost && hasTransaction) {
showStatus('Файлы загружены. Нажмите «Провести сверку»', 'success');
} else if (hasVedomost) {
showStatus('Загружена ведомость. Добавьте выгрузку транзакций (TRANSACTIONS_EXT)', 'info');
} else if (hasTransaction) {
showStatus('Загружена выгрузка транзакций. Добавьте ведомости', 'info');
} else {
showStatus('Файлы не распознаны. Загрузите ведомости (.xlsx) и/или выгрузку TRANSACTIONS_EXT', 'warning');
}
}
/**
* Определить типы всех загруженных файлов
*/
async function detectTypes() {
for (let i = 0; i < state.files.length; i++) {
if (state.fileTypes[i]) continue; // уже определён
try {
const sheets = await readExcel(state.files[i]);
const firstSheet = sheets[0]?.data || [];
const type = detectFileType(state.files[i].name, firstSheet);
if (type) {
state.fileTypes[i] = type;
} else {
state.fileTypes[i] = 'unknown';
}
} catch (err) {
console.error('Ошибка чтения файла:', state.files[i].name, err);
state.fileTypes[i] = 'error';
}
}
}
/**
* Рендер списка файлов
*/
function renderFileList() {
dom.fileList.innerHTML = '';
state.files.forEach((file, i) => {
const type = state.fileTypes[i];
if (type === 'error' || type === 'unknown') {
const msg = type === 'error'
? 'Ошибка чтения файла'
: 'Не удалось определить тип';
dom.fileList.appendChild(renderFileError(file.name, msg));
} else {
dom.fileList.appendChild(renderFileItem(file, i, type));
}
});
// Кнопки удаления
dom.fileList.querySelectorAll('.file-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.dataset.i);
state.files.splice(idx, 1);
// Перестраиваем индексы
const newTypes = {};
Object.keys(state.fileTypes).forEach(k => {
const ki = parseInt(k);
if (ki < idx) newTypes[ki] = state.fileTypes[k];
else if (ki > idx) newTypes[ki - 1] = state.fileTypes[k];
});
state.fileTypes = newTypes;
state.parsedVedomosti = [];
state.parsedTransactions = [];
state.result = null;
renderFileList();
updateUI();
hideResult();
showStatus('Файл удалён', 'info');
});
});
}
/* ================================================================
СВЕРКА
================================================================ */
async function onReconcile() {
if (state.isProcessing) return;
state.isProcessing = true;
dom.btnReconcile.disabled = true;
try {
showProgress(1200);
// Парсим все файлы
state.parsedVedomosti = [];
state.parsedTransactions = [];
for (let i = 0; i < state.files.length; i++) {
const type = state.fileTypes[i];
if (type === 'unknown' || type === 'error') continue;
const sheets = await readExcel(state.files[i]);
const data = sheets[0]?.data || [];
if (type === 'vedomost') {
const ved = parseVedomost(data);
if (ved && ved.drivers.length > 0) {
state.parsedVedomosti.push(ved);
} else {
showStatus(`Ведомость "${state.files[i].name}" не содержит данных о водителях`, 'warning');
}
} else if (type === 'transaction') {
const txs = parseTransactions(data);
if (txs.length > 0) {
state.parsedTransactions.push(...txs);
} else {
showStatus(`Выгрузка "${state.files[i].name}" не содержит транзакций`, 'warning');
}
}
}
// Проверка наличия данных
if (state.parsedVedomosti.length === 0) {
showStatus('Не найдено ни одной ведомости с данными о водителях', 'error');
state.isProcessing = false;
dom.btnReconcile.disabled = false;
resetProgress();
return;
}
if (state.parsedTransactions.length === 0) {
showStatus('Не найдено транзакций для сверки', 'error');
state.isProcessing = false;
dom.btnReconcile.disabled = false;
resetProgress();
return;
}
// Выполняем сверку
state.result = reconcile(state.parsedVedomosti, state.parsedTransactions);
if (state.result.drivers.length === 0) {
showStatus('Не удалось сопоставить ни одного водителя. Проверьте формат файлов', 'error');
state.isProcessing = false;
dom.btnReconcile.disabled = false;
resetProgress();
return;
}
renderResult();
updateUI();
showStatus(
`✅ Сверка завершена: ${state.result.drivers.length} водителей, ` +
`${state.result.conclusions.discrepanciesCount} расхождений`,
state.result.conclusions.discrepanciesCount > 0 ? 'warning' : 'success'
);
} catch (err) {
console.error('Ошибка сверки:', err);
showStatus('❌ Ошибка при выполнении сверки: ' + err.message, 'error');
} finally {
state.isProcessing = false;
dom.btnReconcile.disabled = false;
resetProgress();
}
}
/* ================================================================
РЕНДЕР РЕЗУЛЬТАТА
================================================================ */
function renderResult() {
if (!state.result) return;
const r = state.result;
// Скрываем пустое состояние, показываем результат
dom.emptyState.style.display = 'none';
dom.resultActions.style.display = '';
dom.tabsContainer.style.display = '';
// --- Расхождения ---
const discData = r.drivers.filter(d => d.diff !== 0);
renderTableBody(dom.tableDiscrepancies, discData, ['driver', 'car', 'given', 'collected', 'diff', 'status'], (val, key, row) => {
if (key === 'status') {
const labels = { ok: '✓ Совпадает', warn: `${formatMoney(row.diff)}`, err: `${formatMoney(Math.abs(row.diff))}` };
return `<span class="badge badge-${val}">${labels[val] || val}</span>`;
}
if (key === 'diff') {
if (val === 0) return `<span class="diff-zero">0 ₽</span>`;
return `<span class="${val > 0 ? 'diff-positive' : 'diff-negative'}">${val > 0 ? '+' : ''}${formatMoney(val)} ₽</span>`;
}
if (typeof val === 'number') return formatMoney(val);
return val || '—';
});
enableSorting('tableDiscrepancies', discData, (sorted) => {
renderTableBody(dom.tableDiscrepancies, sorted, ['driver', 'car', 'given', 'collected', 'diff', 'status']);
});
dom.countDisc.textContent = discData.length;
// --- Все водители ---
const allData = r.drivers.map(d => ({
date: d.date,
driver: d.driver,
route: d.route,
section: d.section,
given: d.given,
collected: d.collected,
txCount: d.txCount
}));
renderTableBody(dom.tableAll, allData, ['date', 'driver', 'route', 'section', 'given', 'collected', 'txCount'], (val, key) => {
if (key === 'section') {
return val === 'Город' ? '🏙️ Город' : '🚌 Пригород';
}
if (typeof val === 'number') return formatMoney(val);
return val || '—';
});
enableSorting('tableAll', allData, (sorted) => {
renderTableBody(dom.tableAll, sorted, ['date', 'driver', 'route', 'section', 'given', 'collected', 'txCount']);
});
dom.countAll.textContent = allData.length;
// --- По кассирам ---
renderTableBody(dom.tableCashiers, r.cashiers, ['cashier', 'count', 'given', 'collected', 'diff'], (val, key) => {
if (key === 'diff') {
if (val === 0) return `<span class="diff-zero">0 ₽</span>`;
return `<span class="${val > 0 ? 'diff-positive' : 'diff-negative'}">${val > 0 ? '+' : ''}${formatMoney(val)} ₽</span>`;
}
if (typeof val === 'number') return formatMoney(val);
return val || '—';
});
enableSorting('tableCashiers', r.cashiers, (sorted) => {
renderTableBody(dom.tableCashiers, sorted, ['cashier', 'count', 'given', 'collected', 'diff']);
});
// --- По маршрутам ---
renderTableBody(dom.tableRoutes, r.routes, ['route', 'type', 'drivers', 'sum'], (val, key) => {
if (typeof val === 'number') return formatMoney(val);
return val || '—';
});
enableSorting('tableRoutes', r.routes, (sorted) => {
renderTableBody(dom.tableRoutes, sorted, ['route', 'type', 'drivers', 'sum']);
});
// --- Выводы ---
const c = r.conclusions;
const diffClass = c.totalDiff === 0 ? '' : (c.totalDiff > 0 ? 'err' : 'warn');
dom.conclusionsContent.innerHTML = `
<div class="summary-card">
<div class="info-block ${diffClass}">
<div class="info-block-icon ${c.totalDiff === 0 ? 'green' : (c.totalDiff > 0 ? 'rose' : 'amber')}">
${c.totalDiff === 0 ? '✓' : (c.totalDiff > 0 ? '⚠' : '')}
</div>
<div>
<div class="info-block-title" style="color:${c.totalDiff === 0 ? 'var(--success-text)' : 'var(--danger-text)'}">
Итого сверено: ${c.totalDrivers} водителей
</div>
<div class="info-block-text">
<strong>Сдано:</strong> ${formatMoney(c.totalGiven)} ₽ ·
<strong>Собрано:</strong> ${formatMoney(c.totalCollected)} ₽ ·
<strong>Расхождение:</strong>
<span style="color:${c.totalDiff === 0 ? 'var(--text-muted)' : (c.totalDiff > 0 ? 'var(--danger)' : 'var(--success)')};font-weight:600">
${c.totalDiff > 0 ? '+' : ''}${formatMoney(c.totalDiff)}
</span>
</div>
<div class="info-block-text" style="margin-top:0.5rem;">
Совпадений: ${c.totalDrivers - c.discrepanciesCount} из ${c.totalDrivers} (${c.matchedPercent}%)
${c.discrepanciesCount > 0 ? `<span style="color:var(--danger)"> · Расхождений: ${c.discrepanciesCount}</span>` : ''}
</div>
</div>
</div>
${r.unmatchedTx.length > 0 ? `
<div class="info-block warn" style="margin-top:1rem;">
<div class="info-block-icon amber">⚠</div>
<div>
<div class="info-block-title" style="color:var(--warning-text)">
Не сопоставлено транзакций: ${r.unmatchedTx.length}
</div>
<div class="info-block-text">
Эти водители есть в выгрузке, но не найдены в ведомостях.
Возможные причины: опечатки в ФИО, разные гос. номера, или водитель работал без ведомости.
</div>
</div>
</div>` : ''}
</div>`;
}
/* ================================================================
ЭКСПОРТ
================================================================ */
function onExportCsv() {
if (!state.result) return;
const data = state.result.drivers.map(r => ({
driver: r.driver,
car: r.car,
route: r.route,
cashier: r.cashier,
date: r.date,
given: r.given,
collected: r.collected,
diff: r.diff,
status: r.status === 'ok' ? 'Совпадает' : (r.status === 'warn' ? 'Мелкое расхождение' : 'Расхождение')
}));
const csv = arrayToCsv(data, ['driver', 'car', 'route', 'cashier', 'date', 'given', 'collected', 'diff', 'status']);
downloadFile(csv, 'sverka_vyruchki.csv');
showStatus('CSV-файл скачан', 'success');
}
function onDownloadExcel() {
if (!state.result) return;
const data = state.result.drivers.map(r => ({
'Водитель': r.driver,
'Гос. номер': r.car,
'Маршрут': r.route,
'Кассир': r.cashier,
'Дата': r.date,
'Сдано (₽)': r.given,
'Собрано (₽)': r.collected,
'Расхождение (₽)': r.diff,
'Статус': r.status === 'ok' ? 'Совпадает' : (r.status === 'warn' ? 'Мелкое расхождение' : 'Расхождение')
}));
downloadExcel(data, 'sverka_vyruchki.xlsx');
showStatus('Excel-отчёт скачан', 'success');
}
/* ================================================================
ОЧИСТКА
================================================================ */
function onClear() {
if (state.isProcessing) return;
state.files = [];
state.fileTypes = {};
state.parsedVedomosti = [];
state.parsedTransactions = [];
state.result = null;
dom.fileInput.value = '';
renderFileList();
updateUI();
hideResult();
showStatus('Все файлы удалены', 'info');
}
/* ================================================================
ОБНОВЛЕНИЕ UI
================================================================ */
function updateUI() {
const hasFiles = state.files.length > 0;
const hasResult = state.result !== null;
dom.btnReconcile.disabled = !hasFiles || state.isProcessing;
dom.btnClear.disabled = !hasFiles || state.isProcessing;
dom.btnExportCsv.disabled = !hasResult;
dom.btnDownload.disabled = !hasResult;
}
function hideResult() {
dom.emptyState.style.display = '';
dom.resultActions.style.display = 'none';
dom.tabsContainer.style.display = 'none';
}
/* ================================================================
СТАТУС-БАР
================================================================ */
function showStatus(message, type = 'info') {
const bar = dom.statusBar;
if (!bar) return;
bar.textContent = message;
bar.className = 'status-bar';
bar.classList.add('status-' + type);
bar.style.display = '';
// Авто-скрытие через 8 секунд для успеха и информации
if (type === 'success' || type === 'info') {
clearTimeout(bar._hideTimer);
bar._hideTimer = setTimeout(() => {
bar.style.display = 'none';
}, 8000);
}
}