Создан main.js — главный модуль: инициализация, обработчики событий, рендер результатов
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user